diff --git a/.github/workflows/periphery.yml b/.github/workflows/periphery.yml new file mode 100644 index 00000000..e33fc7c2 --- /dev/null +++ b/.github/workflows/periphery.yml @@ -0,0 +1,30 @@ +name: Dead Code + +on: + push: + branches: + - main + pull_request: + +jobs: + periphery: + name: Periphery scan + runs-on: macos-latest + timeout-minutes: 30 + # Advisory, like SwiftLint (#1361): findings annotate the PR but don't + # block merges. Drop continue-on-error once the baseline proves stable. + continue-on-error: true + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install Periphery + # homebrew-core formula; the peripheryapp tap lags years behind. + run: brew install periphery + + - name: Scan for dead code + # Config comes from .periphery.yml; known findings (mostly iOS-only + # code invisible to a macOS scan) are suppressed by the committed + # baseline. --strict fails the step when NEW dead code appears. + run: periphery scan --strict --disable-update-check diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 479ec616..81856b24 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -12,7 +12,10 @@ jobs: runs-on: macos-latest # A hung test must fail fast, not hold a runner for GitHub's 360-minute # default (observed: intermittent app-suite hangs starving the queue). - timeout-minutes: 15 + # The long steps carry tighter individual bounds (5-minute test watchdog, + # 6-minute benchmark step, 10-minute floor gate that may re-run the + # benchmarks up to twice on a noisy runner); this is the backstop. + timeout-minutes: 25 strategy: fail-fast: false # Don't cancel other matrix jobs when one fails @@ -29,17 +32,22 @@ jobs: - name: Checkout code uses: actions/checkout@v5 - - name: Set up Swift - uses: swift-actions/setup-swift@v2 + # Use the Xcode-bundled Swift toolchain: it always matches the SDK on + # the runner image. A standalone swift.org toolchain (setup-swift) broke + # whenever the image's Xcode moved ahead of it ("this SDK is not + # supported by the compiler"). + - name: Note toolchain version (cache key) + id: swift-version + run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT" - name: Cache build artifacts uses: actions/cache@v4 with: path: ${{ matrix.path }}/.build - key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }} + key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }} restore-keys: | - ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }} - ${{ runner.os }}-${{ matrix.name }}- + ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }} + ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}- - name: Build tests # Built separately so the hang watchdog below times only test @@ -97,9 +105,14 @@ jobs: # Order-of-magnitude performance regression gate. Floors are deliberately # generous (see bitchatTests/Performance/perf-floors.json) so this - # catches algorithmic regressions, never runner variance. + # catches algorithmic regressions, never runner variance. If a metric + # still lands below floor (a saturated runner can dip one), the script + # re-runs the benchmarks — appending to the same log and keeping each + # benchmark's best value per metric — so noise clears on retry while a + # real regression fails every attempt. Floors are never lowered by this. - name: Performance floor gate if: matrix.name == 'app' + timeout-minutes: 10 run: ./scripts/check-perf-floors.sh perf-output.log # Informational only: surfaces per-file and total line coverage in the @@ -118,10 +131,10 @@ jobs: echo "No coverage data found; skipping summary." fi - # SPM tests above only compile the macOS slice; this job covers the - # iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.). + # SPM tests do not link the shipping app targets. This job covers the + # iOS-conditional paths and both universal Release link configurations. ios-build: - name: Build iOS app (simulator) + name: Build Release apps (universal) runs-on: macos-latest timeout-minutes: 15 @@ -130,13 +143,52 @@ jobs: uses: actions/checkout@v5 - name: Build iOS (simulator, no signing) - # arm64 only: the vendored arti.xcframework has no x86_64 simulator slice. + # Build both simulator architectures so CI validates every vendored + # Arti simulator slice and the configuration that ships. run: | set -o pipefail xcodebuild -project bitchat.xcodeproj \ -scheme "bitchat (iOS)" \ + -configuration Release \ -sdk iphonesimulator \ -destination 'generic/platform=iOS Simulator' \ - ARCHS=arm64 \ + ARCHS='arm64 x86_64' \ + ONLY_ACTIVE_ARCH=NO \ CODE_SIGNING_ALLOWED=NO \ build + + - name: Build macOS (universal, no signing) + run: | + set -o pipefail + xcodebuild -project bitchat.xcodeproj \ + -scheme "bitchat (macOS)" \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + ARCHS='arm64 x86_64' \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + build + + # Advisory only: SwiftLint reports style violations without ever failing the + # build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so + # it can never break the documented xcodebuild path or block a merge. + lint: + name: SwiftLint (advisory) + runs-on: ubuntu-latest + timeout-minutes: 15 + # This job runs a third-party container image, so give it the least + # privilege we can: a read-only token, and no credentials left in the + # checkout for the container to find. + permissions: + contents: read + container: + # Tag for readability, digest for immutability (tags can be repointed). + # Bump both together, deliberately — never a floating tag. + image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e + continue-on-error: true + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Run SwiftLint + run: swiftlint lint --reporter github-actions-logging diff --git a/.periphery.baseline.json b/.periphery.baseline.json new file mode 100644 index 00000000..0b826869 --- /dev/null +++ b/.periphery.baseline.json @@ -0,0 +1 @@ +{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 00000000..fab8b45f --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,21 @@ +# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery) +# +# CI runs the macOS scheme only (an iOS scan needs a device destination and +# doubles the build time). macOS-only scans falsely flag iOS-only code — +# state restoration, screenshot handlers, background BLE sampling — so those +# findings live in .periphery.baseline.json rather than being "fixed". +# When auditing by hand, scan BOTH schemes and intersect: +# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64 +project: bitchat.xcodeproj +schemes: + - bitchat (macOS) +retain_swift_ui_previews: true +# Codable properties are (de)serialized via synthesized conformances the +# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle +# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk — +# and slipped past its baselined USR. Retaining Codable properties outright +# is deterministic; a truly-dead Codable field is a persisted-format change +# anyway, never a safe mechanical delete. +retain_codable_properties: true +relative_results: true +baseline: .periphery.baseline.json diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..833662e9 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,34 @@ +# Build artifacts and generated sources; keeps local `swiftlint` runs clean +# (CI checkouts are fresh, so this only matters in a working tree). +excluded: + - .build + - .claude + - .swiftpm + - .DerivedData + - DerivedData + - build + - localPackages/*/.build + +disabled_rules: + - line_length + - type_name + - identifier_name + - statement_position + - implicit_optional_initialization + - force_try + - vertical_whitespace + - for_where + - control_statement + - void_function_in_ternary + - redundant_discardable_let # SwiftUI breaks without it + # To be enabled as we fix the issues + - trailing_whitespace + - cyclomatic_complexity + - function_body_length + - function_parameter_count + - type_body_length + - file_length + - large_tuple + - force_cast + - multiple_closures_with_trailing_closure + - nesting diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig index b87e9064..f018b882 100644 --- a/Configs/Release.xcconfig +++ b/Configs/Release.xcconfig @@ -1,4 +1,4 @@ -MARKETING_VERSION = 1.5.3 +MARKETING_VERSION = 1.7.1 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 16.0 diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 03d770de..93edc3b6 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,165 +1,155 @@ # bitchat Privacy Policy -*Last updated: June 2026* +*Last updated: July 2026* ## Our Commitment -bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy. +bitchat is designed for private, account-free communication. This policy describes what the app keeps on your device, what it sends when you use mesh or optional internet features, and how long local data can remain. ## Summary -- **No personal data collection** - We don't collect names, emails, or phone numbers -- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays -- **No tracking** - We have no analytics, telemetry, or user tracking -- **Open source** - You can verify these claims by reading our code +- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays. +- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK. +- **No sale of data** — the project does not sell user data or build advertising profiles. +- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code. -## What Information bitchat Stores +## What bitchat Stores on Your Device -### On Your Device Only +1. **Identity and cryptographic keys** + - Noise, signing, group, prekey, and optional Nostr identity material is generated locally. + - Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events. + - Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app. -1. **Identity Keys** - - Cryptographic private keys generated on first launch or when optional Nostr identities are created - - Stored locally in your device's secure storage - - Allows you to maintain "favorite" relationships across app restarts - - Private keys never leave your device; public keys are shared when needed for messaging +2. **Nickname, preferences, and relationships** + - Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally. + - The share extension briefly places content you choose to share in the app-group preferences so the main app can import it. -2. **Nickname** - - The display name you choose (or auto-generated) - - Stored only on your device - - Shared with peers you communicate with +3. **Private group state** + - Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support. + - Current group keys are stored in the keychain. Group state remains until you leave or remove the group, panic-wipe the app, or remove the app. -3. **Message History** (if enabled) - - When room owners enable retention, messages are saved locally - - Stored encrypted on your device - - You can delete this at any time +4. **Queued and carried private messages** + - An outgoing private message that has not been acknowledged may remain for up to 24 hours in a bounded, encrypted outbox. The outbox is sealed with ChaCha20-Poly1305 and its key is stored in the keychain. + - A device acting as a courier may store a bounded opaque end-to-end encrypted envelope for another user for up to 24 hours. The courier cannot read its message content. + - A panic wipe deletes both stores. -4. **Favorite Peers** - - Public keys of peers you mark as favorites - - Stored only on your device - - Allows you to recognize these peers in future sessions +5. **Recent public mesh messages and notices** + - Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch. + - Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable. + - These items are public to the mesh or board where they are posted; they are not confidential messages. -5. **Optional Location Channel State** - - Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names - - Stored locally on your device so the location-channel UI can restore your choices - - Per-geohash Nostr identities are derived locally from a device seed stored in secure storage - - Exact latitude and longitude are not persisted by bitchat +6. **Media attachments** + - Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app. + - Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk. -### Temporary Session Data +7. **Optional location-channel state** + - Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them. + - Per-geohash Nostr identities are derived locally from a device seed stored in the keychain. + - bitchat does not persist exact latitude or longitude and does not include exact coordinates in mesh or Nostr messages. -During each session, bitchat temporarily maintains: -- Active peer connections (forgotten when app closes) -- Routing information for message delivery -- Cached messages for offline peers (12 hours max) -- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names +## Temporary Session Data -## What Information is Shared +While running, bitchat maintains active connections, routing state, deduplication state, and bounded in-memory conversation timelines. Closing the app clears the in-memory timelines and active connections, but it does not erase the persistent stores listed above. -### With Other bitchat Users +## What Is Shared -When you use bitchat, nearby peers can see: -- Your chosen nickname -- Your ephemeral public key (changes each session) -- Messages you send to public rooms or directly to them -- Your approximate Bluetooth signal strength (for connection quality) +### With Nearby Mesh Users -### With Room Members +Depending on the feature you use, nearby peers can receive: -When you join a password-protected room: -- Your messages are visible to others with the password -- Your nickname appears in the member list -- Room owners can see you've joined +- Your chosen nickname and public Noise/signing identity material. +- Announce metadata such as supported capability flags and a bounded list of short direct-neighbor identifiers. When the bridge is enabled, an announce can also include its coarse rendezvous geohash cell. +- Public mesh messages, public notices, and group-control packets you intentionally send. +- Private ciphertext addressed to them, or opaque courier ciphertext they agree to carry. +- Radio metadata available to the receiver, such as approximate Bluetooth signal strength. -### With Nostr Relays (Optional Features) +Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state. -If you enable Nostr-backed features: -- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content. -- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash. -- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level. -- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes. +### With Private Group Members -## What We DON'T Do +Private group members receive the group's name, roster, key epoch, and encrypted group traffic needed to participate. Group messages are confidential to devices holding the current group key, subject to the security of those devices and members. -bitchat **never**: -- Collects personal information -- Sells or shares your exact GPS location -- Stores data on servers we operate -- Sells your data to advertisers or data brokers -- Uses analytics or telemetry -- Creates user profiles -- Requires registration +### With Nostr Relays and Internet Gateways -## Encryption +Internet-backed features are optional. When enabled or used: -All private messages use end-to-end encryption: -- **X25519** for key exchange -- **AES-256-GCM** for message encryption -- **Ed25519** for digital signatures -- **Argon2id** for password-protected rooms +- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext. +- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area. +- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge. +- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata. +- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices. -## Your Rights +Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy. -You have complete control: -- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences -- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out -- **No Account**: No account record exists for you to delete from us -- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it +## Location and Apple Services -## Bluetooth & Permissions +Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels. -bitchat requires Bluetooth permission to function: -- Used only for peer-to-peer communication -- Bluetooth is not used for tracking -- You can revoke this permission at any time in system settings +- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat. +- A selected geohash can still reveal an approximate area to peers and relays. +- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms. +- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app. -## Location Permission +## Microphone, Camera, and Media Permissions -Location permission is optional and is used only for location channels: -- Used to compute local geohash channels and display names -- Requested as when-in-use permission -- Exact coordinates are not shared in messages or stored by bitchat -- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app -- You can revoke this permission at any time in system settings +- Microphone access is used only while you record a voice note or actively hold live push-to-talk. The resulting audio is sent to the mesh conversation you selected; public-conversation audio is public to that mesh, while private-conversation audio uses the private transport protections described below. +- Voice-note and live-audio files can remain in Application Support under the media retention rules above. +- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send. +- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive. + +## Cryptography + +Private and public features use different protections: + +- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256. +- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures. +- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. +- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM. +- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential. + +No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it. + +## Data Retention Summary + +- **In-memory chat timelines and active connections:** until the app closes or state is cleared. +- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first. +- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first. +- **Recent public mesh gossip:** up to 15 minutes. +- **Public board posts and tombstones:** until expiry, at most seven days. +- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal. +- **Nostr data:** according to the policies of the relays that receive it. + +## Your Controls + +- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app. +- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled. +- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings. +- **No account:** The project operates no account record for you to request or export. + +## What the Project Does Not Do + +bitchat does not: + +- Operate an account database or project-owned messaging backend. +- Include advertising, analytics, or tracking SDKs. +- Sell user data or create advertising profiles. +- Include exact GPS coordinates in bitchat mesh or Nostr message payloads. ## Children's Privacy -bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. - -## Data Retention - -- **Messages**: Deleted from memory when app closes (unless room retention is enabled) -- **Identity Key**: Persists until you delete the app -- **Favorites**: Persist until you remove them or delete the app -- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted -- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy -- **Everything Else**: Exists only during active sessions - -## Security Measures - -- All communication is encrypted -- No accounts or company servers -- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels -- Open source code for public audit -- Regular security updates -- Cryptographic signatures prevent tampering +The project does not knowingly operate a service that collects children's personal data. The app has no account registration or age-verification system. Users and guardians should understand that public mesh, board, bridge, and location-channel posts are visible to other participants and may be relayed. ## Changes to This Policy -If we update this policy: -- The "Last updated" date will change -- The updated policy will be included in the app -- No retroactive changes can make us collect data already held only in your app +Material behavior changes will be reflected in this document and its “Last updated” date. Updating this policy cannot retroactively retrieve data that remained only on a user's device. ## Contact bitchat is an open source project. For privacy questions: -- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main) -- Open an issue on GitHub -- Join the discussion in public rooms -## Philosophy - -Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely. +- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat) +- Open an issue on GitHub. --- -*This policy is released into the public domain under The Unlicense, just like bitchat itself.* +*This policy is released into the public domain under The Unlicense, like the project itself.* diff --git a/Package.swift b/Package.swift index 3f6e6b8f..7d447630 100644 --- a/Package.swift +++ b/Package.swift @@ -13,9 +13,9 @@ let package = Package( .executable( name: "bitchat", targets: ["bitchat"] - ), + ) ], - dependencies:[ + dependencies: [ .package(path: "localPackages/Arti"), .package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitLogger"), diff --git a/WHITEPAPER.md b/WHITEPAPER.md index 6ad839d7..7716850c 100644 --- a/WHITEPAPER.md +++ b/WHITEPAPER.md @@ -1,309 +1,141 @@ -# BitChat Protocol Whitepaper +# bitchat Protocol Whitepaper -**Version 1.1** +**Version 2.0** -**Date: July 25, 2025** +**Date: July 6, 2026** --- ## Abstract -BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network. +bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented. --- -## 1. Introduction +## 1. Design Goals -In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE). +* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext. +* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified. +* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership. +* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window. +* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. -The design goals of the BitChat Protocol are: +## 2. Architecture Overview -* **Confidentiality:** All communication must be unreadable to third parties. -* **Authentication:** Users must be able to verify the identity of their correspondents. -* **Integrity:** Messages cannot be tampered with in transit. -* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys. -* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message. -* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments. +Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`: -This paper specifies the technical details of the protocol designed to meet these goals. +* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts. +* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet. ---- +The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly. -## 2. Protocol Stack +## 3. Identity -The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility. +Each device holds two long-term key pairs in the Keychain: -```mermaid -graph TD - A[Application Layer] --> B[Session Layer]; - B --> C[Encryption Layer]; - C --> D[Transport Layer]; +* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and +* an **Ed25519 signing key** for packet signatures. - subgraph "BitChat Application" - A - end +On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person. - subgraph "Message Framing & State" - B - end +## 4. BLE Mesh Layer - subgraph "Noise Protocol Framework" - C - end +### 4.1 Packet Format - subgraph "BLE, Wi-Fi Direct, etc." - D - end +A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes. - style A fill:#cde4ff - style B fill:#b5d8ff - style C fill:#9ac2ff - style D fill:#7eadff -``` +### 4.2 Flood Control -* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data. -* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format. -* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption. -* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol. +Relaying is a deterministic controlled flood tuned by local connection degree: ---- +* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth. +* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay. +* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often. +* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon). +* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset. -## 3. Identity and Key Management +### 4.3 Routing -A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain. +Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding. -1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions. -2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname. +### 4.4 Fragmentation -### 3.1. Fingerprint +Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap). -A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code). +### 4.5 Presence -`Fingerprint = SHA256(StaticPublicKey_Curve25519)` +Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain. -### 3.2. Identity Management +## 5. Encryption -The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key. +### 5.1 Live Sessions: Noise XX ---- +Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets. -## 4. The Social Trust Layer +### 5.2 Offline Seals: Noise X -Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`. +Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work. -### 4.1. Peer Verification +### 5.3 Nostr Path -While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations. +Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content. -### 4.2. Favorites and Blocking +## 6. Store and Forward -To improve the user experience and provide control over interactions, the protocol supports: -* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently. -* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer. +Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode. ---- +### 6.1 Sender Outbox -## 5. The Noise Protocol Layer +Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext. -BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption. +### 6.2 Couriers -### 5.1. Protocol Name +When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient: -The specific Noise protocol implemented is: +* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days. +* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first. +* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires. +* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection). +* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes. +* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time. -**`Noise_XX_25519_ChaChaPoly_SHA256`** +### 6.3 Public History (Gossip Sync) -* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment. -* **`25519`:** The Diffie-Hellman function used is Curve25519. -* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305. -* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256. +Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window. -### 5.2. The `XX` Handshake +### 6.4 Nostr Mailboxes -The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys. +Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. -```mermaid -sequenceDiagram - participant I as Initiator - participant R as Responder +### 6.5 Delivery Metrics - Note over I, R: Pre-computation: h = SHA256(protocol_name) +Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe. - I->>R: -> e - Note right of I: I generates ephemeral key `e_i`.
h = SHA256(h + e_i.pub) +## 7. Application Layer - R->>I: <- e, ee, s, es - Note left of R: R generates ephemeral key `e_r`.
h = SHA256(h + e_r.pub)
MixKey(DH(e_i, e_r))
R sends static key `s_r`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(e_i, s_r)) - - I->>R: -> s, se - Note right of I: I decrypts and verifies `s_r`.
I sends static key `s_i`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(s_i, e_r)) - - Note over I, R: Handshake complete. Transport keys derived. -``` - -**Handshake Flow:** - -1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder. -2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`). -3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`). - -Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding. - -### 5.3. Session Management - -The `NoiseSessionManager` class manages all active Noise sessions. It handles: -* Creating sessions for new peers. -* Coordinating the handshake process to prevent race conditions. -* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`). -* Periodically checking if sessions need to be re-keyed for enhanced security. - ---- - -## 6. The BitChat Session and Application Protocol - -Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages. - -### 6.1. Binary Packet Format (`BitchatPacket`) - -To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis. - -| Field | Size (bytes) | Description | -|-----------------|--------------|---------------------------------------------------------------------------------------------------------| -| **Header** | **13** | **Fixed-size header** | -| Version | 1 | Protocol version (currently `1`). | -| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. | -| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. | -| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. | -| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). | -| Payload Length | 2 | `UInt16` length of the payload field. | -| **Variable** | **...** | **Variable-size fields** | -| Sender ID | 8 | 8-byte truncated peer ID of the sender. | -| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. | -| Payload | Variable | The actual content of the packet, as defined by the `Type` field. | -| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. | - -**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers. - -```mermaid ---- -config: - theme: dark ---- ---- -title: "BitchatPacket" ---- -packet -+8: "Version" -+8: "Type" -+8: "TTL" -+64: "Timestamp" -+8: "Flags" -+16: "Payload Length" -+64: "Sender ID" -+64: "Recipient ID (optional)" -+48: "Payload (variable)" -+64: "Signature (optional)" -``` -_A representation of the sizes of the fields in `BitchatPacket`_ - -### 6.2. Application Message Format (`BitchatMessage`) - -For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content. - -| Field | Size (bytes) | Description | -|---------------------|--------------|--------------------------------------------------------------------------| -| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). | -| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. | -| ID | 1 + len | `UUID` string for the message. | -| Sender | 1 + len | Nickname of the sender. | -| Content | 2 + len | The UTF-8 encoded message content. | -| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. | -| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. | - -```mermaid ---- -config: - theme: dark ---- ---- -title: "BitchatMessage" ---- -packet -+8: "Flags" -+64: "Timestamp" -+24: "ID (variable)" -+32: "Sender (variable)" -+32: "Content (variable)" -+32: "Original Sender (variable) (optional)" -+32: "Recipient Nickname (variable) (optional)" -``` -_A representation of the sizes of the fields in `BitchatMessage`_ - ---- - -## 7. Message Routing and Propagation - -BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery. - -### 7.1. Direct Connection - -This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake. - -### 7.2. Efficient Gossip with Bloom Filters - -To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs. - -The logic is as follows: - -1. A peer receives a packet. -2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers. -3. If the packet is new, its ID is added to the Bloom filter. -4. The peer decrements the packet's Time-To-Live (TTL) field. -5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet. - -This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops. - -### 7.3. Time-To-Live (TTL) - -Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh. - -### 7.4. Private vs. Broadcast Messages - -The routing logic respects the confidentiality of private messages: - -* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet. -* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network. - -### 7.5. Message Reliability and Lifecycle - -To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery. - -* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message. -* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen. -* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience. - -### 7.6. Fragmentation - -Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol. - -* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments. -* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data. -* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly. - -Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer. - ---- +* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above. +* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr. +* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range. +* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota. +* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only. +* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics. ## 8. Security Considerations -* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages. -* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer. -* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other. -* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks. -* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size. +* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext. +* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit. +* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting. +* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces. +* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor. +* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path. + +## 9. Future Work + +* Prekey-based forward secrecy for courier envelopes. +* Couriered media beyond the 16 KiB text cap. +* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs. +* Multi-hop courier routing informed by encounter history. --- -## 9. Conclusion - -The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments. +*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.* diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 5da57510..fe3543c2 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -92,7 +92,8 @@ A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - ShareViewController.swift, + Info.plist, + bitchatShareExtension.entitlements, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; }; @@ -258,9 +259,13 @@ buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */; buildPhases = ( 0A08E70F08F55FD5BA8C7EF3 /* Sources */, + 7E9B64F63F93443FB7BA12DF /* Resources */, ); buildRules = ( ); + fileSystemSynchronizedGroups = ( + A6E32D212E762EAB0032EA8A /* bitchatShareExtension */, + ); name = bitchatShareExtension; productName = bitchatShareExtension; productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; @@ -388,6 +393,13 @@ E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, ); }; + 7E9B64F63F93443FB7BA12DF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -528,7 +540,6 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -561,7 +572,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -620,7 +630,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -655,7 +664,6 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -716,7 +724,6 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -749,7 +756,6 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -816,7 +822,6 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -846,7 +851,6 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index a0710673..72b43b7d 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -36,34 +36,12 @@ enum AppEvent: Sendable, Equatable { actor AppEventStream { private var continuations: [UUID: AsyncStream.Continuation] = [:] - func stream() -> AsyncStream { - let id = UUID() - return AsyncStream { continuation in - continuations[id] = continuation - continuation.onTermination = { [id] _ in - Task { - await self.removeContinuation(id) - } - } - } - } - func emit(_ event: AppEvent) { for continuation in continuations.values { continuation.yield(event) } } - func finish() { - for continuation in continuations.values { - continuation.finish() - } - continuations.removeAll() - } - - private func removeContinuation(_ id: UUID) { - continuations.removeValue(forKey: id) - } } /// Identity key for a direct conversation. Equality and hashing use the diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 3f9cacd7..9db9e51e 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -10,6 +10,10 @@ final class AppChromeModel: ObservableObject { @Published var showingFingerprintFor: PeerID? @Published var isAppInfoPresented = false @Published var isLocationChannelsSheetPresented = false + @Published var isNoticesSheetPresented = false + /// When the sheet is opened for "notes left here" (empty mesh timeline), + /// it should land on the geo tab instead of the channel-derived default. + @Published var noticesSheetPrefersGeoTab = false @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown @@ -18,6 +22,9 @@ final class AppChromeModel: ObservableObject { private let chatViewModel: ChatViewModel private var cancellables = Set() + /// Bulletin-board coordinator, created on first use of the board sheet. + private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) + init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { self.chatViewModel = chatViewModel self.nickname = chatViewModel.nickname @@ -59,6 +66,33 @@ final class AppChromeModel: ObservableObject { isAppInfoPresented = true } + func presentNotices(geoTab: Bool = false) { + noticesSheetPrefersGeoTab = geoTab + isNoticesSheetPresented = true + } + + /// Builds the mesh topology map model from the transport's gossiped + /// graph plus the live nickname table. Unknown nodes (heard about via a + /// neighbor claim but never announced to us) fall back to a short ID. + func meshTopologyDisplayModel() -> MeshTopologyDisplayModel { + let mesh = chatViewModel.meshService + guard let snapshot = mesh.currentMeshTopology() else { return .empty } + let nicknames = mesh.getPeerNicknames() + + let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in + let isSelf = peerID == snapshot.localPeerID + let label: String + if isSelf { + label = chatViewModel.nickname + } else { + label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…" + } + return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf) + } + let edges = snapshot.edges.map { ($0.a.id, $0.b.id) } + return MeshTopologyDisplayModel(nodes: nodes, edges: edges) + } + func triggerScreenshotPrivacyWarning() { showScreenshotPrivacyWarning = true } diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index a706cfac..4c1e20a0 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -18,8 +18,6 @@ final class AppRuntime: ObservableObject { /// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models /// and `ChatViewModel` observe and mutate it through its intent API. let conversations: ConversationStore - let peerIdentityStore: PeerIdentityStore - let locationPresenceStore: LocationPresenceStore let publicChatModel: PublicChatModel let privateInboxModel: PrivateInboxModel let privateConversationModel: PrivateConversationModel @@ -28,6 +26,7 @@ final class AppRuntime: ObservableObject { let locationChannelsModel: LocationChannelsModel let peerListModel: PeerListModel let appChromeModel: AppChromeModel + let boardAlertsModel: BoardAlertsModel private let idBridge: NostrIdentityBridge private var cancellables = Set() @@ -41,7 +40,7 @@ final class AppRuntime: ObservableObject { #endif init( - keychain: KeychainManagerProtocol = KeychainManager(), + keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), idBridge: NostrIdentityBridge = NostrIdentityBridge() ) { self.idBridge = idBridge @@ -50,8 +49,6 @@ final class AppRuntime: ObservableObject { let locationPresenceStore = LocationPresenceStore() let locationManager = LocationChannelManager.shared self.conversations = conversations - self.peerIdentityStore = peerIdentityStore - self.locationPresenceStore = locationPresenceStore self.chatViewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, @@ -91,6 +88,24 @@ final class AppRuntime: ObservableObject { chatViewModel: self.chatViewModel, privateInboxModel: self.privateInboxModel ) + let chatViewModel = self.chatViewModel + self.boardAlertsModel = BoardAlertsModel( + arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(), + wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { post in + let key = chatViewModel.meshService.noiseSigningPublicKeyData() + return !key.isEmpty && key == post.authorSigningKey + }, + emitSystemLine: { content, geohash in + if geohash.isEmpty { + chatViewModel.addMeshOnlySystemMessage(content) + } else { + chatViewModel.addGeohashSystemMessage(content, geohash: geohash) + } + } + ) + ) GeoRelayDirectory.shared.prefetchIfNeeded() bindRuntimeObservers() @@ -202,7 +217,16 @@ final class AppRuntime: ObservableObject { chatViewModel.applicationWillTerminate() } - func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) { + func handleNotificationResponse( + identifier: String, + actionIdentifier: String = UNNotificationDefaultActionIdentifier, + userInfo: [AnyHashable: Any] + ) { + if actionIdentifier == NotificationService.waveActionID { + chatViewModel.sendMeshWave() + return + } + if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { record(.notificationOpened(peerID: peerID)) chatViewModel.startPrivateChat(with: peerID) @@ -289,21 +313,29 @@ private extension AppRuntime { } func checkForSharedContent() { - guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID), - let sharedContent = userDefaults.string(forKey: "sharedContent"), + guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return } + let clearSharedContent = { + userDefaults.removeObject(forKey: "sharedContent") + userDefaults.removeObject(forKey: "sharedContentType") + userDefaults.removeObject(forKey: "sharedContentDate") + } + + guard let sharedContent = userDefaults.string(forKey: "sharedContent"), let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { + // A partial or malformed handoff must not linger in the shared + // app-group container indefinitely. + clearSharedContent() return } guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else { + clearSharedContent() return } let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text - userDefaults.removeObject(forKey: "sharedContent") - userDefaults.removeObject(forKey: "sharedContentType") - userDefaults.removeObject(forKey: "sharedContentDate") + clearSharedContent() switch contentKind { case .url: diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 88d72085..8128cd38 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -225,8 +225,25 @@ final class Conversation: ObservableObject, Identifiable { guard let current else { return false } if current == new { return true } + // Never downgrade to a weaker delivery state. Ordering of certainty: + // sending < sent < carried < delivered < read. A late `.sent` write + // (e.g. the optimistic stamp after routing) must not clobber the + // `.carried` the router already set when it handed a copy to a + // courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous + // failure is weaker than a confirmed recipient receipt too, so it may + // not replace `.delivered`/`.read`. Same for the + // `.sending` stamp a pre-handshake resend emits asynchronously: it + // can land after the message already reached `.sent`, and "Sent" was + // already truthful. (`.failed` → `.sending` stays allowed so a real + // failure retry is visible.) switch (current, new) { - case (.read, .delivered), (.read, .sent): + case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed): + return true + case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed): + return true + case (.carried, .sent), (.carried, .sending): + return true + case (.sent, .sending): return true default: return false @@ -796,16 +813,6 @@ extension ConversationStore { return messageIDs } - /// Removes every direct conversation (panic clear). - func removeAllDirectConversations() { - let directIDs = conversationIDs.filter { id in - if case .direct = id { return true } - return false - } - for id in directIDs { - removeConversation(id) - } - } } // MARK: - Diagnostics support diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index f012edd3..683a9dee 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -12,6 +12,9 @@ final class ConversationUIModel: ObservableObject { @Published private(set) var currentNickname: String @Published private(set) var isBatchingPublic = false @Published private(set) var canSendMediaInCurrentContext = true + /// Who is talking live in the public mesh channel right now (floor + /// courtesy: the composer mic tints "busy" while someone holds the floor). + @Published private(set) var activeLiveVoiceTalker: String? private let chatViewModel: ChatViewModel private let privateConversationModel: PrivateConversationModel @@ -49,6 +52,14 @@ final class ConversationUIModel: ObservableObject { chatViewModel.sendMessage(message) } + /// Resends a failed private message through the normal send path, + /// removing the failed original so the re-submission replaces it + /// instead of stacking a duplicate under the red bubble. + func resendFailedPrivateMessage(_ message: BitchatMessage) { + chatViewModel.removePrivateMessage(withID: message.id) + chatViewModel.sendMessage(message.content) + } + func clearCurrentConversation() { chatViewModel.sendMessage("/clear") } @@ -67,11 +78,23 @@ final class ConversationUIModel: ObservableObject { if let peerID, peerID.isGeoChat, let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) { chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName) + } else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat { + // Mesh: block the peer's stable Noise identity resolved from the + // tapped peerID rather than re-resolving a display-name string. + chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName) } else { chatViewModel.sendMessage("/block \(displayName)") } } + /// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by + /// the tapped peer's stable identity so the exact row is unblocked — this + /// also works for offline peers, which the `/unblock ` command + /// cannot resolve. + func unblock(peerID: PeerID, displayName: String) { + chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName) + } + func updateAutocomplete(for text: String, cursorPosition: Int) { chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) } @@ -130,6 +153,17 @@ final class ConversationUIModel: ObservableObject { chatViewModel.sendVoiceNote(at: url) } + /// Capture backend for the mic gesture: live PTT when the current DM + /// peer can hear it now, classic voice note otherwise. + func makeVoiceCaptureSession() -> VoiceCaptureSession { + chatViewModel.makeVoiceCaptureSession() + } + + /// Whether this message is a live voice burst still streaming in. + func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool { + chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message) + } + func cancelMediaSend(messageID: String) { chatViewModel.cancelMediaSend(messageID: messageID) } @@ -155,6 +189,10 @@ final class ConversationUIModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$isBatchingPublic) + chatViewModel.$activePublicVoiceTalker + .receive(on: DispatchQueue.main) + .assign(to: &$activeLiveVoiceTalker) + conversations.$activeChannel .receive(on: DispatchQueue.main) .sink { [weak self] channel in @@ -173,7 +211,9 @@ final class ConversationUIModel: ObservableObject { private func refreshComputedState() { if let selectedPeerID = privateConversationModel.selectedPeerID { - canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat) + // Media transfer is not wired for groups in v1; keep it off so the + // composer can't strand a media placeholder that never sends. + canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup) return } diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift index c820455a..25cb2b9f 100644 --- a/bitchat/App/LocationChannelsModel.swift +++ b/bitchat/App/LocationChannelsModel.swift @@ -1,4 +1,3 @@ -import BitFoundation import Combine import Foundation @@ -12,20 +11,25 @@ final class LocationChannelsModel: ObservableObject { @Published private(set) var bookmarkNames: [String: String] @Published private(set) var locationNames: [GeohashChannelLevel: String] @Published private(set) var userTorEnabled: Bool + @Published private(set) var gatewayEnabled: Bool private let manager: LocationChannelManager private let network: NetworkActivationService - private var cancellables = Set() + private let gateway: GatewayService init( manager: LocationChannelManager? = nil, - network: NetworkActivationService? = nil + network: NetworkActivationService? = nil, + gateway: GatewayService? = nil ) { let manager = manager ?? .shared let network = network ?? .shared + let gateway = gateway ?? .shared self.manager = manager self.network = network + self.gateway = gateway + self.gatewayEnabled = gateway.isEnabled self.permissionState = manager.permissionState self.availableChannels = manager.availableChannels self.selectedChannel = manager.selectedChannel @@ -160,6 +164,10 @@ final class LocationChannelsModel: ObservableObject { network.$userTorEnabled .receive(on: DispatchQueue.main) .assign(to: &$userTorEnabled) + + gateway.$isEnabled + .receive(on: DispatchQueue.main) + .assign(to: &$gatewayEnabled) } private func level(forLength length: Int) -> GeohashChannelLevel { diff --git a/bitchat/App/NearbyNotesCounter.swift b/bitchat/App/NearbyNotesCounter.swift new file mode 100644 index 00000000..ae173bbd --- /dev/null +++ b/bitchat/App/NearbyNotesCounter.swift @@ -0,0 +1,138 @@ +// +// NearbyNotesCounter.swift +// bitchat +// +// Counts unexpired location notes left at the user's current building-level +// geohash so the empty mesh timeline can say "📍 3 notes left here". Only +// subscribes while a view holds it active, and only when location notes are +// enabled and location permission is already granted (it never prompts). +// This is free and unencumbered software released into the public domain. +// + +import Combine +import Foundation + +@MainActor +final class NearbyNotesCounter: ObservableObject { + static let shared = NearbyNotesCounter() + + @Published private(set) var noteCount = 0 + /// Whether an explicit notes act (the empty-timeline "check for notes" + /// tap, opening the notices sheet's geo tab, or a successful /drop) has + /// unlocked the counter this session. Until then nothing subscribes: + /// merely looking at the mesh timeline must not open a building-precision + /// relay REQ that leaks location passively. + @Published private(set) var revealed = false + + private var manager: LocationNotesManager? + private var managerCancellable: AnyCancellable? + private var channelsCancellable: AnyCancellable? + private var permissionCancellable: AnyCancellable? + private var settingCancellable: AnyCancellable? + private var activeHolders = 0 + private let locationManager: LocationChannelManager + private let managerFactory: @MainActor (String) -> LocationNotesManager + private let releaseManager: @MainActor (LocationNotesManager?) -> Void + + init( + locationManager: LocationChannelManager = .shared, + managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) }, + releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) } + ) { + self.locationManager = locationManager + self.managerFactory = managerFactory + self.releaseManager = releaseManager + } + + /// Whether the empty-timeline "check for notes" hint should render. + /// The permission gate matters: `retarget()` never subscribes without + /// location authorization, so offering the hint to an unauthorized + /// install would be a silent dead-end — tap, `revealed` flips, the hint + /// vanishes, and nothing else happens for the session. The hint never + /// prompts; it simply stays hidden until permission exists. The caller + /// passes its own observed permission state so the hint re-renders when + /// authorization changes. + func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool { + !revealed && LocationNotesSettings.enabled && permissionState == .authorized + } + + /// Marks the one explicit act that lets the counter subscribe. Sticky for + /// the rest of the session (the singleton's lifetime); `deactivate()` + /// deliberately does not reset it. + func reveal() { + guard !revealed else { return } + revealed = true + retarget() + } + + /// Begins (or keeps) the notes subscription for the current building + /// geohash. Balanced by `deactivate()`; ref-counted so multiple views can + /// hold it. + func activate() { + activeHolders += 1 + guard activeHolders == 1 else { return } + channelsCancellable = locationManager.$availableChannels + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.retarget() } + // CoreLocation can revoke authorization while the view remains + // mounted. `availableChannels` deliberately retains its last value, + // so permission must be an independent invalidation signal or the + // building REQ survives on stale coordinates. + permissionCancellable = locationManager.$permissionState + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.retarget() } + // The app-info kill switch must take effect immediately, not on the + // next location change or remount. + settingCancellable = NotificationCenter.default + .publisher(for: LocationNotesSettings.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.retarget() } + retarget() + } + + func deactivate() { + activeHolders = max(0, activeHolders - 1) + guard activeHolders == 0 else { return } + channelsCancellable = nil + permissionCancellable = nil + settingCancellable = nil + managerCancellable = nil + releaseManager(manager) + manager = nil + noteCount = 0 + } + + private func retarget() { + guard activeHolders > 0, + revealed, + LocationNotesSettings.enabled, + locationManager.permissionState == .authorized, + let geohash = locationManager.availableChannels + .first(where: { $0.level == .building })?.geohash + else { + managerCancellable = nil + releaseManager(manager) + manager = nil + noteCount = 0 + return + } + + if let manager { + guard manager.geohash != geohash.lowercased() else { return } + // Pooled managers are shared; never retarget one in place — + // release the old cell and acquire the new one. + managerCancellable = nil + releaseManager(manager) + self.manager = nil + } + + let fresh = managerFactory(geohash) + manager = fresh + managerCancellable = fresh.$notes + .receive(on: DispatchQueue.main) + .sink { [weak self] notes in + let now = Date() + self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count + } + } +} diff --git a/bitchat/App/PeerIdentityStore.swift b/bitchat/App/PeerIdentityStore.swift index 119d765f..e7e49f01 100644 --- a/bitchat/App/PeerIdentityStore.swift +++ b/bitchat/App/PeerIdentityStore.swift @@ -25,10 +25,6 @@ final class PeerIdentityStore: ObservableObject { stablePeerIDsByShortID[peerID] = stablePeerID } - func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) { - stablePeerIDsByShortID = mappings - } - func fingerprint(for peerID: PeerID) -> String? { peerFingerprintsByPeerID[peerID] } @@ -94,10 +90,6 @@ final class PeerIdentityStore: ObservableObject { invalidateEncryptionCache(for: peerID) } - func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) { - encryptionStatuses = statuses - } - func setVerifiedFingerprints(_ fingerprints: Set) { verifiedFingerprints = fingerprints } diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 38f20a1c..b96c2e72 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -14,6 +14,9 @@ struct MeshPeerRow: Identifiable, Equatable { let isMutualFavorite: Bool let encryptionStatus: EncryptionStatus let showsVerifiedBadgeWhenOffline: Bool + /// Vouched-for by someone I verified, without an explicit verification of + /// mine — rendered as the unfilled seal (verified gets the filled one). + let showsVouchedBadge: Bool var id: String { peerID.id } } @@ -26,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable { let isBlocked: Bool } +struct GroupChatRow: Identifiable, Equatable { + let peerID: PeerID + let name: String + let memberCount: Int + let isCreator: Bool + let hasUnread: Bool + + var id: String { peerID.id } +} + @MainActor final class PeerListModel: ObservableObject { @Published private(set) var allPeers: [BitchatPeer] = [] @Published private(set) var meshRows: [MeshPeerRow] = [] @Published private(set) var geohashPeople: [GeohashPersonRow] = [] + @Published private(set) var groupRows: [GroupChatRow] = [] @Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0 @@ -129,6 +143,13 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + chatViewModel.groupStore.$groups + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + peerIdentityStore.$encryptionStatuses .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -183,13 +204,12 @@ final class PeerListModel: ObservableObject { let myPeerID = chatViewModel.meshService.myPeerID let meshRows = allPeers.map { peer in let isMe = peer.peerID == myPeerID - let verifiedBadge: Bool - if !isMe && !peer.isConnected, - let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) { - verifiedBadge = peerIdentityStore.isVerified(fingerprint) - } else { - verifiedBadge = false - } + let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID) + let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false + let verifiedBadge = !peer.isConnected && isVerifiedFingerprint + // Vouched is subordinate to verified: never show both seals. + let vouchedBadge = !isVerifiedFingerprint + && (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false) return MeshPeerRow( peerID: peer.peerID, @@ -202,7 +222,8 @@ final class PeerListModel: ObservableObject { isReachable: peer.isReachable, isMutualFavorite: peer.isMutualFavorite, encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), - showsVerifiedBadgeWhenOffline: verifiedBadge + showsVerifiedBadgeWhenOffline: verifiedBadge, + showsVouchedBadge: vouchedBadge ) } @@ -217,22 +238,40 @@ final class PeerListModel: ObservableObject { } let geohashPeople = buildGeohashPeople() + let groupRows = buildGroupRows() self.meshRows = meshRows reachableMeshPeerCount = meshCounts.reachable connectedMeshPeerCount = meshCounts.connected self.geohashPeople = geohashPeople visibleGeohashPeerCount = geohashPeople.count + self.groupRows = groupRows renderID = ( meshRows.map { "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" } + geohashPeople.map { "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" + } + + groupRows.map { + "group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)" } ).joined(separator: "|") } + private func buildGroupRows() -> [GroupChatRow] { + let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint() + return chatViewModel.groupStore.groups.map { group in + GroupChatRow( + peerID: group.peerID, + name: group.name, + memberCount: group.members.count, + isCreator: group.creatorFingerprint == myFingerprint, + hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID) + ) + } + } + private func buildGeohashPeople() -> [GeohashPersonRow] { let myHex = currentGeohashIdentityHex() let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() }) diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 71871984..d9920646 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable { let encryptionStatus: EncryptionStatus? var supportsFavoriteToggle: Bool { - !conversationPeerID.isGeoDM + !conversationPeerID.isGeoDM && !conversationPeerID.isGroup + } + + /// Group chats have no single peer identity behind the header: no + /// fingerprint screen, no per-peer encryption badge. + var isGroupConversation: Bool { + conversationPeerID.isGroup } } @@ -206,6 +212,13 @@ final class PrivateConversationModel: ObservableObject { } .store(in: &cancellables) + chatViewModel.groupStore.$groups + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -229,10 +242,36 @@ final class PrivateConversationModel: ObservableObject { } private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { + // Group chats: the "peer" is the whole crew. Name + member count in + // the header; availability reads as mesh since group traffic floods + // the local mesh, and the per-peer encryption badge does not apply. + if conversationPeerID.isGroup { + let displayName: String + if let group = chatViewModel.groupStore.group(for: conversationPeerID) { + displayName = "#\(group.name) (\(group.members.count))" + } else { + displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer") + } + return PrivateConversationHeaderState( + conversationPeerID: conversationPeerID, + headerPeerID: conversationPeerID, + displayName: displayName, + availability: .meshReachable, + isFavorite: false, + encryptionStatus: nil + ) + } + let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) - let availability = resolveAvailability(for: headerPeerID, peer: peer) + // Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys + // never resolve to a reachable mesh peer, so resolveAvailability would + // report .offline. Report .nostrAvailable so the header shows the + // globe instead of a misleading "offline" tag. + let availability = conversationPeerID.isGeoDM + ? .nostrAvailable + : resolveAvailability(for: headerPeerID, peer: peer) let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM ? nil : chatViewModel.getEncryptionStatus(for: headerPeerID) diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift index bf31f05f..d9c2ce08 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -3,12 +3,19 @@ import Combine import Foundation struct FingerprintPresentationState: Equatable { - let statusPeerID: PeerID let peerNickname: String let encryptionStatus: EncryptionStatus let theirFingerprint: String? let myFingerprint: String let isVerified: Bool + /// Number of currently-valid vouches from peers the user verified + /// (0 when the peer is explicitly verified — the stronger badge wins). + let voucherCount: Int + /// Display names of the (verified) vouchers, where known. + let voucherNames: [String] + + /// Vouched for by ≥1 peer the user verified (and not explicitly verified). + var isVouched: Bool { voucherCount > 0 } var canToggleVerification: Bool { encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified @@ -48,10 +55,6 @@ final class VerificationModel: ObservableObject { return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? "" } - func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { - chatViewModel.beginQRVerification(with: qr) - } - func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome { guard let qr = VerificationService.shared.verifyScannedQR(payload) else { return .invalid @@ -82,14 +85,33 @@ final class VerificationModel: ObservableObject { let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID) let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) + let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + + // Vouch state is recomputed on read: only vouchers still in the + // verified set count, so removing a verification silently retires the + // vouches that peer gave. + let vouchers: [VouchRecord] + if !isVerified, let theirFingerprint { + vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint) + } else { + vouchers = [] + } + let voucherNames = vouchers.compactMap { record -> String? in + guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else { + return nil + } + if let petname = social.localPetname, !petname.isEmpty { return petname } + return social.claimedNickname.isEmpty ? nil : social.claimedNickname + } return FingerprintPresentationState( - statusPeerID: statusPeerID, peerNickname: peerNickname, encryptionStatus: encryptionStatus, theirFingerprint: theirFingerprint, myFingerprint: chatViewModel.getMyFingerprint(), - isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + isVerified: isVerified, + voucherCount: vouchers.count, + voucherNames: voucherNames ) } @@ -122,6 +144,17 @@ final class VerificationModel: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + + // Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged) + // are signalled via this notification rather than a published + // property, so an open fingerprint sheet refreshes its vouched badge + // live when a vouch batch is accepted. + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png index f85ced2c..c9298e1a 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png index 6516c53c..7dfe0762 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png index fa6a8cf0..e20339bf 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png index 84b3ea2c..cf0a709f 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png index 6516c53c..7dfe0762 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png index 5c73d4e6..930a4bb2 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png index 84b3ea2c..cf0a709f 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png index 58e93947..500352c4 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png index 5c73d4e6..930a4bb2 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png index 9bf9b82a..46801070 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json b/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json index b74e0441..72bc51f7 100644 --- a/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json +++ b/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json @@ -27,6 +27,66 @@ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" + }, + { + "filename" : "mac_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "mac_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "mac_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "mac_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "mac_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "mac_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "mac_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "mac_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "mac_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "mac_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" } ], "info" : { diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128.png new file mode 100644 index 00000000..9915a654 Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128@2x.png new file mode 100644 index 00000000..b5458dca Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16.png new file mode 100644 index 00000000..c5a0c8e1 Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16@2x.png new file mode 100644 index 00000000..5a292f43 Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256.png new file mode 100644 index 00000000..b5458dca Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256@2x.png new file mode 100644 index 00000000..95912d2f Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32.png new file mode 100644 index 00000000..5a292f43 Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32@2x.png new file mode 100644 index 00000000..1cea92dd Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512.png new file mode 100644 index 00000000..95912d2f Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512@2x.png new file mode 100644 index 00000000..691785a8 Binary files /dev/null and b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512@2x.png differ diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 4f2e4199..c1a39fff 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -40,6 +40,7 @@ struct BitchatApp: App { .environmentObject(runtime.locationChannelsModel) .environmentObject(runtime.peerListModel) .environmentObject(runtime.appChromeModel) + .environmentObject(runtime.boardAlertsModel) .onAppear { appDelegate.runtime = runtime runtime.start() @@ -71,7 +72,7 @@ struct BitchatApp: App { final class AppDelegate: NSObject, UIApplicationDelegate { weak var runtime: AppRuntime? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { true } @@ -103,12 +104,20 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let identifier = response.notification.request.identifier + let actionIdentifier = response.actionIdentifier let userInfo = response.notification.request.content.userInfo + // Complete only after the response is handled: for a background + // action (👋 wave) the system may suspend the app the moment the + // completion handler runs, which would drop the queued send. Task { @MainActor in - self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo) + self.runtime?.handleNotificationResponse( + identifier: identifier, + actionIdentifier: actionIdentifier, + userInfo: userInfo + ) + completionHandler() } - completionHandler() } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { diff --git a/bitchat/Features/voice/AudioSessionCoordinator.swift b/bitchat/Features/voice/AudioSessionCoordinator.swift new file mode 100644 index 00000000..b92b1549 --- /dev/null +++ b/bitchat/Features/voice/AudioSessionCoordinator.swift @@ -0,0 +1,472 @@ +// +// AudioSessionCoordinator.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import AVFoundation +import BitLogger +import Foundation + +/// The raw audio-session calls the coordinator makes, abstracted so the +/// state machine is unit-testable with a mock (and compiles on the macOS +/// test host, where `AVAudioSession` doesn't exist). +/// +/// Calls arrive on the coordinator's private serial queue — never the main +/// thread. `setCategory`/`setActive` block on IPC to the audio server +/// (observed >1 s under contention on device, tripping the system gesture +/// gate), and Apple explicitly recommends activating the session off the +/// main thread. +protocol SessionApplying: Sendable { + func setCategory(_ category: AudioSessionCoordinator.Category) throws + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws +} + +/// Sole owner of `AVAudioSession` category/activation for voice features. +/// +/// Talk-over means capture (push-to-talk) and playback (inbound bursts, +/// voice notes) can be live simultaneously; letting each engine configure +/// the shared session directly made them stomp each other's category and +/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every +/// client acquires a `Token` and the coordinator: +/// +/// - reference-counts activation: `setActive(true)` only on the first +/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the +/// last one releases — no client can deactivate another's session; +/// - keeps one escalating category: playback-only holders get `.playback`, +/// any capture holder escalates to `.playAndRecord`, and the category is +/// never downgraded while anyone still holds a token (capture ending must +/// not yank the route out from under live playback); +/// - fans out `onInterrupted` on system interruptions and when the active +/// route's device disappears (no auto-resume: bursts are transient, the +/// next press or burst simply re-acquires). The escalating category change +/// fans out separately as `onCategoryEscalated` — the session stays live, +/// so holders that can rebuild their engine against the new configuration +/// keep playing (talk-over is bidirectional); holders that don't provide +/// it fall back to `onInterrupted`. +/// +/// Threading: all state lives on a private serial queue, which both +/// serializes rapid acquire/release pairs and keeps the blocking session IPC +/// off the main thread (`acquire` is `async` for exactly that hop; `release` +/// is fire-and-forget onto the queue). Holder callbacks always run on the +/// main actor. +/// +/// Microphone *permission* queries stay with their callers; this type owns +/// only category and activation. +/// +/// `@unchecked Sendable`: every mutable property is confined to `queue`. +final class AudioSessionCoordinator: @unchecked Sendable { + enum Use { + case playback + case capture + } + + /// The session category the coordinator has applied (the `SessionApplying` + /// adapter maps these to concrete `AVAudioSession` category/mode/options). + enum Category { + case playback + case playAndRecord + } + + /// Opaque handle for one client's hold on the session. Release exactly + /// once when done (extra releases are ignored). + /// + /// `@unchecked` because the stored callbacks are `@MainActor`-isolated + /// closures (non-Sendable as stored types). Lifecycle state is protected + /// by `stateLock`, and callbacks are only ever invoked on the main actor. + final class Token: @unchecked Sendable { + fileprivate enum CallbackKind: Sendable { + case interrupted + case categoryEscalated + } + + /// A callback snapshot is only valid for the lifecycle epoch in which + /// it was captured. `release` advances the epoch synchronously before + /// its queue work, so a callback already headed to the main actor can't + /// reach a client that has since released this token and reacquired a + /// different one. + fileprivate struct CallbackTicket: Sendable { + let token: Token + let kind: CallbackKind + let lifecycleEpoch: UInt64 + } + + private enum Lifecycle { + /// Registered on the session queue, but `acquire` has not yet + /// returned into the client's main-actor call frame. + case acquiring + case ready + case released + } + + fileprivate let onInterrupted: @MainActor () -> Void + fileprivate let onCategoryEscalated: (@MainActor () -> Void)? + private let stateLock = NSLock() + private var lifecycle = Lifecycle.acquiring + private var lifecycleEpoch: UInt64 = 0 + /// A terminal event that lands while the token is registered but not + /// yet handed off invalidates the acquire before its caller can start. + private var terminalEventPendingHandoff = false + + fileprivate init( + onInterrupted: @escaping @MainActor () -> Void, + onCategoryEscalated: (@MainActor () -> Void)? + ) { + self.onInterrupted = onInterrupted + self.onCategoryEscalated = onCategoryEscalated + } + + /// Records an event at the same linearization point at which the + /// coordinator snapshots its holders. An acquiring token cannot safely + /// receive a callback yet: terminal events invalidate the acquire, + /// while category escalation needs no callback because its engine will + /// start against the already-escalated configuration. + fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? { + stateLock.withLock { + switch lifecycle { + case .acquiring: + switch kind { + case .interrupted: + terminalEventPendingHandoff = true + case .categoryEscalated: + break + } + return nil + case .ready: + return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch) + case .released: + return nil + } + } + } + + /// Completes the main-actor ownership handoff if no terminal event + /// invalidated it. Because `acquire` itself is main-actor isolated, a + /// successful handoff returns directly into the caller without another + /// actor hop; no callback can interleave before the caller stores the + /// returned token. + fileprivate func completeHandoff() -> Bool { + stateLock.withLock { + guard lifecycle == .acquiring, + !terminalEventPendingHandoff + else { return false } + lifecycle = .ready + return true + } + } + + /// Marks the token dead synchronously, before the asynchronous holder + /// removal. Returns false for an already-released token. + fileprivate func markReleased() -> Bool { + stateLock.withLock { + guard lifecycle != .released else { return false } + lifecycle = .released + lifecycleEpoch &+= 1 + terminalEventPendingHandoff = false + return true + } + } + + /// Revalidates a queue snapshot at the main-actor delivery boundary. + /// The lock is deliberately released before invoking client code: real + /// callbacks commonly call `release` on this same token. + @MainActor + fileprivate func deliver(_ ticket: CallbackTicket) { + let isLive = stateLock.withLock { + lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch + } + guard isLive else { return } + switch ticket.kind { + case .interrupted: + onInterrupted() + case .categoryEscalated: + (onCategoryEscalated ?? onInterrupted)() + } + } + } + + /// Deterministic suspension points for lifecycle race tests. Production + /// instances use the nil defaults; the hooks never move session calls off + /// the coordinator queue or callback execution off the main actor. + struct TestingHooks: Sendable { + let beforeAcquireHandoff: (@Sendable () async -> Void)? + let beforeCallbackDelivery: (@Sendable () async -> Void)? + + init( + beforeAcquireHandoff: (@Sendable () async -> Void)? = nil, + beforeCallbackDelivery: (@Sendable () async -> Void)? = nil + ) { + self.beforeAcquireHandoff = beforeAcquireHandoff + self.beforeCallbackDelivery = beforeCallbackDelivery + } + } + + static let shared = AudioSessionCoordinator(session: SystemAudioSession()) + + private let session: SessionApplying + private let testingHooks: TestingHooks + /// Confines all mutable state, serializes whole acquire/release + /// operations (two rapid presses can't interleave their category and + /// activation calls), and hosts the blocking session IPC off main. + private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated) + + // Queue-confined state. + private var holders: [ObjectIdentifier: Token] = [:] + private var currentCategory: Category? + private var sessionActive = false + /// Written once in init, read in deinit — never touched concurrently. + private var observers: [NSObjectProtocol] = [] + + init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) { + self.session = session + self.testingHooks = testingHooks + observeSystemNotifications() + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Configures + activates the session for `use` and registers the caller + /// as a holder. The blocking `AVAudioSession` calls run on the session + /// queue — the caller suspends instead of stalling its thread (a PTT + /// press used to block main >1 s in `setActive`, tripping the system + /// gesture gate). `onInterrupted` fires (on the main actor) when the + /// client must stop using the session: a system interruption began or + /// its route's device went away. The client should stop its engine, + /// finalize any artifacts, and release — resuming means acquiring again. + /// + /// `onCategoryEscalated` fires instead when the session category + /// escalated underneath the holder (a capture client joined): the session + /// stays active, so a holder that can rebuild its engine against the new + /// configuration should restart and keep going. Holders that pass `nil` + /// get `onInterrupted` for escalation too. Escalation is delivered before + /// `acquire` returns, so the new holder starts its engine strictly after + /// existing ones were told to rebuild. Main-actor isolation is also the + /// ownership handoff boundary: if interruption or route loss lands after + /// queue registration but before that boundary, the provisional holder is + /// removed and `acquire` throws `CancellationError` instead of returning a + /// token whose callback already fired. + @MainActor + func acquire( + _ use: Use, + onInterrupted: @escaping @MainActor () -> Void, + onCategoryEscalated: (@MainActor () -> Void)? = nil + ) async throws -> Token { + let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated) + let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try self.activateOnQueue(use, registering: token)) + } catch { + continuation.resume(throwing: error) + } + } + } + + // Escalating playback -> playAndRecord reconfigures the hardware + // route; engines started against the old configuration must restart. + if !reconfigured.isEmpty { + SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session) + await deliver(reconfigured) + } + if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff { + await beforeAcquireHandoff() + } + guard token.completeHandoff() else { + // A call/Siri interruption or route loss landed after registration + // but before ownership handoff. Remove the provisional holder and + // fail instead of starting a client engine after the stop event. + release(token) + throw CancellationError() + } + return token + } + + /// Drops one holder. Deactivates the session (notifying other apps) only + /// when the last holder releases. Safe to call more than once, from any + /// thread (including `deinit` paths): the work is fire-and-forget onto + /// the session queue, so the blocking deactivation IPC never runs on the + /// caller. + func release(_ token: Token) { + guard token.markReleased() else { return } + queue.async { + self.releaseOnQueue(token) + } + } + + // MARK: - Queue-confined core + + /// Returns callback tickets for pre-existing live holders whose engines + /// must restart because this acquire escalated the category. + private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] { + let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback + let categoryChanged = target != currentCategory + let previousCategory = currentCategory + if categoryChanged { + try session.setCategory(target) + currentCategory = target + } + if !sessionActive { + do { + try session.setActive(true, notifyOthersOnDeactivation: false) + } catch { + // Activation failed (e.g. a phone call owns the hardware): + // with no holder registered, an escalated category recorded + // here would stick and pin later playback-only acquires to + // .playAndRecord. Existing holders keep the category the + // hardware really has. + if categoryChanged, holders.isEmpty { + currentCategory = previousCategory + } + throw error + } + sessionActive = true + } + + let reconfigured = categoryChanged + ? holders.values.compactMap { $0.record(.categoryEscalated) } + : [] + holders[ObjectIdentifier(token)] = token + return reconfigured + } + + private func releaseOnQueue(_ token: Token) { + guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return } + guard holders.isEmpty else { return } + currentCategory = nil + guard sessionActive else { return } + sessionActive = false + do { + try session.setActive(false, notifyOthersOnDeactivation: true) + } catch { + SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session) + } + } + + private func onQueue(_ body: @escaping @Sendable () -> T) async -> T { + await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: body()) + } + } + } + + @MainActor + private func deliver(_ tickets: [Token.CallbackTicket]) async { + guard !tickets.isEmpty else { return } + if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery { + await beforeCallbackDelivery() + } + for ticket in tickets { + ticket.token.deliver(ticket) + } + } + + // MARK: - System events (internal so tests can drive them directly) + + /// A system interruption began: the session is already deactivated by the + /// OS, so just mark it inactive and tell every ready holder (on the main + /// actor) to stop. A provisional acquiring holder is invalidated instead. + /// No auto-resume — the next acquire re-activates. + func handleInterruptionBegan() async { + let tickets = await onQueue { () -> [Token.CallbackTicket] in + self.sessionActive = false + return self.holders.values.compactMap { $0.record(.interrupted) } + } + await deliver(tickets) + } + + /// The active route's input/output device disappeared (e.g. BT headset + /// off): ready holders' engines are wedged against a dead route — stop + /// them; invalidate a holder whose acquire has not returned yet. + func handleRouteDeviceUnavailable() async { + let tickets = await onQueue { + self.holders.values.compactMap { $0.record(.interrupted) } + } + await deliver(tickets) + } + + /// Test hook: suspends until every session operation enqueued before this + /// call — including fire-and-forget `release`s — has completed. + func drain() async { + await onQueue {} + } + + private func observeSystemNotifications() { + #if os(iOS) + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] note in + guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + AVAudioSession.InterruptionType(rawValue: raw) == .began, + let self + else { return } + SecureLogger.info("AudioSession: interruption began", category: .session) + Task { await self.handleInterruptionBegan() } + }) + observers.append(center.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] note in + guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, + AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable, + let self + else { return } + SecureLogger.info("AudioSession: route device became unavailable", category: .session) + Task { await self.handleRouteDeviceUnavailable() } + }) + #endif + } +} + +// MARK: - Production adapter + +#if os(iOS) +private struct SystemAudioSession: SessionApplying { + func setCategory(_ category: AudioSessionCoordinator.Category) throws { + let session = AVAudioSession.sharedInstance() + switch category { + case .playback: + try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) + case .playAndRecord: + // allowBluetoothHFP is not available on iOS Simulator + #if targetEnvironment(simulator) + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers] + ) + #else + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers] + ) + #endif + } + } + + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { + try AVAudioSession.sharedInstance().setActive( + active, + options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : [] + ) + } +} +#else +/// macOS has no app-level audio session; the coordinator still runs its +/// bookkeeping so client code is identical across platforms. +private struct SystemAudioSession: SessionApplying { + func setCategory(_ category: AudioSessionCoordinator.Category) throws {} + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {} +} +#endif diff --git a/bitchat/Features/voice/PTTAudioCodec.swift b/bitchat/Features/voice/PTTAudioCodec.swift new file mode 100644 index 00000000..0b8ed6f3 --- /dev/null +++ b/bitchat/Features/voice/PTTAudioCodec.swift @@ -0,0 +1,175 @@ +// +// PTTAudioCodec.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import AVFoundation +import BitLogger +import Foundation + +/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder +/// carries a bit reservoir across frames); one instance per burst. +/// Not thread-safe — confine to one queue. +final class PTTFrameEncoder { + private let converter: AVAudioConverter + private var pendingInput: [AVAudioPCMBuffer] = [] + + init?() { + guard let pcm = PTTAudioFormat.pcmFormat, + let aac = PTTAudioFormat.aacFormat, + let converter = AVAudioConverter(from: pcm, to: aac) + else { return nil } + converter.bitRate = PTTAudioFormat.bitRate + self.converter = converter + } + + /// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the + /// encoder produced. Frames come out ~130 bytes each at 16 kbps. + func encode(_ buffer: AVAudioPCMBuffer) -> [Data] { + pendingInput.append(buffer) + return drainConverter() + } + + private func drainConverter() -> [Data] { + var frames: [Data] = [] + while true { + let output = AVAudioCompressedBuffer( + format: converter.outputFormat, + packetCapacity: 8, + maximumPacketSize: max(converter.maximumOutputPacketSize, 1) + ) + var error: NSError? + let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in + guard let self, let next = self.pendingInput.first else { + outStatus.pointee = .noDataNow + return nil + } + self.pendingInput.removeFirst() + outStatus.pointee = .haveData + return next + } + if status == .error { + SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session) + return frames + } + frames.append(contentsOf: Self.extractPackets(from: output)) + // .haveData means the output buffer filled and more may be ready; + // anything else means the converter wants more input. + if status != .haveData { return frames } + } + } + + private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] { + guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] } + var frames: [Data] = [] + frames.reserveCapacity(Int(buffer.packetCount)) + for index in 0.. 0 else { continue } + let start = buffer.data.advanced(by: Int(description.mStartOffset)) + frames.append(Data(bytes: start, count: Int(description.mDataByteSize))) + } + return frames + } +} + +/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per +/// inbound burst. Not thread-safe — confine to one queue/actor. +final class PTTFrameDecoder { + private let converter: AVAudioConverter + private let pcmFormat: AVAudioFormat + private let aacFormat: AVAudioFormat + + init?() { + guard let pcm = PTTAudioFormat.pcmFormat, + let aac = PTTAudioFormat.aacFormat, + let converter = AVAudioConverter(from: aac, to: pcm) + else { return nil } + self.converter = converter + self.pcmFormat = pcm + self.aacFormat = aac + } + + /// Decodes one raw AAC frame to PCM. Returns nil for malformed input or + /// while the decoder is still priming (the first frame of a stream). + func decode(_ frame: Data) -> AVAudioPCMBuffer? { + guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil } + + let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count) + frame.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + input.data.copyMemory(from: base, byteCount: frame.count) + } + input.byteLength = UInt32(frame.count) + input.packetCount = 1 + input.packetDescriptions?.pointee = AudioStreamPacketDescription( + mStartOffset: 0, + mVariableFramesInPacket: 0, + mDataByteSize: UInt32(frame.count) + ) + + guard let output = AVAudioPCMBuffer( + pcmFormat: pcmFormat, + frameCapacity: PTTAudioFormat.samplesPerFrame * 2 + ) else { return nil } + + var consumed = false + var error: NSError? + let status = converter.convert(to: output, error: &error) { _, outStatus in + if consumed { + outStatus.pointee = .noDataNow + return nil + } + consumed = true + outStatus.pointee = .haveData + return input + } + guard status != .error else { + SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session) + return nil + } + return output.frameLength > 0 ? output : nil + } +} + +/// Sample-rate/channel converter from the microphone's native format to the +/// 16 kHz mono processing format. Stateful; not thread-safe. +final class PTTInputResampler { + private let converter: AVAudioConverter + private let outputFormat: AVAudioFormat + private let ratio: Double + + init?(inputFormat: AVAudioFormat) { + guard let pcm = PTTAudioFormat.pcmFormat, + let converter = AVAudioConverter(from: inputFormat, to: pcm) + else { return nil } + self.converter = converter + self.outputFormat = pcm + self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate + } + + func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? { + let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64 + guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil } + + var consumed = false + var error: NSError? + let status = converter.convert(to: output, error: &error) { _, outStatus in + if consumed { + outStatus.pointee = .noDataNow + return nil + } + consumed = true + outStatus.pointee = .haveData + return buffer + } + guard status != .error else { + SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session) + return nil + } + return output.frameLength > 0 ? output : nil + } +} diff --git a/bitchat/Features/voice/PTTAudioFormat.swift b/bitchat/Features/voice/PTTAudioFormat.swift new file mode 100644 index 00000000..39e5a61a --- /dev/null +++ b/bitchat/Features/voice/PTTAudioFormat.swift @@ -0,0 +1,84 @@ +// +// PTTAudioFormat.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import AVFoundation +import Foundation + +/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono, +/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings +/// so a burst's finalized `.m4a` and its live frames sound the same. +enum PTTAudioFormat { + static let sampleRate: Double = 16_000 + static let channelCount: AVAudioChannelCount = 1 + static let bitRate = 16_000 + /// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz. + static let samplesPerFrame: AVAudioFrameCount = 1024 + static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate } + + /// Uncompressed processing format (deinterleaved float PCM). + static var pcmFormat: AVAudioFormat? { + AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount) + } + + /// Compressed wire format. + static var aacFormat: AVAudioFormat? { + var description = AudioStreamBasicDescription( + mSampleRate: sampleRate, + mFormatID: kAudioFormatMPEG4AAC, + mFormatFlags: 0, + mBytesPerPacket: 0, + mFramesPerPacket: samplesPerFrame, + mBytesPerFrame: 0, + mChannelsPerFrame: channelCount, + mBitsPerChannel: 0, + mReserved: 0 + ) + return AVAudioFormat(streamDescription: &description) + } + + /// Voice-note container settings for the finalized `.m4a`, mirroring + /// `VoiceRecorder.startRecording()`. + static var voiceNoteFileSettings: [String: Any] { + [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: sampleRate, + AVNumberOfChannelsKey: Int(channelCount), + AVEncoderBitRateKey: bitRate + ] + } +} + +/// Builds ADTS-framed AAC so a receiver can persist a burst progressively: +/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac` +/// stream is playable at any prefix — a partially received burst is still a +/// replayable voice note. +enum ADTSFramer { + private static let headerSize = 7 + /// MPEG-4 sampling frequency index for 16 kHz. + private static let samplingFrequencyIndex: UInt8 = 8 + private static let channelConfiguration: UInt8 = 1 + + /// Wraps one raw AAC-LC frame in an ADTS header. + static func frame(_ aacFrame: Data) -> Data { + let frameLength = aacFrame.count + headerSize + var data = Data(capacity: frameLength) + // Syncword 0xFFF, MPEG-4, layer 00, no CRC. + data.append(0xFF) + data.append(0xF1) + // Profile AAC-LC (audio object type 2 -> bits 01), frequency index, + // private bit 0, channel config high bit. + data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1)) + data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3)) + data.append(UInt8((frameLength >> 3) & 0xFF)) + data.append(UInt8((frameLength & 0x7) << 5) | 0x1F) + // Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame. + data.append(0xFC) + data.append(aacFrame) + return data + } +} diff --git a/bitchat/Features/voice/PTTBurstPlayer.swift b/bitchat/Features/voice/PTTBurstPlayer.swift new file mode 100644 index 00000000..272906f4 --- /dev/null +++ b/bitchat/Features/voice/PTTBurstPlayer.swift @@ -0,0 +1,509 @@ +// +// PTTBurstPlayer.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +@preconcurrency import AVFoundation +import BitLogger +import Foundation + +/// The engine operations behind live-burst playback, abstracted so the +/// player's lifecycle (jitter start, category-escalation restart, stop) is +/// unit-testable without real audio hardware. +@MainActor +protocol PTTPlaybackEngine: AnyObject { + /// The object `AVAudioEngineConfigurationChange` notifications are posted + /// for (nil for mocks — no observer is registered). + var configChangeObject: AnyObject? { get } + func start() throws + func play() + func stop() + func schedule( + _ buffer: AVAudioPCMBuffer, + completionType: PTTPlaybackCompletionType, + completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void + ) +} + +/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled +/// buffer. `dataConsumed` only means the node no longer needs the bytes; it +/// may arrive before the render pipeline has made the audio audible. +enum PTTPlaybackCompletionType: Equatable, Sendable { + case dataConsumed + case dataPlayedBack +} + +enum PTTPlaybackCompletionEvent: Equatable, Sendable { + case dataConsumed + case dataPlayedBack + /// AVAudioPlayerNode invokes the requested callback when the node is + /// stopped too. That is not audible completion and must remain replayable. + case playbackStopped +} + +/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start: +/// an engine instantiated against an earlier audio-session configuration keeps +/// rendering to the stale route (same class of failure as the capture side's +/// fresh-engine-per-press rule). +@MainActor +private final class SystemPTTPlaybackEngine: PTTPlaybackEngine { + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + + init(format: AVAudioFormat) { + engine.attach(node) + engine.connect(node, to: engine.mainMixerNode, format: format) + } + + var configChangeObject: AnyObject? { engine } + + func start() throws { + engine.prepare() + try engine.start() + } + + func play() { + node.play() + } + + func stop() { + node.stop() + engine.stop() + } + + func schedule( + _ buffer: AVAudioPCMBuffer, + completionType: PTTPlaybackCompletionType, + completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void + ) { + let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType { + case .dataConsumed: .dataConsumed + case .dataPlayedBack: .dataPlayedBack + } + let scheduledEngine = engine + node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in + // The API invokes this callback when the player is stopped as + // well. A configuration change can stop the engine before its + // notification reaches MainActor, so do not misclassify that + // flushed tail as audible playback. + guard scheduledEngine?.isRunning == true else { + completionHandler(.playbackStopped) + return + } + switch callbackType { + case .dataConsumed: + completionHandler(.dataConsumed) + case .dataRendered: + completionHandler(.dataConsumed) + case .dataPlayedBack: + completionHandler(.dataPlayedBack) + @unknown default: + completionHandler(.playbackStopped) + } + } + } +} + +/// Completion callbacks arrive off the main actor, while engine rebuilds are +/// serialized on it. This small lock-backed latch lets a rebuild atomically +/// claim only buffers whose completion has not already fired — even when the +/// callback's hop back to the main actor is still queued. +private final class PTTPlaybackCompletionState: @unchecked Sendable { + private enum State { + case scheduled + case completed + case retired + } + + private let lock = NSLock() + private var state: State = .scheduled + + /// Returns true exactly once when playback completion wins the race with + /// an engine rebuild or stop. + func complete() -> Bool { + lock.withLock { + guard case .scheduled = state else { return false } + state = .completed + return true + } + } + + /// Returns true exactly once when a rebuild or stop claims this + /// still-pending schedule. Later callbacks from that engine are stale. + func retireIfPending() -> Bool { + lock.withLock { + guard case .scheduled = state else { return false } + state = .retired + return true + } + } +} + +/// Plays one inbound live voice burst with a small jitter buffer. +/// +/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`; +/// an underrun (missing/late packets) simply pauses output until the next +/// buffer arrives, which self-heals timing without explicit silence +/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds` +/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed. +/// +/// Talk-over is bidirectional: when push-to-talk capture starts while this +/// burst plays, the session category escalates underneath the engine — the +/// player rebuilds a fresh engine against the new configuration and keeps +/// streaming instead of dying. Real interruptions (phone call, route device +/// gone) still stop it; the burst keeps assembling to file either way. +@MainActor +final class PTTBurstPlayer { + /// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a + /// handful of category/route changes is plenty — beyond it something is + /// thrashing and stopping cleanly beats an engine-rebuild loop. + private static let maxEngineRestarts = 8 + + private let makeEngine: @MainActor () -> PTTPlaybackEngine + private var engine: PTTPlaybackEngine + private let decoder: PTTFrameDecoder + private let coordinator: AudioSessionCoordinator + /// Injectable so tests don't fight over the app-wide exclusive-playback + /// slot (a parallel test's `play()` would stop this player mid-test). + private let exclusivity: VoiceNotePlaybackCoordinator + + private var queuedBuffers: [AVAudioPCMBuffer] = [] + private var queuedDuration: TimeInterval = 0 + private struct ScheduledBuffer { + let id: UInt64 + let buffer: AVAudioPCMBuffer + let completionState: PTTPlaybackCompletionState + } + /// Buffers handed to the current engine whose completion has not yet + /// been processed on the main actor. Keeping the buffers themselves lets + /// a category-escalation rebuild replay the unfinished tail in order. + private var scheduledBuffers: [ScheduledBuffer] = [] + private var nextScheduledBufferID: UInt64 = 0 + /// Bumped on every engine rebuild or stop so completion tasks from a + /// torn-down engine cannot mutate the current generation's pending list. + private var engineGeneration = 0 + private var engineRestarts = 0 + private var engineStarted = false + private var finished = false + /// Latched off (internal read so tests can await the async failure path). + private(set) var stopped = false + /// A session acquire is in flight (it suspends off-main for the blocking + /// session IPC); gates `startIfReady` against double acquisition. + private var acquiringSession = false + private var deadlineTask: Task? + private var sessionToken: AudioSessionCoordinator.Token? + /// Reserved before the session acquire suspends. Activation succeeds only + /// if no newer playback request claimed the floor in the meantime. + private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation? + private var configChangeObserver: NSObjectProtocol? + + private(set) var isPlaying = false + + /// Fires exactly once when the player stops for good (drain-out, cancel, + /// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark + /// the draining player it keeps alive after the assembly — the player's + /// only long-lived owner — is discarded on burst END. + var onStopped: (() -> Void)? + + init?( + coordinator: AudioSessionCoordinator? = nil, + exclusivity: VoiceNotePlaybackCoordinator? = nil, + makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil + ) { + guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil } + self.decoder = decoder + self.coordinator = coordinator ?? .shared + self.exclusivity = exclusivity ?? .shared + let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) } + self.makeEngine = factory + self.engine = factory() + + deadlineTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000)) + self?.startIfReady(force: true) + } + } + + deinit { + // Backstop for an owner dropping the player before it stopped: the + // session coordinator retains registered tokens strongly, so a token + // leaked here would keep the session active (and pin any escalated + // category) for the app's lifetime. `release` is fire-and-forget + // onto the coordinator's queue, so it is deinit-safe. + if let token = sessionToken { + coordinator.release(token) + } + if let observer = configChangeObserver { + NotificationCenter.default.removeObserver(observer) + } + deadlineTask?.cancel() + } + + /// Decodes and queues frames (in burst order). Starts playback when the + /// jitter buffer fills. + func enqueue(_ frames: [Data]) { + guard !stopped else { return } + for frame in frames { + guard let pcm = decoder.decode(frame) else { continue } + if engineStarted { + schedule(pcm) + } else { + queuedBuffers.append(pcm) + queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate + } + } + startIfReady(force: false) + } + + /// The burst ended: stop once everything scheduled has played out. + func finishAfterDrain() { + finished = true + // The complete burst is queued — no jitter left to wait for. This + // also matters when END lands while the async session acquire is + // still in flight: the queued audio must play out, not be treated + // as already drained. + startIfReady(force: true) + stopIfDrained() + } + + /// Immediate stop (cancel, another playback taking over, interruption, + /// teardown). + func stop() { + guard !stopped else { return } + stopped = true + deadlineTask?.cancel() + removeConfigObserver() + queuedBuffers = [] + retireScheduledBuffers() + if engineStarted { + engine.stop() + } + isPlaying = false + releaseSessionToken() + exclusivity.deactivate(self) + onStopped?() + } + + private func startIfReady(force: Bool) { + guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return } + guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return } + + // Acquiring the session suspends for its blocking IPC (off the main + // actor); frames arriving meanwhile keep queueing and are flushed + // onto the engine once it starts. + acquiringSession = true + playbackReservation = exclusivity.reserve(self) + Task { [weak self] in + await self?.acquireSessionAndStart() + } + } + + private func acquireSessionAndStart() async { + let token: AudioSessionCoordinator.Token + do { + token = try await coordinator.acquire( + .playback, + onInterrupted: { [weak self] in self?.stop() }, + onCategoryEscalated: { [weak self] in self?.restartEngine() } + ) + } catch { + acquiringSession = false + SecureLogger.error("PTT playback session activation failed: \(error)", category: .session) + // Playing unregistered would leave the engine exposed: another + // holder's last release deactivates the session mid-play, and no + // interruption/escalation fan-out ever reaches us. Bail like the + // engine-start failure below; the burst still assembles to file. + // (stop() also fires onStopped so a parked draining player is + // unparked instead of leaking.) + stop() + return + } + acquiringSession = false + // stop() (cancel, exclusivity, teardown) may have landed while the + // session was activating: hand the token straight back. + guard !stopped else { + coordinator.release(token) + return + } + sessionToken = token + guard let playbackReservation, + exclusivity.isCurrent(playbackReservation, for: self) + else { + // The request was superseded while audio-session activation was + // suspended. Do not even start the retired engine. + stop() + return + } + + // Observe reconfiguration before starting so nothing lands between. + registerConfigObserver() + do { + try engine.start() + } catch { + // A capture racing this start can reconfigure the session while + // the engine spins up (its escalation fan-out no-ops on a player + // that never started): rebuild once against the settled + // configuration — counted against the restart cap — before + // giving up. + SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session) + removeConfigObserver() + engineRestarts += 1 + engine = makeEngine() + registerConfigObserver() + do { + try engine.start() + } catch { + SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session) + // stop() removes the observer, hands the token back, and + // fires onStopped for any parked draining owner. + stop() + return + } + } + engineStarted = true + guard exclusivity.activate(self, reservation: playbackReservation) + else { + // A newer user-initiated playback reserved the floor while this + // older PTT request was suspended in audio-session activation. + // Never let the late completion steal playback back. + stop() + return + } + isPlaying = true + engine.play() + + let buffered = queuedBuffers + queuedBuffers = [] + queuedDuration = 0 + for buffer in buffered { + schedule(buffer) + } + } + + /// The audio session was reconfigured underneath the running engine + /// (category escalation for talk-over, or an engine configuration + /// change): rebuild a fresh engine against the new configuration and + /// keep streaming. Buffers already completed stay completed; the + /// unfinished scheduled tail is replayed in order on the fresh engine, + /// and frames still arriving continue scheduling after it. + private func restartEngine() { + guard engineStarted, !stopped else { return } + engineRestarts += 1 + guard engineRestarts <= Self.maxEngineRestarts else { + SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session) + stop() + return + } + + removeConfigObserver() + // Claim the unfinished tail before stopping the old engine. Stopping + // a player node may itself invoke its completion handlers; retiring + // the claimed entries first makes those callbacks unambiguously stale. + // A completion that fired just before this rebuild wins the latch and + // is excluded even if its MainActor task has not run yet. + let buffersToReplay = scheduledBuffers.compactMap { scheduled in + scheduled.completionState.retireIfPending() ? scheduled.buffer : nil + } + scheduledBuffers = [] + engineGeneration += 1 + engine.stop() + engine = makeEngine() + registerConfigObserver() + do { + try engine.start() + } catch { + SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session) + stop() + return + } + engine.play() + for buffer in buffersToReplay { + schedule(buffer) + } + SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session) + // If every old buffer completed before the rebuild, a finished burst + // can stop now. Otherwise the replayed tail keeps it alive until its + // new-generation completions arrive. + stopIfDrained() + } + + private func registerConfigObserver() { + guard let object = engine.configChangeObject else { return } + configChangeObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: object, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.restartEngine() + } + } + } + + private func removeConfigObserver() { + if let observer = configChangeObserver { + NotificationCenter.default.removeObserver(observer) + configChangeObserver = nil + } + } + + private func schedule(_ buffer: AVAudioPCMBuffer) { + let id = nextScheduledBufferID + nextScheduledBufferID &+= 1 + let completionState = PTTPlaybackCompletionState() + scheduledBuffers.append(ScheduledBuffer( + id: id, + buffer: buffer, + completionState: completionState + )) + let generation = engineGeneration + engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in + guard event == .dataPlayedBack else { return } + // Mark completion before hopping to MainActor. A rebuild can then + // distinguish already-completed audio from an unfinished tail + // even when this task has not run yet. + guard completionState.complete() else { return } + Task { @MainActor [weak self] in + guard let self, self.engineGeneration == generation else { return } + self.scheduledBuffers.removeAll { $0.id == id } + self.stopIfDrained() + } + } + } + + private func retireScheduledBuffers() { + engineGeneration += 1 + for scheduled in scheduledBuffers { + _ = scheduled.completionState.retireIfPending() + } + scheduledBuffers = [] + } + + private func stopIfDrained() { + guard finished, scheduledBuffers.isEmpty else { return } + // Started: everything scheduled has played out. Never started with + // nothing queued or in flight (e.g. no decodable frames): nothing + // will ever play. Otherwise the engine start is still pending (the + // async session acquire) and the queued audio must play out first. + guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return } + stop() + } + + private func releaseSessionToken() { + sessionToken.map(coordinator.release) + sessionToken = nil + } +} + +extension PTTBurstPlayer: ExclusivePlayback { + /// A live stream can't meaningfully pause; yielding the floor stops it. + /// The burst keeps assembling to file, so nothing is lost. + nonisolated func pauseForExclusivity() { + Task { @MainActor [weak self] in + self?.stop() + } + } +} diff --git a/bitchat/Features/voice/PTTCaptureEngine.swift b/bitchat/Features/voice/PTTCaptureEngine.swift new file mode 100644 index 00000000..4d29181d --- /dev/null +++ b/bitchat/Features/voice/PTTCaptureEngine.swift @@ -0,0 +1,326 @@ +// +// PTTCaptureEngine.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import AVFoundation +import BitLogger +import Foundation + +/// Owns one capture token and returns it even when the capture engine's owner +/// disappears without reaching its normal stop/cancel path. The coordinator +/// retains registered tokens strongly, so relying on `Token.deinit` cannot +/// reclaim an abandoned hold. +final class PTTCaptureSessionLease: @unchecked Sendable { + private let coordinator: AudioSessionCoordinator + private let lock = NSLock() + private var token: AudioSessionCoordinator.Token? + + init(coordinator: AudioSessionCoordinator) { + self.coordinator = coordinator + } + + func install(_ token: AudioSessionCoordinator.Token) { + let previous = lock.withLock { + let previous = self.token + self.token = token + return previous + } + previous.map(coordinator.release) + } + + func release() { + let token = lock.withLock { + let token = self.token + self.token = nil + return token + } + token.map(coordinator.release) + } + + deinit { + release() + } +} + +/// Monotonic capture identity shared by main-actor lifecycle code and queued +/// engine callbacks. Removing a notification observer does not cancel a block +/// already enqueued on the main queue, so every callback must also prove it +/// still belongs to the current hold before mutating capture state. +final class PTTCaptureGeneration: @unchecked Sendable { + private let lock = NSLock() + private var value: UInt = 0 + + func begin() -> UInt { + lock.withLock { + value &+= 1 + return value + } + } + + func invalidate() { + lock.withLock { value &+= 1 } + } + + func invalidate(ifCurrent generation: UInt) -> Bool { + lock.withLock { + guard value == generation else { return false } + value &+= 1 + return true + } + } + + func isCurrent(_ generation: UInt) -> Bool { + lock.withLock { value == generation } + } +} + +/// Captures microphone audio for a live push-to-talk burst, producing both: +/// - live AAC frames via `onFrames` (called on the capture queue), and +/// - a finalized `.m4a` voice note on `stop()` — the same artifact +/// `VoiceRecorder` produces, so the existing voice-note send pipeline +/// handles delivery to receivers that missed the live stream. +/// `@unchecked Sendable`: every mutable property is confined to one executor — +/// the capture `queue` (resampler/encoder/file/counters) or the main actor +/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) — so +/// weak references may cross the `@Sendable` tap/notification closures, which +/// immediately hop back to the owning executor. +final class PTTCaptureEngine: @unchecked Sendable { + /// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the + /// engine keeps running (the UI owns the gesture) but stops encoding. + private static let maxCaptureDuration: TimeInterval = 120 + + /// Recreated on every `start()`: an engine whose input unit was + /// instantiated against an earlier (playback-only or inactive) audio + /// session keeps reporting a dead 0 Hz / 2 ch input format and fails to + /// enable the mic (AURemoteIO -10851, observed on iPhone field tests). + private var engine = AVAudioEngine() + private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated) + private let coordinator: AudioSessionCoordinator + private let sessionLease: PTTCaptureSessionLease + private let captureGeneration = PTTCaptureGeneration() + + // Capture-queue-confined state. + private var resampler: PTTInputResampler? + private var encoder: PTTFrameEncoder? + private var file: AVAudioFile? + private var fileURL: URL? + private var encodedFrameCount = 0 + private var running = false + private var captureStart = Date() + /// Whether `engine.start()` succeeded for the current capture + /// (see `stopEngineIfStarted`). + @MainActor private var engineStarted = false + @MainActor private var configChangeObserver: NSObjectProtocol? + /// Called on the capture queue with each batch of encoded AAC frames. + var onFrames: (([Data]) -> Void)? + + enum CaptureError: Error { + case inputUnavailable + case audioSetupFailed + } + + init(coordinator: AudioSessionCoordinator = .shared) { + self.coordinator = coordinator + self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator) + } + + deinit { + sessionLease.release() + } + + /// Async because acquiring the session hops its blocking IPC off the main + /// actor (a PTT press used to stall main >1 s in `setActive`); the engine + /// itself still starts back on main once the session is configured. + @MainActor + func start(outputURL: URL) async throws { + let generation = captureGeneration.begin() + let token = try await coordinator.acquire(.capture) { [weak self] in + self?.handleInterruption(for: generation) + } + // The hold ended (stop/cancel) while the session was activating: + // starting the engine now would leave a hot mic after release. + guard captureGeneration.isCurrent(generation) else { + coordinator.release(token) + throw CancellationError() + } + sessionLease.install(token) + do { + try beginCapture(outputURL: outputURL, generation: generation) + } catch { + releaseSessionToken() + throw error + } + } + + @MainActor + private func beginCapture(outputURL: URL, generation: UInt) throws { + // Fresh engine per capture so its input unit binds to the session + // that is active *now* (see `engine` doc comment). + engine = AVAudioEngine() + let inputFormat = engine.inputNode.outputFormat(forBus: 0) + guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { + SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session) + throw CaptureError.inputUnavailable + } + guard let resampler = PTTInputResampler(inputFormat: inputFormat), + let encoder = PTTFrameEncoder(), + let pcmFormat = PTTAudioFormat.pcmFormat + else { throw CaptureError.audioSetupFailed } + + let file = try AVAudioFile( + forWriting: outputURL, + settings: PTTAudioFormat.voiceNoteFileSettings, + commonFormat: pcmFormat.commonFormat, + interleaved: pcmFormat.isInterleaved + ) + + queue.sync { + self.resampler = resampler + self.encoder = encoder + self.file = file + self.fileURL = outputURL + self.encodedFrameCount = 0 + self.captureStart = Date() + self.running = true + } + + engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in + self?.queue.async { self?.process(buffer, generation: generation) } + } + // Route/category changes reconfigure the engine underneath the tap; + // stop and finalize cleanly — the .m4a captured so far still sends. + // Registered before start() so no reconfigure lands unobserved + // (handleInterruption also validates this capture generation). + configChangeObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: engine, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleInterruption(for: generation) + } + } + engine.prepare() + do { + try engine.start() + } catch { + SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session) + if let observer = configChangeObserver { + NotificationCenter.default.removeObserver(observer) + configChangeObserver = nil + } + engine.inputNode.removeTap(onBus: 0) + queue.sync { self.teardown(deleteFile: true) } + throw error + } + engineStarted = true + SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session) + } + + /// Stops capture and finalizes the `.m4a`. Returns the file URL and the + /// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long). + @MainActor + func stop() -> (url: URL?, encodedFrames: Int) { + captureGeneration.invalidate() + stopEngineIfStarted() + let result: (URL?, Int) = queue.sync { + let url = fileURL + let frames = encodedFrameCount + teardown(deleteFile: false) + return (url, frames) + } + releaseSessionToken() + return result + } + + @MainActor + func cancel() { + captureGeneration.invalidate() + stopEngineIfStarted() + queue.sync { teardown(deleteFile: true) } + releaseSessionToken() + } + + /// Audio session interrupted (call, Siri) or the engine was reconfigured + /// mid-capture: behave like `stop()` — finalize the `.m4a` container but + /// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()` + /// still returns the note for delivery. + @MainActor + private func handleInterruption(for generation: UInt) { + // Also invalidate a start whose acquire has registered its token but + // has not returned to this actor yet. Without this bump the callback + // is lost while `engineStarted == false`, and the resumed start can + // open the mic after the stop signal. + guard captureGeneration.invalidate(ifCurrent: generation) else { return } + guard engineStarted else { + releaseSessionToken() + return + } + stopEngineIfStarted() + queue.sync { + running = false + // Releasing the AVAudioFile finalizes the .m4a container. + file = nil + encoder = nil + resampler = nil + } + releaseSessionToken() + SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session) + } + + /// Touching `inputNode` on an engine that never started instantiates its + /// input unit against whatever session is active and spams AURemoteIO + /// errors — a canceled-before-start hold must not touch the engine. + @MainActor + private func stopEngineIfStarted() { + if let observer = configChangeObserver { + NotificationCenter.default.removeObserver(observer) + configChangeObserver = nil + } + guard engineStarted else { return } + engineStarted = false + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + + @MainActor + private func releaseSessionToken() { + sessionLease.release() + } + + // MARK: - Capture queue + + private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) { + guard captureGeneration.isCurrent(generation), + running, + Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration, + let resampled = resampler?.resample(buffer) + else { return } + + do { + try file?.write(from: resampled) + } catch { + SecureLogger.error("PTT capture file write failed: \(error)", category: .session) + } + + guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return } + encodedFrameCount += frames.count + onFrames?(frames) + } + + private func teardown(deleteFile: Bool) { + running = false + // Releasing the AVAudioFile finalizes the .m4a container. + file = nil + encoder = nil + resampler = nil + if deleteFile, let url = fileURL { + try? FileManager.default.removeItem(at: url) + } + fileURL = nil + } +} diff --git a/bitchat/Features/voice/PTTSettings.swift b/bitchat/Features/voice/PTTSettings.swift new file mode 100644 index 00000000..c3b56510 --- /dev/null +++ b/bitchat/Features/voice/PTTSettings.swift @@ -0,0 +1,39 @@ +// +// PTTSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// User preference for live push-to-talk voice. One switch controls both +/// directions: streaming your holds live, and auto-playing inbound bursts. +/// Off means voice messages behave exactly like classic voice notes. +enum PTTSettings { + private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled" + + static var liveVoiceEnabled: Bool { + get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true } + set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) } + } + + /// Autoplay is foreground-only: audio must never start from the + /// background. + @MainActor + static var isAppActive: Bool { + #if os(iOS) + return UIApplication.shared.applicationState == .active + #elseif os(macOS) + return NSApplication.shared.isActive + #else + return true + #endif + } +} diff --git a/bitchat/Features/voice/VoiceCaptureSession.swift b/bitchat/Features/voice/VoiceCaptureSession.swift new file mode 100644 index 00000000..d2f4f8c3 --- /dev/null +++ b/bitchat/Features/voice/VoiceCaptureSession.swift @@ -0,0 +1,237 @@ +// +// VoiceCaptureSession.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Foundation + +/// Capture backend behind the composer's hold-to-record gesture. +/// `VoiceRecordingViewModel` drives one session per press; the concrete type +/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a +/// note delivered on release (today's behavior), `PTTLiveVoiceSession` +/// additionally streams frames live while the button is held. +@MainActor +protocol VoiceCaptureSession: AnyObject { + /// Whether audio is leaving the device in real time while recording — + /// drives the composer's LIVE treatment. + var isLive: Bool { get } + func requestPermission() async -> Bool + func start() async throws + /// Stops capture and returns the finalized voice-note file, or nil when + /// nothing valid was captured. + func finish() async -> URL? + func cancel() async +} + +/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`. +@MainActor +final class VoiceNoteCaptureSession: VoiceCaptureSession { + private let recorder: VoiceRecorder + private let owner = VoiceRecorder.RecordingOwner() + + var isLive: Bool { false } + + init(recorder: VoiceRecorder = .shared) { + self.recorder = recorder + } + + func requestPermission() async -> Bool { + await recorder.requestPermission() + } + + func start() async throws { + try await recorder.startRecording(owner: owner) + } + + func finish() async -> URL? { + await recorder.stopRecording(owner: owner) + } + + func cancel() async { + await recorder.cancelRecording(owner: owner) + } +} + +/// Testable surface of the live capture engine. Production uses +/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening +/// real audio hardware. +@MainActor +protocol PTTCapturing: AnyObject { + var onFrames: (([Data]) -> Void)? { get set } + func start(outputURL: URL) async throws + func stop() -> (url: URL?, encodedFrames: Int) + func cancel() +} + +extension PTTCaptureEngine: PTTCapturing {} + +/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while +/// recording, then finalizes the same audio as a standard voice note whose +/// file name carries the burst ID (`voice_.m4a`) so receivers that +/// heard the live stream absorb the note silently instead of seeing a +/// duplicate. +@MainActor +final class PTTLiveVoiceSession: VoiceCaptureSession { + let burstID: Data + + private let sendPacket: (Data) -> Void + private let capture: any PTTCapturing + private let now: () -> Date + /// Capture-queue-confined stream state: packetizes frames and lazily + /// emits START so packet order is guaranteed by queue serialization. + private final class StreamState { + var packetizer: VoiceBurstPacketizer + var sentStart = false + init(burstID: Data) { + packetizer = VoiceBurstPacketizer(burstID: burstID) + } + } + private let stream: StreamState + private var startDate: Date? + private var completed = false + + var isLive: Bool { true } + + /// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the + /// target peer; must be safe to call from any queue (BLEService hops to + /// its own message queue internally). + init( + sendPacket: @escaping (Data) -> Void, + capture: (any PTTCapturing)? = nil, + now: @escaping () -> Date = Date.init, + burstID: Data? = nil + ) { + self.burstID = burstID ?? VoiceBurstPacket.makeBurstID() + self.sendPacket = sendPacket + self.capture = capture ?? PTTCaptureEngine() + self.now = now + self.stream = StreamState(burstID: self.burstID) + } + + func requestPermission() async -> Bool { + await VoiceRecorder.shared.requestPermission() + } + + func start() async throws { + let outputURL = try Self.makeOutputURL(burstID: burstID) + let sendPacket = sendPacket + let stream = stream + capture.onFrames = { frames in + if !stream.sentStart { + stream.sentStart = true + if let start = VoiceBurstPacket( + burstID: stream.packetizer.burstID, + seq: 0, + kind: .start(codec: .aacLC16kMono) + ) { + sendPacket(start.encode()) + } + } + for frame in frames { + for packet in stream.packetizer.add(frame) { + sendPacket(packet) + } + } + // Flush per callback batch: at ~130-byte frames the budget fits + // one frame per packet anyway, and holding residue would add + // ~100 ms of avoidable latency. + for packet in stream.packetizer.flush() { + sendPacket(packet) + } + } + do { + try await capture.start(outputURL: outputURL) + } catch is CancellationError { + // The hold was released/canceled while the session acquire was + // in flight: the engine never started and the capture already + // handed its token back — nothing to retry. A coordinator-side + // interruption during handoff also cancels acquire, but that is + // not a successful start and must propagate to the view model. + guard completed else { throw CancellationError() } + return + } catch { + // The HAL can briefly report a dead input right after the audio + // session (re)activates while the route settles; one retry after + // a short pause covers it (observed on iPhone field tests). + SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session) + try? await Task.sleep(nanoseconds: 150_000_000) + // The hold may have been released/canceled during the retry pause. + // Starting the mic now would leave it live and streaming after the + // user let go, so bail instead of opening a hot mic. + guard !completed else { + capture.cancel() + return + } + try await capture.start(outputURL: outputURL) + } + startDate = now() + SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session) + } + + func finish() async -> URL? { + guard !completed else { return nil } + completed = true + + let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0 + let (url, encodedFrames) = capture.stop() + // stop() drained the capture queue, so touching `stream` is safe now. + + let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration + + guard elapsed >= VoiceRecorder.minRecordingDuration, + capturedDuration >= VoiceRecorder.minRecordingDuration, + let url + else { + sendControlPacket(.canceled) + if let url { + try? FileManager.default.removeItem(at: url) + } + return nil + } + + for packet in stream.packetizer.flush() { + sendPacket(packet) + } + let durationMs = UInt32((capturedDuration * 1000).rounded()) + sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs)) + SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session) + return url + } + + func cancel() async { + let alreadyCompleted = completed + completed = true + // Always tear down the capture, even if a quick-release already marked + // us completed: the engine can start late (during start()'s retry + // pause), and only capture.cancel() stops the mic and deactivates the + // session. It is idempotent, so a redundant call is harmless. + capture.cancel() + if !alreadyCompleted { + sendControlPacket(.canceled) + } + } + + private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) { + guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return } + sendPacket(packet.encode()) + } + + private static func makeOutputURL(burstID: Data) throws -> URL { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let directory = base + .appendingPathComponent("files", isDirectory: true) + .appendingPathComponent("voicenotes/outgoing", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a") + } +} diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index 79b9af6c..3818128c 100644 --- a/bitchat/Features/voice/VoiceNotePlaybackController.swift +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay @Published private(set) var duration: TimeInterval = 0 @Published private(set) var progress: Double = 0 + /// Internal lifecycle visibility for deterministic acquisition tests. + var isPlaybackStartPending: Bool { sessionAcquireInFlight } + /// rounded so 4.9s shows "00:05" var roundedDuration: Int { guard duration.isFinite else { return 0 } @@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay private var player: AVAudioPlayer? private var timer: Timer? private var url: URL + /// Test seam; `AudioSessionCoordinator.shared` when nil. + private let sessionCoordinatorOverride: AudioSessionCoordinator? + /// Injectable so tests don't fight over the app-wide exclusive-playback + /// slot (a parallel test's `play()` would pause this controller mid-test). + private let exclusivity: VoiceNotePlaybackCoordinator + private var sessionToken: AudioSessionCoordinator.Token? + /// A session acquire is in flight (it suspends off-main for the blocking + /// session IPC); gates against double acquisition on rapid play taps. + private var sessionAcquireInFlight = false - init(url: URL) { + init( + url: URL, + sessionCoordinator: AudioSessionCoordinator? = nil, + exclusivity: VoiceNotePlaybackCoordinator? = nil + ) { self.url = url + self.sessionCoordinatorOverride = sessionCoordinator + self.exclusivity = exclusivity ?? .shared super.init() // Don't load anything eagerly - wait until user interaction or view is fully displayed } @@ -51,6 +69,16 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay deinit { timer?.invalidate() + player?.stop() + // A per-row @StateObject can be discarded mid-playback (navigating + // away). Leaking the token here would hold the session forever — + // never deactivating it, and pinning any escalated category for the + // app's lifetime. `release` is fire-and-forget onto the coordinator's + // queue, so it is deinit-safe: only the Sendable token crosses. + if let token = sessionToken { + sessionToken = nil + (sessionCoordinatorOverride ?? .shared).release(token) + } } func replaceURL(_ url: URL) { @@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay func play() { guard ensurePlayerReady() else { return } - VoiceNotePlaybackCoordinator.shared.activate(self) - player?.play() + exclusivity.activate(self) + isPlaying = true startTimer() updateProgress() - isPlaying = true + // Acquired here (not in ensurePlayerReady): scrubbing a paused note + // must not hold the session while nothing is audible. The session + // calls block on audio-server IPC, so they run off the main thread; + // the player starts once the session is configured. + startPlayerAfterAcquiringSession() } func pause() { @@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay stopTimer() updateProgress() isPlaying = false + releaseSession() } func stop() { @@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay stopTimer() updateProgress() isPlaying = false - VoiceNotePlaybackCoordinator.shared.deactivate(self) + releaseSession() + exclusivity.deactivate(self) } func seek(to fraction: Double) { @@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay let clamped = max(0, min(1, fraction)) if let player = player { player.currentTime = clamped * player.duration - if isPlaying { - player.play() + // While the session acquire is still in flight, don't start + // audio pre-activation — the pending acquire's completion starts + // playback (from the new position) once the session resolves. + if isPlaying, !sessionAcquireInFlight { + startPreparedPlayer() } updateProgress() } @@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay self.stopTimer() self.updateProgress() self.isPlaying = false - VoiceNotePlaybackCoordinator.shared.deactivate(self) + self.releaseSession() + self.exclusivity.deactivate(self) } } // MARK: - Private Helpers private func preparePlayer(for url: URL) { - // Prepare player synchronously (only called when playback is requested) + // Load metadata synchronously, but do not call prepareToPlay here: + // paused scrubbing reaches this path and must not acquire playback + // hardware outside the AudioSessionCoordinator token lifetime. do { let player = try AVAudioPlayer(contentsOf: url) player.delegate = self - player.prepareToPlay() self.player = player duration = player.duration currentTime = player.currentTime @@ -141,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay if player == nil { preparePlayer(for: url) } - #if os(iOS) - let session = AVAudioSession.sharedInstance() - do { - try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) - try session.setActive(true, options: []) - } catch { - SecureLogger.error("Failed to activate audio session: \(error)", category: .session) - } - #endif return player != nil } + /// All entry points (SwiftUI actions, `pauseForExclusivity`, the + /// delegate's main-queue hop) run on the main thread; the acquire itself + /// suspends while the blocking session IPC runs on the coordinator's + /// queue, and the player starts when it resolves. An acquire failure + /// leaves playback stopped: starting without a registered token would + /// bypass interruption fan-out and the coordinator's refcount. A + /// pause/stop landing mid-acquire hands the token straight back. + private func startPlayerAfterAcquiringSession() { + if sessionToken != nil { + startPreparedPlayer() + return + } + guard !sessionAcquireInFlight else { return } + sessionAcquireInFlight = true + let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared + Task { @MainActor [weak self] in + var token: AudioSessionCoordinator.Token? + do { + token = try await coordinator.acquire(.playback) { [weak self] in + self?.pause() + } + } catch { + SecureLogger.error("Failed to activate audio session: \(error)", category: .session) + } + guard let self else { + // The row was discarded while acquiring; deinit had no token + // to release yet. + token.map(coordinator.release) + return + } + self.sessionAcquireInFlight = false + guard self.isPlaying else { + // Paused/stopped while the session was activating. + token.map(coordinator.release) + return + } + guard let token else { + self.failPlaybackStart() + return + } + self.sessionToken = token + self.startPreparedPlayer() + } + } + + @discardableResult + private func startPreparedPlayer() -> Bool { + guard let player, + player.prepareToPlay(), + player.play() + else { + SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session) + failPlaybackStart() + return false + } + return true + } + + private func failPlaybackStart() { + player?.pause() + stopTimer() + updateProgress() + isPlaying = false + releaseSession() + exclusivity.deactivate(self) + } + + private func releaseSession() { + sessionToken.map((sessionCoordinatorOverride ?? .shared).release) + sessionToken = nil + } + private func startTimer() { if timer != nil { return } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in @@ -181,25 +283,75 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay } } -/// Ensures only one voice note plays at a time. +/// Something that can hold the app's single audio-playback slot and yield it +/// when another playback starts (voice notes pause; live bursts stop). +protocol ExclusivePlayback: AnyObject { + func pauseForExclusivity() +} + +extension VoiceNotePlaybackController: ExclusivePlayback { + func pauseForExclusivity() { + pause() + } +} + +/// Ensures only one voice playback (note or live burst) runs at a time. final class VoiceNotePlaybackCoordinator { static let shared = VoiceNotePlaybackCoordinator() - private weak var activeController: VoiceNotePlaybackController? - - private init() {} - - func activate(_ controller: VoiceNotePlaybackController) { - if activeController === controller { - return - } - activeController?.pause() - activeController = controller + struct Reservation: Equatable { + fileprivate let generation: UInt64 } - func deactivate(_ controller: VoiceNotePlaybackController) { + private weak var activeController: (any ExclusivePlayback)? + private weak var latestReservedController: (any ExclusivePlayback)? + private var latestReservation = Reservation(generation: 0) + + /// Internal so tests can isolate their own exclusivity slot; the app + /// uses `shared`. + init() {} + + /// Records playback intent without interrupting audio that is already + /// audible. Async starters reserve before suspension, then activate only + /// after their audio resource is ready. + func reserve(_ controller: any ExclusivePlayback) -> Reservation { + latestReservation = Reservation(generation: latestReservation.generation &+ 1) + latestReservedController = controller + return latestReservation + } + + /// Immediate activation for synchronous/user-initiated playback. + @discardableResult + func activate(_ controller: any ExclusivePlayback) -> Reservation { + let reservation = reserve(controller) + _ = activate(controller, reservation: reservation) + return reservation + } + + /// Commits an earlier reservation only when it is still the newest + /// playback request. This prevents an older async acquire from stealing + /// the floor after a newer play gesture. + @discardableResult + func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool { + guard isCurrent(reservation, for: controller) else { return false } + if activeController === controller { + return true + } + activeController?.pauseForExclusivity() + activeController = controller + return true + } + + func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool { + latestReservation == reservation && latestReservedController === controller + } + + func deactivate(_ controller: any ExclusivePlayback) { if activeController === controller { activeController = nil } + if latestReservedController === controller { + latestReservedController = nil + } } } diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index b7b4b91f..eb1b6af2 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -1,22 +1,95 @@ import Foundation import AVFoundation +/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping +/// it behind a protocol lets lifecycle races be tested without opening the +/// microphone on the test host. +protocol VoiceAudioRecording: AnyObject { + var isRecording: Bool { get } + var isMeteringEnabled: Bool { get set } + func prepareToRecord() -> Bool + func record(forDuration duration: TimeInterval) -> Bool + func stop() +} + +extension AVAudioRecorder: VoiceAudioRecording {} + +protocol VoiceAudioRecorderCreating { + func makeRecorder(url: URL) throws -> any VoiceAudioRecording +} + +private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { + func makeRecorder(url: URL) throws -> any VoiceAudioRecording { + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 16_000, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 16_000 + ] + return try AVAudioRecorder(url: url, settings: settings) + } +} + /// Manages audio capture for mesh voice notes with predictable encoding settings. actor VoiceRecorder { - enum RecorderError: Error { + enum RecorderError: Error, Equatable { case microphoneAccessDenied - case recorderInitializationFailed case recordingInProgress + case failedToStartRecording } static let shared = VoiceRecorder() - private let paddingInterval: TimeInterval = 0.5 - private let maxRecordingDuration: TimeInterval = 120 static let minRecordingDuration: TimeInterval = 1 - private var recorder: AVAudioRecorder? + /// Identity of one press/hold. Every lifecycle mutation must present the + /// same owner that started the recorder, so a stale finish or cancel from + /// another hold cannot stop or delete the current recording. + final class RecordingOwner: @unchecked Sendable {} + + /// Test-only scheduling seams for lifecycle boundaries that otherwise rely + /// on wall-clock sleeps. Production uses the real padding delay. + struct TestingHooks: Sendable { + let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? + + init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) { + self.waitForStopPadding = waitForStopPadding + } + } + + private let sessionCoordinator: AudioSessionCoordinator + private let recorderFactory: any VoiceAudioRecorderCreating + private let permissionGranted: () -> Bool + private let paddingInterval: TimeInterval + private let maxRecordingDuration: TimeInterval + private let outputDirectory: URL? + private let testingHooks: TestingHooks + + private var recorder: (any VoiceAudioRecording)? private var currentURL: URL? + private var sessionToken: AudioSessionCoordinator.Token? + private var activeOwner: RecordingOwner? + /// True only while `startRecording()` is suspended in session acquire. + /// A second start is rejected instead of superseding the first one. + private var startInFlight = false + + init( + sessionCoordinator: AudioSessionCoordinator = .shared, + recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(), + permissionGranted: (() -> Bool)? = nil, + paddingInterval: TimeInterval = 0.5, + maxRecordingDuration: TimeInterval = 120, + outputDirectory: URL? = nil, + testingHooks: TestingHooks = TestingHooks() + ) { + self.sessionCoordinator = sessionCoordinator + self.recorderFactory = recorderFactory + self.permissionGranted = permissionGranted ?? Self.hasSystemPermission + self.paddingInterval = paddingInterval + self.maxRecordingDuration = maxRecordingDuration + self.outputDirectory = outputDirectory + self.testingHooks = testingHooks + } // MARK: - Permissions @@ -42,82 +115,130 @@ actor VoiceRecorder { // MARK: - Recording Lifecycle @discardableResult - func startRecording() throws -> URL { - if recorder?.isRecording == true { + func startRecording(owner: RecordingOwner) async throws -> URL { + if activeOwner != nil { throw RecorderError.recordingInProgress } - #if os(iOS) - let session = AVAudioSession.sharedInstance() - guard session.recordPermission == .granted else { + guard permissionGranted() else { throw RecorderError.microphoneAccessDenied } - #if targetEnvironment(simulator) - // allowBluetoothHFP is not available on iOS Simulator - try session.setCategory( - .playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetoothA2DP] - ) - #else - try session.setCategory( - .playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] - ) - #endif - try session.setActive(true, options: .notifyOthersOnDeactivation) - #endif - #if os(macOS) - guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { - throw RecorderError.microphoneAccessDenied + + activeOwner = owner + startInFlight = true + + // The acquire suspends while the blocking session IPC runs on the + // coordinator's queue (never this actor's thread or main). + let token: AudioSessionCoordinator.Token + do { + token = try await sessionCoordinator.acquire(.capture) { [weak self] in + Task { await self?.handleSessionInterruption(for: owner) } + } + } catch { + guard activeOwner === owner else { + throw CancellationError() + } + startInFlight = false + activeOwner = nil + throw error } - #endif - let outputURL = try makeOutputURL() - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 16_000, - AVNumberOfChannelsKey: 1, - AVEncoderBitRateKey: 16_000 - ] + // Actor reentrancy: release/cancel may have ended this hold while the + // blocking session activation was still in progress. + guard activeOwner === owner, startInFlight else { + sessionCoordinator.release(token) + throw CancellationError() + } + startInFlight = false + sessionToken = token - let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) - audioRecorder.isMeteringEnabled = true - audioRecorder.prepareToRecord() - audioRecorder.record(forDuration: maxRecordingDuration) + var outputURL: URL? + do { + let newURL = try makeOutputURL() + outputURL = newURL + let audioRecorder = try recorderFactory.makeRecorder(url: newURL) + audioRecorder.isMeteringEnabled = true + guard audioRecorder.prepareToRecord() else { + throw RecorderError.failedToStartRecording + } + guard audioRecorder.record(forDuration: maxRecordingDuration) else { + throw RecorderError.failedToStartRecording + } - recorder = audioRecorder - currentURL = outputURL - return outputURL + recorder = audioRecorder + currentURL = newURL + return newURL + } catch { + releaseSessionToken() + recorder = nil + currentURL = nil + activeOwner = nil + if let outputURL { + try? FileManager.default.removeItem(at: outputURL) + } + throw error + } } - func stopRecording() async -> URL? { - guard let recorder, recorder.isRecording else { - return currentURL + func stopRecording(owner: RecordingOwner) async -> URL? { + guard activeOwner === owner else { return nil } + + // `finish()` can race a still-suspended start on a direct caller even + // though the UI normally routes quick releases through cancel(). + if startInFlight { + activeOwner = nil + startInFlight = false + return nil + } + + guard let activeRecorder = recorder else { + let sessionURL = currentURL + releaseSessionToken() + currentURL = nil + activeOwner = nil + return sessionURL } let sessionURL = currentURL - try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000)) - - recorder.stop() - - // A new session may have started during the sleep — don't touch its state - if self.recorder === recorder { - cleanupSession() - self.recorder = nil - currentURL = nil + if activeRecorder.isRecording, paddingInterval > 0 { + if let waitForStopPadding = testingHooks.waitForStopPadding { + await waitForStopPadding(paddingInterval) + } else { + try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000)) + } } + // Cancellation or interruption may have run during the padding sleep. + // Only the recorder whose stop began here may be finalized by it. + guard activeOwner === owner, + let recorder = self.recorder, + recorder === activeRecorder + else { return nil } + + if activeRecorder.isRecording { + activeRecorder.stop() + } + releaseSessionToken() + self.recorder = nil + currentURL = nil + activeOwner = nil + return sessionURL } - func cancelRecording() { + func cancelRecording(owner: RecordingOwner) async { + guard activeOwner === owner else { return } + + // Invalidate ownership before cleanup. An actor-reentrant start whose + // session acquire resumes later will observe the mismatch and release + // its token without opening the microphone. + activeOwner = nil + startInFlight = false if let recorder, recorder.isRecording { recorder.stop() } - cleanupSession() + releaseSessionToken() if let currentURL { try? FileManager.default.removeItem(at: currentURL) } @@ -125,14 +246,45 @@ actor VoiceRecorder { currentURL = nil } + /// The audio session was interrupted (call, Siri) or reconfigured: stop + /// the recorder but keep `recorder`/`currentURL` so the caller's pending + /// `stopRecording()` still returns the partial note. + private func handleSessionInterruption(for owner: RecordingOwner) async { + // A callback captured for a released token must never stop a newer + // recording. Conversely, an interruption delivered while acquire is + // still suspended invalidates that acquire before it can open the mic. + guard activeOwner === owner else { return } + if startInFlight { + activeOwner = nil + startInFlight = false + return + } + startInFlight = false + if let recorder, recorder.isRecording { + recorder.stop() + } + releaseSessionToken() + } + // MARK: - Helpers + private static func hasSystemPermission() -> Bool { + #if os(iOS) + AVAudioSession.sharedInstance().recordPermission == .granted + #elseif os(macOS) + AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + #else + true + #endif + } + private func makeOutputURL() throws -> URL { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" - let fileName = "voice_\(formatter.string(from: Date())).m4a" + let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a" - let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true) + let baseDirectory = try outputDirectory + ?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true) try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil) return baseDirectory.appendingPathComponent(fileName) } @@ -147,9 +299,11 @@ actor VoiceRecorder { #endif } - private func cleanupSession() { - #if os(iOS) - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - #endif + /// Fire-and-forget: the coordinator hops the blocking deactivation IPC + /// onto its own queue. + private func releaseSessionToken() { + guard let token = sessionToken else { return } + sessionToken = nil + sessionCoordinator.release(token) } } diff --git a/bitchat/Features/voice/Waveform.swift b/bitchat/Features/voice/Waveform.swift index e33c72bd..cfc79310 100644 --- a/bitchat/Features/voice/Waveform.swift +++ b/bitchat/Features/voice/Waveform.swift @@ -56,12 +56,6 @@ final class WaveformCache { } } - func purgeAll() { - queue.async(flags: .barrier) { [weak self] in - self?.cache.removeAll() - } - } - private func computeWaveform(url: URL, bins: Int) -> [Float]? { guard bins > 0 else { return nil } // Use autoreleasepool to manage memory from audio buffer allocations diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 29f036c6..0d8dc475 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -88,8 +88,6 @@ import BitFoundation /// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. struct EphemeralIdentity { - let peerID: PeerID // 8 random bytes - let sessionStart: Date var handshakeState: HandshakeState } @@ -98,7 +96,6 @@ enum HandshakeState { case initiated case inProgress case completed(fingerprint: String) - case failed(reason: String) } /// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair. @@ -110,7 +107,6 @@ struct CryptographicIdentity: Codable { // Optional Ed25519 signing public key (used to authenticate public messages) var signingPublicKey: Data? = nil let firstSeen: Date - let lastHandshake: Date? } /// Represents the social layer of identity - user-assigned names and trust relationships. @@ -126,11 +122,35 @@ struct SocialIdentity: Codable { var notes: String? } +/// Trust ladder: unknown → casual → vouched → trusted → verified. +/// +/// Persistence compatibility: `TrustLevel` is stored by its *String* raw +/// value ("unknown", "casual", …), not by ordinal position, so inserting +/// `vouched` mid-ladder cannot corrupt previously persisted values — every +/// pre-existing case keeps the exact raw value it was written with. The +/// `vouched` tier is additionally never persisted into `SocialIdentity` +/// (it's recomputed on read from stored vouches), so downgraded builds never +/// encounter the unfamiliar raw value. enum TrustLevel: String, Codable { - case unknown = "unknown" - case casual = "casual" - case trusted = "trusted" - case verified = "verified" + case unknown + case casual + /// Transitively trusted: vouched for by at least one peer *I* verified. + /// Derived at read time — never written to persistent storage. + case vouched + case trusted + case verified +} + +// MARK: - Vouching (transitive verification) + +/// One accepted vouch: a peer I verified (the voucher) attested that they +/// verified the vouchee. Validity is recomputed on read — a record only +/// counts while its voucher remains in `verifiedFingerprints` and its +/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher +/// silently invalidates the vouches they gave without a cascade delete. +struct VouchRecord: Codable, Equatable { + let voucherFingerprint: String + let timestamp: Date } // MARK: - Identity Cache @@ -154,9 +174,21 @@ struct IdentityCache: Codable { // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] - - // Schema version for future migrations - var version: Int = 1 + + // Vouching (transitive verification). All three fields are Optional so + // caches persisted before this feature decode cleanly — the synthesized + // decoder uses decodeIfPresent for optionals, and a missing key must not + // trip the "unreadable cache" recovery path that discards everything. + + // Vouchee fingerprint -> accepted vouches (capped per vouchee) + var vouchesByVouchee: [String: [VouchRecord]]? = nil + + // Peer fingerprint -> when we last sent them a vouch batch (rate limit) + var vouchBatchSentAt: [String: Date]? = nil + + // Fingerprint -> when we verified it (orders outgoing vouch batches; + // entries verified before this field exists sort as oldest) + var verifiedAt: [String: Date]? = nil } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index fe63f871..7c68a01b 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -108,8 +108,6 @@ protocol SecureIdentityStateManagerProtocol { func updateSocialIdentity(_ identity: SocialIdentity) // MARK: Favorites Management - func getFavorites() -> Set - func setFavorite(_ fingerprint: String, isFavorite: Bool) func isFavorite(fingerprint: String) -> Bool // MARK: Blocked Users Management @@ -123,8 +121,7 @@ protocol SecureIdentityStateManagerProtocol { // MARK: Ephemeral Session Management func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) - func updateHandshakeState(peerID: PeerID, state: HandshakeState) - + // MARK: Cleanup func clearAllIdentityData() func removeEphemeralSession(peerID: PeerID) @@ -133,6 +130,16 @@ protocol SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set + + // MARK: Vouching (transitive verification) + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool + func validVouchers(for fingerprint: String) -> [VouchRecord] + func isVouched(fingerprint: String) -> Bool + func lastVouchBatchSent(to fingerprint: String) -> Date? + func markVouchBatchSent(to fingerprint: String, at date: Date) + func signingPublicKey(forFingerprint fingerprint: String) -> Data? + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] } /// Singleton manager for secure identity state persistence and retrieval. @@ -311,21 +318,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey ?? existing.signingPublicKey, - firstSeen: existing.firstSeen, - lastHandshake: now + firstSeen: existing.firstSeen ) self.cryptographicIdentities[fingerprint] = existing } else { - // Update signing key and lastHandshake + // Update signing key existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey - let updated = CryptographicIdentity( - fingerprint: existing.fingerprint, - publicKey: existing.publicKey, - signingPublicKey: existing.signingPublicKey, - firstSeen: existing.firstSeen, - lastHandshake: now - ) - self.cryptographicIdentities[fingerprint] = updated + self.cryptographicIdentities[fingerprint] = existing } // Persist updated state (already assigned in branches above) } else { @@ -334,8 +333,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey, - firstSeen: now, - lastHandshake: now + firstSeen: now ) self.cryptographicIdentities[fingerprint] = entry } @@ -500,11 +498,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) { queue.async(flags: .barrier) { - self.ephemeralSessions[peerID] = EphemeralIdentity( - peerID: peerID, - sessionStart: Date(), - handshakeState: handshakeState - ) + self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState) } } @@ -550,16 +544,20 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.async(flags: .barrier) { if verified { self.cache.verifiedFingerprints.insert(fingerprint) + var verifiedAt = self.cache.verifiedAt ?? [:] + verifiedAt[fingerprint] = Date() + self.cache.verifiedAt = verifiedAt } else { self.cache.verifiedFingerprints.remove(fingerprint) + self.cache.verifiedAt?.removeValue(forKey: fingerprint) } - + // Update trust level if social identity exists if var identity = self.cache.socialIdentities[fingerprint] { identity.trustLevel = verified ? .verified : .casual self.cache.socialIdentities[fingerprint] = identity } - + self.saveIdentityCache() } } @@ -576,6 +574,159 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } + // MARK: - Vouching (transitive verification) + + /// Maximum vouchers retained per vouchee (most recent kept). + static let maxVouchersPerVouchee = 8 + + /// Records an accepted vouch, enforcing every accept-policy gate that can + /// be evaluated against stored state (signature verification is the + /// caller's job — it needs the sender's announce-bound signing key): + /// - the voucher must be a fingerprint *I* verified + /// - self-vouches are ignored + /// - vouches for peers I already verified are ignored (nothing to add) + /// - attestations outside the validity window are ignored + /// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee + /// + /// Returns true when the vouch was stored (or refreshed). + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + recordVouch( + voucheeFingerprint: voucheeFingerprint, + voucherFingerprint: voucherFingerprint, + timestamp: timestamp, + now: Date() + ) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool { + queue.sync(flags: .barrier) { + guard voucheeFingerprint != voucherFingerprint, + self.cache.verifiedFingerprints.contains(voucherFingerprint), + !self.cache.verifiedFingerprints.contains(voucheeFingerprint) else { + return false + } + let age = now.timeIntervalSince(timestamp) + guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else { + return false + } + + var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? [] + if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) { + let newest = max(records[index].timestamp, timestamp) + records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest) + } else { + records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp)) + } + // Keep the most recent vouchers up to the cap. + records.sort { $0.timestamp > $1.timestamp } + let capped = Array(records.prefix(Self.maxVouchersPerVouchee)) + guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else { + return false // Full of fresher vouches; nothing changed. + } + + var vouches = self.cache.vouchesByVouchee ?? [:] + vouches[voucheeFingerprint] = capped + self.cache.vouchesByVouchee = vouches + self.saveIdentityCache() + return true + } + } + + /// The vouches that currently count for `fingerprint`. Validity is + /// recomputed here rather than maintained by cascade deletes: a record + /// only counts while its voucher is still verified-by-me and its + /// timestamp is within the expiry window. + func validVouchers(for fingerprint: String) -> [VouchRecord] { + validVouchers(for: fingerprint, now: Date()) + } + + func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] { + queue.sync { + self.validVouchersLocked(for: fingerprint, now: now) + } + } + + /// Requires `queue`. + private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] { + guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] } + return records.filter { record in + record.voucherFingerprint != fingerprint + && cache.verifiedFingerprints.contains(record.voucherFingerprint) + && now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge + } + } + + /// True when the peer has at least one valid vouch and no explicit + /// verification of ours. + func isVouched(fingerprint: String) -> Bool { + isVouched(fingerprint: fingerprint, now: Date()) + } + + func isVouched(fingerprint: String, now: Date) -> Bool { + queue.sync { + guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false } + return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty + } + } + + /// The trust level to display: explicit verification wins, then the + /// persisted level, with `vouched` layered in (derived, never persisted) + /// between `casual` and `trusted`. + func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { + effectiveTrustLevel(for: fingerprint, now: Date()) + } + + func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel { + queue.sync { + if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified } + let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown + let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty + switch stored { + case .verified, .trusted: + return stored + case .vouched, .casual, .unknown: + if vouched { return .vouched } + // `.vouched` should never be persisted; degrade defensively. + return stored == .vouched ? .casual : stored + } + } + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + queue.sync { cache.vouchBatchSentAt?[fingerprint] } + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + queue.async(flags: .barrier) { + var sentAt = self.cache.vouchBatchSentAt ?? [:] + sentAt[fingerprint] = date + self.cache.vouchBatchSentAt = sentAt + self.saveIdentityCache() + } + } + + /// The peer's announce-bound Ed25519 signing key, if seen this session. + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey } + } + + /// Verified fingerprints ordered most recently verified first (entries + /// without a recorded verification time sort last), excluding the given + /// fingerprint. Feeds the outgoing vouch batch. + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + queue.sync { + let verifiedAt = cache.verifiedAt ?? [:] + let ordered = cache.verifiedFingerprints + .filter { $0 != fingerprint } + .sorted { + (verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1) + } + return Array(ordered.prefix(limit)) + } + } + var debugNicknameIndex: [String: Set] { queue.sync { cache.nicknameIndex } } diff --git a/bitchat/Info.plist b/bitchat/Info.plist index f949ecee..57ddfa11 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -31,6 +31,8 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.social-networking LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription @@ -40,9 +42,9 @@ NSCameraUsageDescription bitchat uses the camera to scan QR codes to verify peers. NSLocationWhenInUseUsageDescription - bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. + bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages. NSMicrophoneUsageDescription - bitchat uses the microphone to record voice notes that relay across the mesh. + bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation. NSPhotoLibraryUsageDescription bitchat lets you pick images from your photo library to share with nearby peers. UIBackgroundModes diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index be1bba06..80c3c11d 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,6 +1,191 @@ { "sourceLanguage" : "en", "strings" : { + "notification.action.wave" : { + "comment" : "Title of the notification action button that sends a friendly wave back to a nearby person", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لوّح 👋" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "হাত নাড়ুন 👋" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "winken 👋" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wave 👋" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "saludar 👋" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kumaway 👋" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "saluer 👋" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לנופף 👋" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "हाथ हिलाएँ 👋" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "melambai 👋" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "saluta 👋" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "手を振る 👋" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "손 흔들기 👋" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "lambai 👋" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "हात हल्लाउनुहोस् 👋" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zwaaien 👋" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pomachaj 👋" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "acenar 👋" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "acenar 👋" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "помахать 👋" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "vinka 👋" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "கை அசைக்கவும் 👋" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โบกมือ 👋" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "el salla 👋" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "помахати 👋" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ہاتھ ہلائیں 👋" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "vẫy tay 👋" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "挥手 👋" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "揮手 👋" + } + } + } + }, + "#%@" : { + "comment" : "Non-localizable channel hashtag format used in code", + "extractionState" : "manual", + "shouldTranslate" : false + }, "%@" : { "comment" : "Non-localizable symbol used in code", "extractionState" : "manual", @@ -184,6 +369,7 @@ }, "%@ active" : { "comment" : "A label at the bottom of the people list sheet showing the number of active users.", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1077,185 +1263,6 @@ } } }, - "app_info.close" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إغلاق" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বন্ধ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "schließen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "close" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "cerrar" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "isara" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "fermer" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "סגור" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "बंद करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tutup" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "chiudi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "閉じる" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "닫기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tutup" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "बन्द" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "sluiten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zamknij" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "fechar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "fechar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "закрыть" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "stäng" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "மூடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ปิด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kapat" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "закрити" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بند کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đóng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "关闭" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "關閉" - } - } - } - }, "app_info.done" : { "extractionState" : "manual", "localizations" : { @@ -1435,6 +1442,364 @@ } } }, + "app_info.features.bridge.description" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يربط جزر mesh القريبة عبر الإنترنت حتى لا ينقسم الحشد الواحد بسبب مدى الراديو" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি মেশ দ্বীপগুলোকে ইন্টারনেটের মাধ্যমে যুক্ত করে, যাতে রেডিও পরিসরের কারণে একই ভিড় ভাগ না হয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindet nahe mesh-inseln über das internet, damit eine menge nicht durch die funkreichweite geteilt wird" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "links nearby mesh islands through the internet so one crowd isn't split by radio range" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "une islas mesh cercanas a través de internet para que una multitud no quede dividida por el alcance de radio" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "iniuugnay ang mga kalapit na mesh island sa pamamagitan ng internet para hindi mahati ng saklaw ng radyo ang iisang grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relie les îlots mesh voisins via internet pour qu'une même foule ne soit pas coupée par la portée radio" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מקשר איי mesh קרובים דרך האינטרנט כדי שקהל אחד לא יתפצל בגלל טווח הרדיו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आसपास के मेश द्वीपों को इंटरनेट से जोड़ता है ताकि एक ही भीड़ रेडियो दायरे से बँट न जाए" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghubungkan pulau-pulau mesh terdekat lewat internet agar satu kerumunan tidak terbelah oleh jangkauan radio" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "collega le isole mesh vicine tramite internet così una folla non viene divisa dalla portata radio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのmeshの島々をインターネットでつなぎ、電波範囲で人の輪が分断されないようにします" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처의 mesh 섬들을 인터넷으로 연결해 한 무리가 전파 범위 때문에 갈라지지 않게 합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghubungkan pulau-pulau mesh berdekatan melalui internet supaya satu kumpulan tidak terpisah oleh jangkauan radio" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकका mesh टापुहरूलाई इन्टरनेटमार्फत जोड्छ ताकि एउटै भीड रेडियो पहुँचले नबाँडियोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindt nabije mesh-eilanden via internet zodat één groep niet door radiobereik wordt gesplitst" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "łączy pobliskie wyspy mesh przez internet, aby jeden tłum nie był dzielony przez zasięg radiowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "liga ilhas mesh próximas através da internet para que uma multidão não fique dividida pelo alcance de rádio" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "liga ilhas mesh próximas pela internet para que uma multidão não fique dividida pelo alcance de rádio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "связывает соседние mesh-острова через интернет, чтобы одна толпа не делилась радиусом радиосвязи" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "länkar närliggande mesh-öar via internet så att en folkmassa inte delas av radioräckvidden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள mesh தீவுகளை இணையம் வழியாக இணைக்கிறது, ஒரே கூட்டம் ரேடியோ வரம்பால் பிரியாமல் இருக்க" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เชื่อมเกาะ mesh ที่อยู่ใกล้กันผ่านอินเทอร์เน็ต เพื่อให้ฝูงชนเดียวกันไม่ถูกแบ่งด้วยระยะวิทยุ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki mesh adalarını internet üzerinden birbirine bağlar, böylece bir kalabalık telsiz menziliyle bölünmez" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "з'єднує сусідні mesh-острови через інтернет, щоб один натовп не ділився радіусом радіозв'язку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی mesh جزیروں کو انٹرنیٹ کے ذریعے جوڑتا ہے تاکہ ایک ہجوم ریڈیو رینج سے تقسیم نہ ہو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "liên kết các đảo mesh gần nhau qua internet để một đám đông không bị chia cắt bởi phạm vi sóng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过互联网连接附近的 mesh 孤岛,让同一群人不被无线电范围隔开" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過網際網路連接附近的 mesh 孤島,讓同一群人不被無線電範圍隔開" + } + } + } + }, + "app_info.features.bridge.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جسور mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ ব্রিজিং" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-bridging" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridging" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "puentes mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridging" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pontage mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גישור mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेश ब्रिजिंग" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "bridging mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジング" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리징" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh ब्रिजिङ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-bridging" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mostkowanie mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "pontes mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "pontes mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-мосты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-bryggning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh பாலம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การบริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh köprüleme" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-мости" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh پل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 桥接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 橋接" + } + } + } + }, "app_info.features.encryption.description" : { "extractionState" : "manual", "localizations" : { @@ -4305,175 +4670,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "• اضغط أيقونة الأشخاص لفتح الشريط الجانبي" + "value" : "• اضغط أيقونة الأشخاص لفتح القائمة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "• সাইডবার খুলতে মানুষ আইকনে ট্যাপ করুন" + "value" : "• তালিকা খুলতে মানুষ আইকনে ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "• tippe auf das personen-icon, um die seitenleiste zu öffnen" + "value" : "• tippe auf das personen-icon für die liste" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "• tap people icon for sidebar" + "value" : "• tap people icon for list" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "• toca el ícono de personas para abrir la barra lateral" + "value" : "• toca el ícono de personas para ver la lista" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "• i-tap ang icon ng tao para buksan ang sidebar" + "value" : "• i-tap ang icon ng tao para sa listahan" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "• tape sur l'icône personnes pour ouvrir la barre latérale" + "value" : "• tape sur l'icône personnes pour la liste" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "• הקש על אייקון האנשים כדי לפתוח סרגל צד" + "value" : "• הקש על אייקון האנשים כדי לפתוח את הרשימה" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "• साइडबार खोलने के लिए लोगों वाले आइकन पर टैप करें" + "value" : "• सूची खोलने के लिए लोगों वाले आइकन पर टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk ikon orang untuk membuka sidebar" + "value" : "• ketuk ikon orang untuk membuka daftar" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "• tocca l'icona persone per aprire la barra laterale" + "value" : "• tocca l'icona persone per aprire la lista" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "• 人アイコンをタップしてサイドバーを開く" + "value" : "• 人アイコンをタップして一覧を開く" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "• 사람 아이콘을 탭하여 사이드바를 엽니다" + "value" : "• 사람 아이콘을 탭하여 목록을 엽니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk ikon orang untuk membuka sidebar" + "value" : "• ketuk ikon orang untuk membuka senarai" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "• साइडबार खोल्न मान्छे आइकन ट्याप गर" + "value" : "• सूची खोल्न मान्छे आइकन ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "• tik op het personen-icoon om de zijbalk te openen" + "value" : "• tik op het personen-icoon voor de lijst" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "• stuknij ikonę osób, aby otworzyć panel boczny" + "value" : "• stuknij ikonę osób, aby otworzyć listę" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "• toca no ícone das pessoas para abrir a barra lateral" + "value" : "• toca no ícone das pessoas para abrir a lista" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "• toque o ícone de pessoas para abrir a barra lateral" + "value" : "• toque o ícone de pessoas para abrir a lista" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "• нажми на иконку людей, чтобы открыть боковое меню" + "value" : "• нажми на иконку людей, чтобы открыть список" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "• tryck på personikonen för att öppna sidomenyn" + "value" : "• tryck på personikonen för att öppna listan" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "• பக்கப்பட்டியைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்" + "value" : "• பட்டியலைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "• แตะไอคอนคนเพื่อเปิดแถบด้านข้าง" + "value" : "• แตะไอคอนคนเพื่อเปิดรายชื่อ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "• kenar çubuğunu açmak için insan simgesine dokunun" + "value" : "• listeyi açmak için insan simgesine dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "• торкни піктограму людей, щоб відкрити бічну панель" + "value" : "• торкни піктограму людей, щоб відкрити список" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "• سائیڈ بار کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں" + "value" : "• فہرست کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "• chạm biểu tượng người để mở thanh bên" + "value" : "• chạm biểu tượng người để mở danh sách" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "• 轻点人物图标打开侧栏" + "value" : "• 轻点人物图标打开列表" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "• 輕點人物圖示打開側欄" + "value" : "• 輕點人物圖示打開列表" } } } @@ -4663,175 +5028,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "• اضغط اسم القرين لبدء رسائل خاصة" + "value" : "• اضغط اسم شخص لبدء رسائل خاصة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "• ডিএম শুরু করতে পিয়ারের নাম ট্যাপ করুন" + "value" : "• ডিএম শুরু করতে কোনো ব্যক্তির নাম ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "• tippe auf den namen eines peers, um eine pn zu starten" + "value" : "• tippe auf den namen einer person, um eine pn zu starten" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "• tap a peer's name to start a DM" + "value" : "• tap a person's name to start a DM" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "• toca el nombre de un participante para iniciar un MD" + "value" : "• toca el nombre de una persona para iniciar un MD" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "• i-tap ang pangalan ng peer para magsimula ng DM" + "value" : "• i-tap ang pangalan ng isang tao para magsimula ng DM" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "• tape sur le nom d'un pair pour démarrer un mp" + "value" : "• tape sur le nom d'une personne pour démarrer un mp" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "• הקש על שם עמית כדי להתחיל הודעה פרטית" + "value" : "• הקש על שם של אדם כדי להתחיל הודעה פרטית" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "• किसी पीयर का नाम टैप करके DM शुरू करें" + "value" : "• किसी व्यक्ति का नाम टैप करके DM शुरू करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk nama peer untuk mulai dm" + "value" : "• ketuk nama seseorang untuk mulai dm" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "• tocca il nome di un peer per avviare un dm" + "value" : "• tocca il nome di una persona per avviare un dm" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "• ピアの名前をタップしてdm開始" + "value" : "• 人の名前をタップしてdm開始" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "• 피어의 이름을 탭하여 DM을 시작합니다" + "value" : "• 상대방의 이름을 탭하여 DM을 시작합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk nama peer untuk mulai dm" + "value" : "• ketuk nama seseorang untuk mulai dm" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर" + "value" : "• dm सुरु गर्न कुनै व्यक्तिको नाम ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "• tik op de naam van een peer om een DM te starten" + "value" : "• tik op de naam van een persoon om een DM te starten" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "• stuknij nazwę peera, aby rozpocząć DM" + "value" : "• stuknij czyjeś imię, aby rozpocząć DM" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "• toca no nome de um par para iniciar um DM" + "value" : "• toca no nome de uma pessoa para iniciar um DM" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "• toque o nome de um par para iniciar um dm" + "value" : "• toque o nome de uma pessoa para iniciar um dm" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "• нажми имя пользователя, чтобы начать лс" + "value" : "• нажми имя человека, чтобы начать лс" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "• tryck på en peers namn för att starta ett DM" + "value" : "• tryck på en persons namn för att starta ett DM" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "• peer பெயரைத் தட்டி DM தொடங்கவும்" + "value" : "• ஒருவரின் பெயரைத் தட்டி DM தொடங்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "• แตะชื่อเพียร์เพื่อเริ่ม DM" + "value" : "• แตะชื่อบุคคลเพื่อเริ่ม DM" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "• bir eşin adına dokunarak DM başlatın" + "value" : "• bir kişinin adına dokunarak DM başlatın" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "• торкни ім'я піра, щоб почати приватний чат" + "value" : "• торкни ім'я людини, щоб почати приватний чат" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "• DM شروع کرنے کیلئے کسی ہم منصب کے نام پر ٹیپ کریں" + "value" : "• DM شروع کرنے کیلئے کسی شخص کے نام پر ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "• chạm tên một nút ngang hàng để mở DM" + "value" : "• chạm tên một người để mở DM" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "• 轻点同伴名字开始 dm" + "value" : "• 轻点某人名字开始 dm" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "• 輕點同伴名字開始 dm" + "value" : "• 輕點某人名字開始 dm" } } } @@ -5015,6 +5380,3605 @@ } } }, + "app_info.legend.blocked" : { + "comment" : "Legend entry for the nosign glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محظور" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্লক করা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloqueado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "na-block" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חסום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लॉक किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ブロック中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "차단됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zablokowany" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தடுக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ถูกบล็อก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "engellendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بلاک شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã chặn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽" + } + } + } + }, + "app_info.legend.bridged" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وصلت الرسالة عبر جسر mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বার্তাটি মেশ ব্রিজ পেরিয়ে এসেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nachricht kam über eine mesh-brücke an" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message arrived across a mesh bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "el mensaje llegó a través de un puente mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "dumating ang mensahe sa pamamagitan ng mesh bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "message arrivé via un pont mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ההודעה הגיעה דרך גשר mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "संदेश मेश ब्रिज से होकर आया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pesan tiba lewat jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "messaggio arrivato attraverso un ponte mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジ経由で届いたメッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리지를 거쳐 도착한 메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pesan tiba melalui jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सन्देश mesh पुल हुँदै आइपुग्यो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bericht kwam via een mesh-brug binnen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wiadomość dotarła przez most mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem chegou através de uma ponte mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem chegou por uma ponte mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сообщение пришло через mesh-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "meddelandet kom via en mesh-brygga" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "செய்தி mesh பாலம் வழியாக வந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อความมาถึงผ่านบริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesaj bir mesh köprüsü üzerinden geldi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "повідомлення надійшло через mesh-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پیغام mesh پل کے ذریعے پہنچا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tin nhắn đến qua cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消息经 mesh 桥接送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "訊息經 mesh 橋接送達" + } + } + } + }, + "app_info.legend.encrypted" : { + "comment" : "Legend entry for the lock glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جلسة مشفرة من طرف إلى طرف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এন্ড-টু-এন্ড এনক্রিপ্টেড সেশন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "end-to-end-verschlüsselte sitzung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "end-to-end encrypted session" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sesión cifrada de extremo a extremo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "end-to-end na naka-encrypt na session" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "session chiffrée de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חיבור מוצפן מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एंड-टू-एंड एन्क्रिप्टेड सत्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesi terenkripsi ujung ke ujung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sessione cifrata end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "エンドツーエンド暗号化されたセッション" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "종단간 암호화된 세션" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesi terenkripsi ujung ke ujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्ड-टु-एन्ड सङ्केत सत्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "end-to-end-versleutelde sessie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesja szyfrowana end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sessão encriptada ponta a ponta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sessão criptografada ponto a ponto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сессия со сквозным шифрованием" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "end-to-end-krypterad session" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "முனை-முதல்-முனை குறியாக்கம் செய்யப்பட்ட அமர்வு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เซสชันที่เข้ารหัสแบบครบวงจร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uçtan uca şifreli oturum" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "наскрізно зашифрована сесія" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اینڈ ٹو اینڈ خفیہ کردہ سیشن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "phiên mã hóa đầu cuối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "端到端加密会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "端到端加密會話" + } + } + } + }, + "app_info.legend.encryption_failed" : { + "comment" : "Legend entry for the failed-encryption lock-slash glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فشل التشفير — الرسائل غير مؤمَّنة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপশন ব্যর্থ — বার্তা সুরক্ষিত নয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verschlüsselung fehlgeschlagen — nachrichten nicht gesichert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed — messages not secured" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cifrado fallido — mensajes no protegidos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nabigo ang pag-encrypt — hindi ligtas ang mga mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec du chiffrement — messages non sécurisés" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההצפנה נכשלה — ההודעות אינן מאובטחות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्शन विफल — संदेश सुरक्षित नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal — pesan tidak aman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cifratura fallita — messaggi non protetti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化に失敗 — メッセージは保護されていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화 실패 — 메시지가 보호되지 않음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal — pesan tidak selamat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सङ्केत असफल — सन्देश सुरक्षित छैनन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "versleuteling mislukt — berichten niet beveiligd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "szyfrowanie nie powiodło się — wiadomości nie są zabezpieczone" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha na encriptação — mensagens não protegidas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha na criptografia — mensagens sem proteção" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрование не удалось — сообщения не защищены" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kryptering misslyckades — meddelanden inte skyddade" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குறியாக்கம் தோல்வியடைந்தது — செய்திகள் பாதுகாக்கப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้ารหัสไม่สำเร็จ — ข้อความไม่ปลอดภัย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "şifreleme başarısız — mesajlar güvende değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрування не вдалося — повідомлення не захищені" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انکرپشن ناکام — پیغامات محفوظ نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mã hóa thất bại — tin nhắn không được bảo mật" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "加密失败 — 消息未受保护" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "加密失敗 — 訊息未受保護" + } + } + } + }, + "app_info.legend.favorite" : { + "comment" : "Legend entry for the star glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مفضّل — يتيح الرسائل بدون اتصال عبر nostr عند التفضيل المتبادل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রিয় — পারস্পরিক হলে nostr দিয়ে অফলাইন বার্তা সক্রিয় করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — ermöglicht offline-nachrichten über nostr, wenn beidseitig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite — enables offline messages via nostr when mutual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito — habilita mensajes sin conexión vía Nostr cuando es mutuo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paborito — nagbibigay-daan sa offline na mensahe sa nostr kapag mutual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori — active les messages hors ligne via nostr quand c'est mutuel" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מועדף — מאפשר הודעות לא מקוונות דרך nostr כשההעדפה הדדית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पसंदीदा — आपसी होने पर नोस्ट्र के ज़रिए ऑफ़लाइन संदेश सक्षम करता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — mengaktifkan pesan offline via nostr saat saling favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "preferito — abilita i messaggi offline via nostr quando reciproco" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "お気に入り — 相互になるとnostr経由でオフラインメッセージが可能に" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "즐겨찾기 — 상호 등록 시 nostr를 통해 오프라인 메시지 가능" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — membolehkan pesan offline melalui nostr apabila saling favorit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मनपर्ने — दुवैतर्फ भएमा nostr मार्फत अफलाइन सन्देश सक्षम गर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favoriet — schakelt offline berichten via nostr in bij wederzijds" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ulubiony — umożliwia wiadomości offline przez Nostr, gdy wzajemny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorito — ativa mensagens offline via nostr quando for mútuo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorito — habilita mensagens offline via nostr quando mútuo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "избранное — включает офлайн-сообщения через nostr при взаимности" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — möjliggör offline-meddelanden via Nostr när ömsesidig" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சிறப்பு — பரஸ்பரமாக இருக்கும்போது nostr வழியாக ஆஃப்லைன் செய்திகளை இயக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คนโปรด — เปิดใช้ข้อความออฟไลน์ผ่าน nostr เมื่อเป็นคนโปรดของกันและกัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori — karşılıklı olduğunda Nostr üzerinden çevrimdışı mesajları etkinleştirir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "улюблений — вмикає офлайн-повідомлення через nostr, коли взаємний" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پسندیدہ — باہمی ہونے پر nostr کے ذریعے آف لائن پیغامات فعال کرتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yêu thích — bật tin nhắn ngoại tuyến qua nostr khi cả hai cùng thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "收藏 — 互相收藏后可通过 nostr 发送离线消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "收藏 — 互相收藏後可透過 nostr 收發離線訊息" + } + } + } + }, + "app_info.legend.location_nearby" : { + "comment" : "Legend entry for the map pin glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "موجود فعليًا في منطقة قناة الموقع هذه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সশরীরে এই লোকেশন চ্যানেলের এলাকায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "physisch im gebiet dieses standortkanals" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "physically in this location channel's area" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "físicamente en el área de este canal de ubicación" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pisikal na nasa lugar ng channel ng lokasyon na ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "physiquement dans la zone de ce canal de localisation" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נמצא פיזית באזור של ערוץ המיקום הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस लोकेशन चैनल के क्षेत्र में मौजूद" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "secara fisik berada di area kanal lokasi ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fisicamente nell'area di questo canale di posizione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "この位置チャンネルのエリア内にいます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 위치 채널 영역 안에 있음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "secara fizikal berada di kawasan kanal lokasi ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो स्थान च्यानलको क्षेत्रमा भौतिक रूपमा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fysiek in het gebied van dit locatiekanaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fizycznie w obszarze tego kanału lokalizacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fisicamente na área deste canal de localização" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fisicamente na área deste canal de localização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "физически в зоне этого локального канала" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fysiskt i den här platskanalens område" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த இருப்பிட சேனலின் பகுதியில் நேரடியாக உள்ளார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "อยู่ในพื้นที่ของช่องตามตำแหน่งนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fiziksel olarak bu konum kanalının bölgesinde" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "фізично в зоні цього каналу локації" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس لوکیشن چینل کے علاقے میں جسمانی طور پر موجود" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện đang ở trong khu vực của kênh vị trí này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "本人就在此位置频道的区域内" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "實際位於此位置頻道的區域內" + } + } + } + }, + "app_info.legend.mesh_connected" : { + "comment" : "Legend entry for the antenna glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "متصل مباشرة عبر bluetooth" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সরাসরি ব্লুটুথে সংযুক্ত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "direkt über bluetooth verbunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected directly over bluetooth" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectado directamente por Bluetooth" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "direktang nakakonekta sa bluetooth" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connecté directement en bluetooth" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחובר ישירות דרך bluetooth" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लूटूथ से सीधे जुड़ा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "terhubung langsung lewat bluetooth" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connesso direttamente via bluetooth" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetoothで直接接続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetooth로 직접 연결됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bersambung terus melalui bluetooth" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetooth मार्फत सिधै जडान" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rechtstreeks verbonden via bluetooth" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "połączono bezpośrednio przez Bluetooth" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ligado diretamente por bluetooth" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conectado diretamente por bluetooth" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "подключён напрямую через bluetooth" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ansluten direkt via Bluetooth" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetooth மூலம் நேரடியாக இணைக்கப்பட்டுள்ளது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เชื่อมต่อโดยตรงผ่าน bluetooth" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "doğrudan Bluetooth üzerinden bağlı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "з'єднано напряму через bluetooth" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "براہ راست Bluetooth کے ذریعے جڑا ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kết nối trực tiếp qua Bluetooth" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "通过 bluetooth 直接连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "透過 bluetooth 直接連線" + } + } + } + }, + "app_info.legend.mesh_relayed" : { + "comment" : "Legend entry for the relayed-mesh glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يمكن الوصول إليه عبر mesh، بإعادة تمرير من الآخرين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশের মাধ্যমে পৌঁছানো যায়, অন্যরা রিলে করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "über das mesh erreichbar, von anderen weitergeleitet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable through the mesh, relayed by others" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alcanzable a través del mesh, retransmitido por otros" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "maabot sa pamamagitan ng mesh, ipinapasa ng iba" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "joignable via le mesh, relayé par d'autres" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נגיש דרך ה-mesh, בשידור חוזר על ידי אחרים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश के ज़रिए पहुँच योग्य, दूसरों द्वारा रिले किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bisa dijangkau lewat mesh, diteruskan oleh yang lain" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "raggiungibile tramite la mesh, inoltrato da altri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh経由で到達可能、他のピアがリレー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh를 통해 도달 가능, 다른 피어가 중계" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "boleh dicapai melalui mesh, diteruskan oleh orang lain" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh मार्फत पुग्न सकिने, अरूले रिले गरेको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bereikbaar via de mesh, doorgestuurd door anderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "osiągalny przez mesh, przekazywany przez innych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "acessível através da mesh, retransmitido por outros" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "alcançável pelo mesh, retransmitido por outros" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "доступен через mesh, ретранслируется другими" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nåbar via mesh, vidarebefordrad av andra" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh வழியாக அணுகக்கூடியது, மற்றவர்களால் ரிலே செய்யப்படுகிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้าถึงได้ผ่าน mesh โดยมีผู้อื่นส่งต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh üzerinden ulaşılabilir, başkaları tarafından aktarılır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "досяжно через mesh, ретрансльовано іншими" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh کے ذریعے قابل رسائی، دوسروں کے ذریعے ریلے شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "có thể tiếp cận qua mesh, được người khác chuyển tiếp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "可通过 mesh 到达,由他人中继" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "可透過 mesh 到達,由其他人中繼" + } + } + } + }, + "app_info.legend.nostr" : { + "comment" : "Legend entry for the globe glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يمكن الوصول إليه عبر الإنترنت (nostr) — المفضّلون المتبادلون فقط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ইন্টারনেটে পৌঁছানো যায় (nostr) — শুধু পারস্পরিক প্রিয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "über das internet erreichbar (nostr) — nur bei beidseitigen favoriten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable over the internet (nostr) — mutual favorites only" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alcanzable por internet (Nostr) — solo favoritos mutuos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "maabot sa internet (nostr) — mutual na paborito lamang" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "joignable via internet (nostr) — favoris mutuels uniquement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נגיש דרך האינטרנט (nostr) — מועדפים הדדיים בלבד" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इंटरनेट (नोस्ट्र) पर पहुँच योग्य — केवल आपसी पसंदीदा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bisa dijangkau lewat internet (nostr) — hanya untuk saling favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "raggiungibile via internet (nostr) — solo preferiti reciproci" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "インターネット(nostr)経由で到達可能 — 相互お気に入りのみ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "인터넷(nostr)을 통해 도달 가능 — 상호 즐겨찾기만" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "boleh dicapai melalui internet (nostr) — hanya untuk saling favorit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इन्टरनेट (nostr) मार्फत पुग्न सकिने — दुवैतर्फका मनपर्ने मात्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bereikbaar via internet (nostr) — alleen wederzijdse favorieten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "osiągalny przez internet (Nostr) — tylko wzajemni ulubieni" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "acessível pela internet (nostr) — apenas favoritos mútuos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "alcançável pela internet (nostr) — apenas favoritos mútuos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "доступен через интернет (nostr) — только для взаимного избранного" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nåbar via internet (Nostr) — endast ömsesidiga favoriter" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணையம் வழியாக அணுகக்கூடியது (nostr) — பரஸ்பர சிறப்பினர் மட்டுமே" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้าถึงได้ผ่านอินเทอร์เน็ต (nostr) — เฉพาะคนโปรดของกันและกัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internet üzerinden ulaşılabilir (Nostr) — yalnızca karşılıklı favoriler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "досяжно через інтернет (nostr) — лише взаємні улюблені" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انٹرنیٹ (nostr) کے ذریعے قابل رسائی — صرف باہمی پسندیدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "có thể tiếp cận qua internet (nostr) — chỉ khi cả hai cùng thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "可通过互联网 (nostr) 到达 — 仅限互相收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "可透過網際網路 (nostr) 到達 — 僅限互相收藏" + } + } + } + }, + "app_info.legend.offline" : { + "comment" : "Legend entry for the offline person glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "غير متصل — لا يمكن الوصول إليه حاليًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অফলাইন — এখন পৌঁছানো যাচ্ছে না" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — derzeit nicht erreichbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline — not currently reachable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sin conexión — no alcanzable ahora" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — kasalukuyang hindi maabot" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hors ligne — actuellement injoignable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא מקוון — לא נגיש כרגע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ऑफ़लाइन — फ़िलहाल पहुँच योग्य नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — saat ini tidak bisa dijangkau" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — attualmente non raggiungibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "オフライン — 現在到達できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "오프라인 — 현재 도달할 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — kini tidak boleh dicapai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अफलाइन — अहिले पुग्न सकिँदैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — momenteel niet bereikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — obecnie nieosiągalny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — atualmente inacessível" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — inalcançável no momento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн — сейчас недоступен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — inte nåbar just nu" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ஆஃப்லைன் — தற்போது அணுக முடியாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ออฟไลน์ — ไม่สามารถเข้าถึงได้ในขณะนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "çevrimdışı — şu anda ulaşılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн — зараз недосяжно" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آف لائن — فی الحال قابل رسائی نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ngoại tuyến — hiện không thể tiếp cận" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "离线 — 当前不可达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "離線 — 目前無法到達" + } + } + } + }, + "app_info.legend.teleported" : { + "comment" : "Legend entry for the teleported glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "منتقل فوريًا — انضم إلى القناة من مكان آخر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টেলিপোর্টেড — অন্য কোথাও থেকে চ্যানেলে যোগ দিয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleportiert — dem kanal von woanders beigetreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported — joined the channel from somewhere else" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "teletransportado — se unió al canal desde otro lugar" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nag-teleport — sumali sa channel mula sa ibang lugar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "téléporté — a rejoint le canal depuis un autre endroit" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טלפורט — הצטרף לערוץ ממקום אחר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टेलीपोर्टेड — कहीं और से चैनल में शामिल हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport — bergabung ke kanal dari tempat lain" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletrasportato — è entrato nel canale da un altro luogo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "テレポート — 別の場所からチャンネルに参加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "텔레포트 — 다른 곳에서 채널에 참여함" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport — menyertai kanal dari tempat lain" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टेलिपोर्ट भएको — अन्त कतैबाट च्यानलमा जोडिएको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geteleporteerd — het kanaal van elders binnengekomen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleportowany — dołączył do kanału skądinąd" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletransportado — entrou no canal a partir de outro sítio" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletransportado — entrou no canal de outro lugar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортирован — присоединился к каналу из другого места" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleporterad — gick med i kanalen från en annan plats" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டெலிபோர்ட் செய்யப்பட்டது — வேறு இடத்திலிருந்து சேனலில் சேர்ந்தார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เทเลพอร์ต — เข้าร่วมช่องจากที่อื่น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ışınlandı — kanala başka bir yerden katıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортований — приєднався до каналу з іншого місця" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹیلی پورٹ شدہ — کسی اور جگہ سے چینل میں شامل ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã dịch chuyển — tham gia kênh từ nơi khác" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已瞬移 — 从别处加入此频道" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "瞬移 — 從其他地方加入此頻道" + } + } + } + }, + "app_info.legend.title" : { + "comment" : "Section header for the symbols legend in app info", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الرموز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রতীকসমূহ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SYMBOLS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SÍMBOLOS" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "MGA SIMBOLO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLES" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "סמלים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रतीक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIMBOL" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIMBOLI" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "記号" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "기호" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIMBOL" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चिन्हहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLEN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLE" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SÍMBOLOS" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SÍMBOLOS" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "СИМВОЛЫ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLER" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சின்னங்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สัญลักษณ์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SEMBOLLER" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "СИМВОЛИ" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "علامات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "KÝ HIỆU" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "符号" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "符號" + } + } + } + }, + "app_info.legend.unread" : { + "comment" : "Legend entry for the envelope glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسائل خاصة غير مقروءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অপঠিত ব্যক্তিগত বার্তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ungelesene private nachrichten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unread private messages" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensajes privados sin leer" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi pa nababasang mga pribadong mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messages privés non lus" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעות פרטיות שלא נקראו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अपठित निजी संदेश" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan pribadi belum dibaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggi privati non letti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未読のプライベートメッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "읽지 않은 개인 메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan peribadi belum dibaca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नपढेका व्यक्तिगत सन्देश" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ongelezen privéberichten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nieprzeczytane wiadomości prywatne" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagens privadas não lidas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagens privadas não lidas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "непрочитанные личные сообщения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "olästa privata meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படிக்காத தனிப்பட்ட செய்திகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความส่วนตัวที่ยังไม่ได้อ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "okunmamış özel mesajlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "непрочитані приватні повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نہ پڑھے گئے نجی پیغامات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tin nhắn riêng tư chưa đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未读私信" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未讀私信" + } + } + } + }, + "app_info.legend.verified" : { + "comment" : "Legend entry for the verified seal glyph", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تم التحقق من الهوية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পরিচয় যাচাই করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identität verifiziert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "identity verified" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "identidad verificada" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beripikado ang pagkakakilanlan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identité vérifiée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הזהות אומתה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहचान सत्यापित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identitas terverifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identità verificata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "本人確認済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "신원 확인됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identiti disahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहिचान प्रमाणित" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identiteit geverifieerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tożsamość zweryfikowana" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identidade verificada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identidade verificada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "личность подтверждена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identitet verifierad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அடையாளம் சரிபார்க்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยืนยันตัวตนแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kimlik doğrulandı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "особу підтверджено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "شناخت کی تصدیق ہو گئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "danh tính đã xác minh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "身份已验证" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "身份已驗證" + } + } + } + }, + "app_info.location.notes.description" : { + "comment" : "Description of the location notes toggle in app info", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت ملاحظات في الأماكن باستخدام /drop، يقرؤها أي مارّ خلال 24 ساعة. يتطلب الموقع." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop দিয়ে জায়গায় নোট পিন করুন, 24 ঘণ্টা ধরে পাশ দিয়ে যাওয়া যে কেউ পড়তে পারবে। অবস্থান প্রয়োজন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hefte notizen mit /drop an orte, 24h lesbar für alle, die vorbeikommen. benötigt standort." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin notes to places with /drop, readable by anyone passing by for 24h. needs location." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija notas en lugares con /drop, legibles por cualquiera que pase durante 24h. necesita ubicación." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-pin ng mga note sa mga lugar gamit ang /drop, mababasa ng sinumang dadaan sa loob ng 24h. kailangan ng lokasyon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épinglez des notes à des lieux avec /drop, lisibles par quiconque passe pendant 24h. nécessite la localisation." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמד פתקים למקומות עם /drop, קריאים לכל מי שעובר במשך 24 שעות. דורש מיקום." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop से जगहों पर नोट पिन करें, 24 घंटे तक वहां से गुजरने वाला कोई भी पढ़ सकता है। स्थान आवश्यक।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan catatan di tempat dengan /drop, dapat dibaca siapa pun yang lewat selama 24 jam. perlu lokasi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "fissa note nei luoghi con /drop, leggibili da chiunque passi per 24h. richiede la posizione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop で場所にメモをピン留め。24時間、通りかかった誰でも読めます。位置情報が必要です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop로 장소에 메모를 고정하세요. 24시간 동안 지나가는 누구나 읽을 수 있습니다. 위치 필요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinkan nota di tempat dengan /drop, boleh dibaca sesiapa yang lalu selama 24 jam. perlukan lokasi." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop ले ठाउँहरूमा नोट पिन गर्नुहोस्, 24 घण्टासम्म त्यहाँबाट जाने जोकोहीले पढ्न सक्छ। स्थान आवश्यक।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin notities aan plekken met /drop, 24 uur leesbaar voor iedereen die langskomt. vereist locatie." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypinaj notatki do miejsc za pomocą /drop, czytelne dla każdego przechodzącego przez 24h. wymaga lokalizacji." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe notas em locais com /drop, legíveis por quem passar durante 24h. requer localização." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe notas em lugares com /drop, legíveis por quem passar durante 24h. precisa de localização." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепляйте заметки в местах с помощью /drop — 24 часа их сможет прочитать любой, кто проходит мимо. нужна геолокация." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "fäst anteckningar på platser med /drop, läsbara av alla som passerar i 24h. kräver plats." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop மூலம் இடங்களில் குறிப்புகளை பின் செய்யுங்கள், 24 மணி நேரம் கடந்து செல்லும் யாரும் படிக்கலாம். இருப்பிடம் தேவை." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักโน้ตไว้ตามสถานที่ด้วย /drop ใครผ่านมาก็อ่านได้ตลอด 24 ชั่วโมง ต้องใช้ตำแหน่งที่ตั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop ile yerlere not sabitle, 24 saat boyunca yoldan geçen herkes okuyabilir. konum gerekir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріплюйте нотатки в місцях за допомогою /drop — 24 години їх зможе прочитати будь-хто, хто проходить повз. потрібне місцезнаходження." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop سے جگہوں پر نوٹ پن کریں، 24 گھنٹے تک وہاں سے گزرنے والا کوئی بھی پڑھ سکتا ہے۔ مقام درکار ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim ghi chú vào địa điểm bằng /drop, ai đi ngang cũng đọc được trong 24h. cần vị trí." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用 /drop 把便签钉在地点,24小时内路过的任何人都能读到。需要位置权限。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用 /drop 將便箋釘在地點,24小時內路過的任何人都能讀到。需要位置權限。" + } + } + } + }, + "app_info.location.notes.title" : { + "comment" : "Title of the location notes toggle in app info", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ملاحظات الموقع" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অবস্থান নোট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "standortnotizen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "location notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas de ubicación" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga note sa lokasyon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "notes de lieu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פתקי מיקום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थान नोट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "catatan lokasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "note di posizione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置メモ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위치 쪽지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "nota lokasi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थान नोटहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "locatienotities" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "notatki lokalizacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas de localização" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas de localização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "геозаметки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "platsanteckningar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இருப்பிடக் குறிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โน้ตตำแหน่งที่ตั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "konum notları" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "геонотатки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مقام کے نوٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghi chú vị trí" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置留言" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置留言" + } + } + } + }, + "app_info.network.title" : { + "comment" : "Section header for network diagnostics in the app info sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الشبكة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নেটওয়ার্ক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NETZWERK" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NETWORK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RED" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NETWORK" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RÉSEAU" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "רשת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नेटवर्क" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "JARINGAN" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RETE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ネットワーク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "네트워크" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RANGKAIAN" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नेटवर्क" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NETWERK" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIEĆ" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "REDE" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "REDE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "СЕТЬ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NÄTVERK" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நெட்வொர்க்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เครือข่าย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AĞ" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "МЕРЕЖА" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نیٹ ورک" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "MẠNG" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "网络" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "網路" + } + } + } + }, + "app_info.network.topology.description" : { + "comment" : "Row description for the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خريطة الأقران والروابط المُستنتَجة من إعلانات mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ অ্যানাউন্স থেকে জানা পিয়ার ও লিঙ্কের মানচিত্র" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "karte der peers und verbindungen, aus mesh-announces gelernt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "map of peers and links learned from mesh announces" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mapa de peers y enlaces obtenidos de los anuncios del mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mapa ng mga peer at link na natutunan mula sa mga mesh announce" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "carte des pairs et des liens issue des annonces mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מפה של עמיתים וקישורים שנלמדו מהכרזות mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश अनाउंस से सीखे गए पीयरों और लिंकों का नक्शा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peta peer dan tautan yang dipelajari dari announce mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mappa dei peer e dei collegamenti appresa dagli announce mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh announceから学習したピアとリンクのマップ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh announce에서 학습한 피어와 링크의 지도" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peta peer dan pautan yang dipelajari daripada announce mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh घोषणाबाट थाहा भएका सहकर्मी र लिंकहरूको नक्सा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kaart van peers en verbindingen, geleerd uit mesh-announces" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mapa peerów i połączeń poznanych z ogłoszeń mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mapa de pares e ligações obtido dos announces mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mapa de pares e conexões aprendido dos anúncios do mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "карта пиров и связей, полученная из mesh-анонсов" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "karta över peers och länkar från mesh-announces" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh அறிவிப்புகளிலிருந்து அறியப்பட்ட peer-கள் மற்றும் இணைப்புகளின் வரைபடம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แผนที่ของเพียร์และการเชื่อมต่อที่เรียนรู้จาก mesh announce" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh duyurularından öğrenilen eşlerin ve bağlantıların haritası" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "карта пірів і зв'язків, отриманих з оголошень mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh اعلانات سے سیکھے گئے ہم منصبوں اور روابط کا نقشہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bản đồ các nút ngang hàng và liên kết học được từ các announce mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "根据 mesh 广播获知的同伴与链路地图" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "根據 mesh 宣告得知的同伴與連線地圖" + } + } + } + }, + "app_info.network.topology.hint" : { + "comment" : "Accessibility hint for the mesh topology row", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح خريطة طوبولوجيا mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ টপোলজি মানচিত্র খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet die mesh-topologie-karte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the mesh topology map" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre el mapa de topología del mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "binubuksan ang mapa ng mesh topology" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre la carte de topologie mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את מפת טופולוגיית ה-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश टोपोलॉजी नक्शा खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka peta topologi mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre la mappa della topologia mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh トポロジーマップを開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 토폴로지 지도를 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka peta topologi mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh टोपोलोजी नक्सा खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent de mesh-topologiekaart" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera mapę topologii mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre o mapa de topologia mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre o mapa de topologia do mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает карту топологии mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar mesh-topologikartan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh டோபாலஜி வரைபடத்தைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแผนที่โทโพโลยี mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topolojisi haritasını açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває карту топології mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ٹوپولوجی نقشہ کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở bản đồ cấu trúc mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打开 mesh 拓扑图" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打開 mesh 拓撲地圖" + } + } + } + }, + "app_info.network.topology.title" : { + "comment" : "Row title opening the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "طوبولوجيا mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ টপোলজি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "topología del mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topology" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טופולוגיית mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश टोपोलॉजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh トポロジー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 토폴로지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh टोपोलोजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia do mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топология mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologi" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh டோபாலஜி" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "โทโพโลยี mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topolojisi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топологія mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ٹوپولوجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cấu trúc mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 拓扑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 拓撲" + } + } + } + }, "app_info.privacy.ephemeral.description" : { "extractionState" : "manual", "localizations" : { @@ -6268,6 +10232,2333 @@ } } }, + "app_info.settings.bridge.cell" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خلية اللقاء: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিলনস্থল সেল: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "rendezvous-zelle: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "rendezvous cell: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "celda de encuentro: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "rendezvous cell: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "cellule de rendez-vous : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תא מפגש: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिलन सेल: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sel titik temu: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cella di incontro: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ランデブーセル: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "랑데부 셀: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sel titik temu: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "भेट हुने सेल: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ontmoetingscel: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "komórka spotkania: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "célula de encontro: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "célula de encontro: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ячейка встречи: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mötescell: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சந்திப்பு செல்: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เซลล์นัดพบ: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buluşma hücresi: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "комірка зустрічі: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ملاقات سیل: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ô hẹn gặp: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会合单元:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "會合單元:%@" + } + } + } + }, + "app_info.settings.bridge.no_cell" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد خلية لقاء بعد — يلزم صلاحية الموقع أو قرين جسر قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখনও কোনো মিলনস্থল সেল নেই — লোকেশন অ্যাক্সেস বা কাছাকাছি একটি ব্রিজ পিয়ার দরকার" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "noch keine rendezvous-zelle — braucht standortzugriff oder einen brücken-peer in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no rendezvous cell yet — needs location access or a nearby bridge peer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no hay celda de encuentro — necesita acceso a la ubicación o un peer puente cercano" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "wala pang rendezvous cell — kailangan ng access sa lokasyon o kalapit na bridge peer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pas encore de cellule de rendez-vous — nécessite l'accès localisation ou un pair pont à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עדיין אין תא מפגש — נדרשת גישת מיקום או עמית גשר קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अभी कोई मिलन सेल नहीं — लोकेशन एक्सेस या आसपास का कोई ब्रिज पीयर चाहिए" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada sel titik temu — perlu akses lokasi atau peer jembatan terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ancora nessuna cella di incontro — serve l'accesso alla posizione o un peer ponte vicino" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ランデブーセルはまだありません — 位置アクセスか近くのブリッジピアが必要です" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 랑데부 셀이 없습니다 — 위치 접근 권한이나 근처의 브리지 피어가 필요합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada sel titik temu — perlu capaian lokasi atau peer jambatan berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अहिलेसम्म भेट हुने सेल छैन — स्थान पहुँच वा नजिकैको bridge peer चाहिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nog geen ontmoetingscel — vereist toegang tot locatie of een brug-peer in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie ma jeszcze komórki spotkania — potrzebny dostęp do lokalizacji lub pobliski peer mostu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda sem célula de encontro — precisa de acesso à localização ou de um par ponte próximo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda sem célula de encontro — precisa de acesso à localização ou de um par ponte por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ячейки встречи пока нет — нужен доступ к локации или ближайший пир-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen mötescell ännu — kräver platsåtkomst eller en brygg-peer i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்னும் சந்திப்பு செல் இல்லை — இட அணுகல் அல்லது அருகிலுள்ள பாலம் peer தேவை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยังไม่มีเซลล์นัดพบ — ต้องมีสิทธิ์เข้าถึงตำแหน่งหรือเพียร์บริดจ์ที่อยู่ใกล้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "henüz buluşma hücresi yok — konum erişimi veya yakında bir köprü eşi gerekiyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "комірки зустрічі поки немає — потрібен доступ до локації або ближній пір-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابھی کوئی ملاقات سیل نہیں — لوکیشن رسائی یا قریبی پل ہم منصب درکار ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chưa có ô hẹn gặp — cần quyền truy cập vị trí hoặc một nút cầu nối gần đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无会合单元 — 需要位置访问或附近有桥接同伴" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚無會合單元 — 需要位置存取權或附近有橋接同伴" + } + } + } + }, + "app_info.settings.bridge.subtitle" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يصل جزر mesh القريبة عبر الإنترنت: ما تقوله في قناة mesh يصل أيضًا إلى أشخاص في منطقتك خارج مدى الراديو، وتظهر رسائلهم هنا معلَّمة برمز الشبكة. ما دام لديك إنترنت، يحمل جهازك أيضًا حركة الجسر وقنوات الموقع للهواتف من حولك التي لا تملك اتصالًا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি মেশ দ্বীপগুলোকে ইন্টারনেটের মাধ্যমে যুক্ত করে: মেশ চ্যানেলে আপনি যা বলেন তা আপনার এলাকার রেডিও পরিসরের বাইরের মানুষের কাছেও পৌঁছায়, আর তাদের বার্তা নেটওয়ার্ক চিহ্নসহ এখানে দেখা যায়। আপনার ইন্টারনেট থাকাকালীন, আপনার ডিভাইস আশপাশের ইন্টারনেটবিহীন ফোনের জন্য ব্রিজ ও লোকেশন-চ্যানেলের ট্র্যাফিকও বহন করে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindet nahe mesh-inseln über das internet: was du im mesh-kanal sagst, erreicht auch personen in deiner gegend außerhalb der funkreichweite, und ihre nachrichten erscheinen hier mit dem netzwerk-symbol markiert. solange du internet hast, trägt dein gerät auch brücken- und standortkanal-verkehr für handys um dich herum, die keins haben." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "une islas mesh cercanas a través de internet: lo que dices en el canal mesh también llega a personas de tu zona fuera del alcance de radio, y sus mensajes aparecen aquí marcados con el símbolo de red. mientras tengas internet, tu dispositivo también lleva el tráfico del puente y de los canales de ubicación para teléfonos a tu alrededor que no tienen conexión." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinagdurugtong ang mga kalapit na mesh island sa pamamagitan ng internet: ang sinasabi mo sa mesh channel ay umaabot din sa mga tao sa iyong lugar na lampas sa saklaw ng radyo, at lumalabas dito ang kanilang mga mensahe na may markang simbolo ng network. habang may internet ka, dinadala rin ng device mo ang trapiko ng bridge at ng mga channel ng lokasyon para sa mga teleponong nasa paligid mo na walang koneksyon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relie les îlots mesh voisins via internet : ce que tu dis dans le canal mesh atteint aussi les personnes de ta zone hors de portée radio, et leurs messages apparaissent ici marqués du symbole réseau. tant que tu as internet, ton appareil achemine aussi le trafic du pont et des canaux localisation pour les téléphones autour de toi qui n'en ont pas." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחבר איי mesh קרובים דרך האינטרנט: מה שאתה אומר בערוץ ה-mesh מגיע גם לאנשים באזורך מעבר לטווח הרדיו, וההודעות שלהם מופיעות כאן מסומנות בסמל הרשת. כל עוד יש לך אינטרנט, המכשיר שלך גם מעביר תעבורת גשר וערוצי מיקום עבור טלפונים סביבך שאין להם חיבור." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आसपास के मेश द्वीपों को इंटरनेट से जोड़ता है: मेश चैनल में आप जो कहते हैं वह आपके क्षेत्र में रेडियो दायरे से बाहर के लोगों तक भी पहुँचता है, और उनके संदेश यहाँ नेटवर्क चिह्न के साथ दिखते हैं। जब तक आपके पास इंटरनेट है, आपका डिवाइस आसपास के बिना इंटरनेट वाले फोनों के लिए ब्रिज और लोकेशन-चैनल का ट्रैफ़िक भी पहुँचाता है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyatukan pulau-pulau mesh terdekat lewat internet: yang kamu katakan di kanal mesh juga sampai ke orang-orang di areamu di luar jangkauan radio, dan pesan mereka muncul di sini ditandai dengan simbol jaringan. selama kamu punya internet, perangkatmu juga membawa lalu lintas jembatan dan kanal lokasi untuk ponsel di sekitarmu yang tidak punya koneksi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "unisce le isole mesh vicine tramite internet: ciò che dici nel canale mesh raggiunge anche persone della tua zona oltre la portata radio, e i loro messaggi appaiono qui contrassegnati con il simbolo di rete. finché hai internet, il tuo dispositivo trasporta anche il traffico del ponte e dei canali posizione per i telefoni intorno a te che non ne hanno." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのmeshの島々をインターネットでつなぎます: meshチャンネルでの発言はエリア内の電波範囲外の人にも届き、相手のメッセージはネットワーク記号付きでここに表示されます。インターネットがある間は、あなたの端末が周囲の接続を持たない端末のためにブリッジとロケーションチャンネルの通信も運びます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처의 mesh 섬들을 인터넷으로 연결합니다: mesh 채널에서 말한 내용이 이 지역의 전파 범위 밖 사람들에게도 전달되고, 그들의 메시지는 네트워크 기호가 붙어 여기에 표시됩니다. 인터넷이 있는 동안에는 내 기기가 주변의 연결이 없는 휴대폰을 위해 브리지와 위치 채널 트래픽도 전달합니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyatukan pulau-pulau mesh berdekatan melalui internet: apa yang kamu katakan di kanal mesh juga sampai kepada orang di kawasanmu di luar jangkauan radio, dan pesan mereka muncul di sini ditanda dengan simbol rangkaian. selagi kamu ada internet, perantimu juga membawa trafik jambatan dan kanal lokasi untuk telefon di sekelilingmu yang tiada sambungan." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकका mesh टापुहरूलाई इन्टरनेटमार्फत जोड्छ: mesh च्यानलमा तिमीले भनेको कुरा तिम्रो क्षेत्रमा रेडियो पहुँचभन्दा बाहिरका मानिससम्म पनि पुग्छ, र उनीहरूका सन्देश नेटवर्क चिन्हसहित यहाँ देखिन्छन्। तिमीसँग इन्टरनेट भएसम्म, तिम्रो यन्त्रले वरपरका इन्टरनेट नभएका फोनहरूका लागि पुल र स्थान-च्यानलको ट्राफिक पनि बोक्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindt nabije mesh-eilanden via internet: wat je in het mesh-kanaal zegt bereikt ook mensen in jouw omgeving buiten radiobereik, en hun berichten verschijnen hier gemarkeerd met het netwerksymbool. zolang je internet hebt, draagt je apparaat ook brug- en locatiekanaalverkeer voor telefoons om je heen die geen verbinding hebben." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "łączy pobliskie wyspy mesh przez internet: to, co mówisz na kanale mesh, dociera też do osób w twojej okolicy poza zasięgiem radiowym, a ich wiadomości pojawiają się tutaj oznaczone symbolem sieci. dopóki masz internet, twoje urządzenie przenosi też ruch mostu i kanałów lokalizacji dla telefonów wokół ciebie, które go nie mają." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "junta ilhas mesh próximas através da internet: o que dizes no canal mesh também chega a pessoas na tua zona fora do alcance de rádio, e as mensagens delas aparecem aqui marcadas com o símbolo de rede. enquanto tens internet, o teu dispositivo também transporta o tráfego da ponte e dos canais de localização para telemóveis à tua volta que não têm ligação." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "junta ilhas mesh próximas pela internet: o que você diz no canal mesh também chega a pessoas na sua área fora do alcance de rádio, e as mensagens delas aparecem aqui marcadas com o símbolo de rede. enquanto você tem internet, seu dispositivo também leva o tráfego da ponte e dos canais de localização para celulares ao seu redor que não têm conexão." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "объединяет соседние mesh-острова через интернет: то, что ты говоришь в mesh-канале, доходит и до людей в твоём районе за пределами радиуса радиосвязи, а их сообщения появляются здесь с пометкой символом сети. пока у тебя есть интернет, твоё устройство также переносит трафик моста и каналов локации для телефонов вокруг, у которых его нет." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kopplar ihop närliggande mesh-öar via internet: det du säger i mesh-kanalen når även personer i ditt område utom radioräckvidd, och deras meddelanden visas här markerade med nätverkssymbolen. så länge du har internet bär din enhet också brygg- och platskanaltrafik åt telefoner omkring dig som saknar anslutning." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள mesh தீவுகளை இணையம் வழியாக இணைக்கிறது: mesh சேனலில் நீங்கள் சொல்வது உங்கள் பகுதியில் ரேடியோ வரம்புக்கு அப்பாலுள்ளவர்களையும் சென்றடையும், அவர்களின் செய்திகள் நெட்வொர்க் சின்னத்துடன் இங்கே தோன்றும். உங்களிடம் இணையம் இருக்கும் வரை, சுற்றியுள்ள இணைப்பு இல்லாத போன்களுக்காக உங்கள் சாதனம் பாலம் மற்றும் இட சேனல்களின் போக்குவரத்தையும் சுமக்கிறது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เชื่อมเกาะ mesh ที่อยู่ใกล้กันผ่านอินเทอร์เน็ต: สิ่งที่คุณพูดในช่อง mesh ยังไปถึงผู้คนในพื้นที่ของคุณที่อยู่นอกระยะวิทยุ และข้อความของพวกเขาจะปรากฏที่นี่พร้อมสัญลักษณ์เครือข่ายกำกับ ตราบใดที่คุณมีอินเทอร์เน็ต อุปกรณ์ของคุณยังส่งต่อทราฟฟิกของบริดจ์และช่องตามตำแหน่งให้โทรศัพท์รอบตัวที่ไม่มีอินเทอร์เน็ตด้วย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki mesh adalarını internet üzerinden birleştirir: mesh kanalında söylediklerin bölgendeki telsiz menzili dışındaki kişilere de ulaşır ve onların mesajları burada ağ simgesiyle işaretli olarak görünür. internetin olduğu sürece cihazın, çevrendeki bağlantısı olmayan telefonlar için köprü ve konum kanalı trafiğini de taşır." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "об'єднує сусідні mesh-острови через інтернет: те, що ти кажеш у mesh-каналі, доходить і до людей у твоєму районі поза радіусом радіозв'язку, а їхні повідомлення з'являються тут із позначкою символом мережі. поки в тебе є інтернет, твій пристрій також переносить трафік мосту й каналів локації для телефонів навколо, які його не мають." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی mesh جزیروں کو انٹرنیٹ کے ذریعے جوڑتا ہے: mesh چینل میں آپ جو کہتے ہیں وہ آپ کے علاقے میں ریڈیو رینج سے باہر لوگوں تک بھی پہنچتا ہے، اور ان کے پیغامات یہاں نیٹ ورک علامت کے ساتھ نظر آتے ہیں۔ جب تک آپ کے پاس انٹرنیٹ ہے، آپ کا آلہ ارد گرد کے بغیر انٹرنیٹ والے فونز کے لیے پل اور لوکیشن چینلز کی ٹریفک بھی پہنچاتا ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "nối các đảo mesh gần nhau qua internet: những gì bạn nói trong kênh mesh cũng đến được mọi người trong khu vực ngoài phạm vi sóng, và tin nhắn của họ hiện ở đây kèm ký hiệu mạng. khi bạn có internet, thiết bị của bạn cũng chuyển lưu lượng cầu nối và kênh vị trí cho những điện thoại xung quanh không có kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过互联网连接附近的 mesh 孤岛:你在 mesh 频道说的话也会送达本地区无线电范围之外的人,他们的消息会带网络符号显示在这里。当你有互联网时,你的设备还会为周围没有网络的手机传送桥接和位置频道流量。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過網際網路連接附近的 mesh 孤島:你在 mesh 頻道說的話也會送達本地區無線電範圍之外的人,他們的訊息會帶網路符號顯示在這裡。當你有網際網路時,你的裝置還會為周圍沒有網路的手機傳送橋接和位置頻道流量。" + } + } + } + }, + "app_info.settings.bridge.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جسر mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ ব্রিজ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "puente mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pont mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גשר mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेश ब्रिज" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ponte mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh पुल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "most mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ponte mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ponte mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-brygga" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh பாலம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh köprüsü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh پل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 桥接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 橋接" + } + } + } + }, + "app_info.settings.connectivity.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاتصال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সংযোগ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KONNEKTIVITÄT" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNECTIVITY" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONECTIVIDAD" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "KONEKTIBIDAD" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNECTIVITÉ" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קישוריות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कनेक्टिविटी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "KONEKTIVITAS" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNETTIVITÀ" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "KESAMBUNGAN" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "जडान" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNECTIVITEIT" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ŁĄCZNOŚĆ" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONECTIVIDADE" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONECTIVIDADE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "СВЯЗЬ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANSLUTNING" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இணைப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การเชื่อมต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "BAĞLANTI" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЗВ'ЯЗОК" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "کنیکٹیویٹی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "KẾT NỐI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線" + } + } + } + }, + "app_info.settings.danger.panic_button" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسح الذعر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "প্যানিক ওয়াইপ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "panik-löschung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "panic wipe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrado de pánico" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "panic wipe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "effacement panique" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחיקת בהלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पैनिक वाइप" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus panik" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancellazione panico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "パニック消去" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패닉 삭제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus panik" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "घबराहट मेटाइ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "paniekwissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "awaryjne wymazanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza de pânico" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza de pânico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "экстренное стирание" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "panikradering" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பதற்ற அழிப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างข้อมูลฉุกเฉิน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "panik silme" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "екстрене стирання" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پینک وائپ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa khẩn cấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "紧急抹除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "緊急抹除" + } + } + } + }, + "app_info.settings.danger.panic_confirm_action" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسح كل شيء" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সব মুছে ফেলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "alles löschen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wipe everything" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrar todo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "burahin lahat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tout effacer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק הכול" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सब कुछ मिटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semuanya" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancella tutto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて消去" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모두 지우기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semuanya" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै मेट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alles wissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wymaż wszystko" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpar tudo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apagar tudo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стереть всё" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "radera allt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அனைத்தையும் அழி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างทั้งหมด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "her şeyi sil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стерти все" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "سب کچھ مٹا دیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa tất cả" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部" + } + } + } + }, + "app_info.settings.danger.panic_confirm_title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسح كل البيانات؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সব ডেটা মুছবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle daten löschen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wipe all data?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿borrar todos los datos?" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "burahin ang lahat ng data?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "effacer toutes les données ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "למחוק את כל הנתונים?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सारा डेटा मिटाएँ?" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semua data?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancellare tutti i dati?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全データを消去しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 데이터를 지울까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semua data?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै डाटा मेट्ने हो?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle data wissen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wymazać wszystkie dane?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpar todos os dados?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apagar todos os dados?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стереть все данные?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "radera all data?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எல்லா தரவையும் அழிக்கவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างข้อมูลทั้งหมด?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tüm veriler silinsin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стерти всі дані?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمام ڈیٹا مٹا دیں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa toàn bộ dữ liệu?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部数据?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部資料?" + } + } + } + }, + "app_info.settings.danger.panic_note" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يمسح كل الرسائل والمفاتيح والهوية. النقر ثلاث مرات على شعار bitchat/ يفعل الشيء نفسه فورًا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সব বার্তা, কী ও পরিচয় মুছে দেয়। bitchat/ লোগোতে তিনবার ট্যাপ করলেও সঙ্গে সঙ্গে একই কাজ হয়।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "löscht alle nachrichten, schlüssel und identität. dreimaliges tippen auf das bitchat/-logo macht dasselbe, sofort." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borra todos los mensajes, claves e identidad. tocar tres veces el logotipo de bitchat/ hace lo mismo, al instante." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "binubura ang lahat ng mensahe, key, at pagkakakilanlan. ang pag-tap nang tatlong beses sa logo ng bitchat/ ay pareho ang ginagawa, agad-agad." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "efface tous les messages, les clés et l'identité. taper trois fois sur le logo bitchat/ fait la même chose, instantanément." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מוחק את כל ההודעות, המפתחות והזהות. הקשה משולשת על הלוגו של bitchat/ עושה את אותו הדבר, מייד." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सभी संदेश, कुंजियाँ और पहचान मिटा देता है। bitchat/ लोगो पर तीन बार टैप करने से भी तुरंत यही होता है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghapus semua pesan, kunci, dan identitas. mengetuk logo bitchat/ tiga kali melakukan hal yang sama, seketika." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancella tutti i messaggi, le chiavi e l'identità. toccare tre volte il logo bitchat/ fa lo stesso, all'istante." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべてのメッセージ、鍵、アイデンティティを消去します。bitchat/ ロゴを3回タップしても同じことが即座に行われます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 메시지, 키, 신원을 지웁니다. bitchat/ 로고를 세 번 탭해도 즉시 같은 동작이 실행됩니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghapus semua pesan, kunci, dan identiti. mengetuk logo bitchat/ tiga kali melakukan perkara yang sama, serta-merta." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै सन्देश, कुञ्जी र पहिचान मेटाउँछ। bitchat/ लोगोमा तीन पटक ट्याप गर्दा पनि तुरुन्तै त्यही हुन्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wist alle berichten, sleutels en identiteit. drie keer tikken op het bitchat/-logo doet hetzelfde, direct." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "usuwa wszystkie wiadomości, klucze i tożsamość. trzykrotne stuknięcie logo bitchat/ robi to samo, natychmiast." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logótipo bitchat/ faz o mesmo, de imediato." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logo bitchat/ faz o mesmo, instantaneamente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стирает все сообщения, ключи и личность. тройной тап по логотипу bitchat/ делает то же самое, мгновенно." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "raderar alla meddelanden, nycklar och identitet. att trycka tre gånger på bitchat/-logotypen gör detsamma, direkt." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எல்லா செய்திகள், சாவிகள், அடையாளத்தையும் அழிக்கிறது. bitchat/ லோகோவை மூன்று முறைத் தட்டினாலும் உடனடியாக அதுவே நடக்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลบข้อความ กุญแจ และตัวตนทั้งหมด การแตะโลโก้ bitchat/ สามครั้งก็ทำแบบเดียวกันทันที" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tüm mesajları, anahtarları ve kimliği siler. bitchat/ logosuna üç kez dokunmak da aynısını anında yapar." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стирає всі повідомлення, ключі та особистість. потрійний дотик до логотипа bitchat/ робить те саме, миттєво." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمام پیغامات، کلیدیں اور شناخت مٹا دیتا ہے۔ bitchat/ لوگو پر تین بار ٹیپ کرنے سے بھی فوراً یہی ہوتا ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa toàn bộ tin nhắn, khóa và danh tính. chạm ba lần vào logo bitchat/ cũng làm điều tương tự, ngay lập tức." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除所有消息、密钥和身份。三击 bitchat/ 标志也会立即执行相同操作。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除所有訊息、金鑰和身分。三擊 bitchat/ 標誌也會立即執行相同操作。" + } + } + } + }, + "app_info.settings.danger.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "منطقة الخطر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বিপদ অঞ্চল" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GEFAHRENZONE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DANGER ZONE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA DE PELIGRO" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "DANGER ZONE" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONE DE DANGER" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אזור סכנה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "खतरे का क्षेत्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA BERBAHAYA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA PERICOLOSA" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "危険ゾーン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위험 구역" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZON BAHAYA" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "खतरा क्षेत्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "GEVARENZONE" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "STREFA ZAGROŻENIA" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA DE PERIGO" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA DE PERIGO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОПАСНАЯ ЗОНА" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "FARLIG ZON" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஆபத்து மண்டலம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โซนอันตราย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TEHLİKELİ BÖLGE" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "НЕБЕЗПЕЧНА ЗОНА" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطرے کا علاقہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "VÙNG NGUY HIỂM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "危险区域" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "危險區域" + } + } + } + }, + "app_info.tab.info" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "معلومات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "তথ্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "impormasyon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "infos" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מידע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जानकारी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "情報" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정보" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "जानकारी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "informacje" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "informações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "инфо" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தகவல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อมูล" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bilgi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "інфо" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "معلومات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thông tin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "信息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資訊" + } + } + } + }, + "app_info.tab.picker_label" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عرض" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ভিউ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ansicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "view" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "vista" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "view" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "vue" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תצוגה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दृश्य" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tampilan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "vista" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "paparan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "दृश्य" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "weergave" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "widok" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "vista" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "exibição" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "вид" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "vy" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "காட்சி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มุมมอง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "görünüm" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "вигляд" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "منظر" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chế độ xem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "视图" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檢視" + } + } + } + }, + "app_info.tab.settings" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإعدادات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সেটিংস" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajustes" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "settings" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "réglages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגדרות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सेटिंग्स" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengaturan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tetapan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सेटिङ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ustawienia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "definições" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajustes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "настройки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inställningar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அமைப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ตั้งค่า" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ayarlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "налаштування" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "سیٹنگز" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + } + } + }, "app_info.tagline" : { "extractionState" : "manual", "localizations" : { @@ -6447,360 +12738,1079 @@ } } }, - "app_info.warning.message" : { - "extractionState" : "manual", + "app_info.voice.live.description" : { + "comment" : "Description of the live voice messages setting", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "أمان الرسائل الخاصة لم يتم تدقيقه بالكامل بعد. لا تستخدمها في الحالات الحرجة حتى يختفي هذا التحذير." + "value" : "يُبث أثناء التحدث؛ الصوت المباشر الوارد يُشغَّل تلقائيًا" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "ব্যক্তিগত বার্তার নিরাপত্তা এখনো সম্পূর্ণ অডিট হয়নি। এই সতর্কতা না থাকা পর্যন্ত জরুরি পরিস্থিতিতে ব্যবহার করবেন না।" + "value" : "কথা বলার সাথে সাথে স্ট্রিম হয়; আগত লাইভ ভয়েস স্বয়ংক্রিয়ভাবে চলে" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "die sicherheit privater nachrichten wurde noch nicht vollständig geprüft. nutze sie nicht für kritische situationen, solange dieser hinweis erscheint." + "value" : "überträgt live beim sprechen; eingehende live-stimme wird automatisch abgespielt" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "private message security has not yet been fully audited. do not use for critical situations until this warning disappears." + "value" : "streams as you speak; incoming live voice plays automatically" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "la seguridad de los mensajes privados aún no ha sido auditada por completo. no lo uses en situaciones críticas hasta que este aviso desaparezca." + "value" : "transmite mientras hablas; la voz en vivo entrante se reproduce automáticamente" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "hindi pa ganap na na-audit ang seguridad ng pribadong mensahe. huwag gamitin para sa kritikal na sitwasyon hangga't hindi nawawala ang babalang ito." + "value" : "nag-i-stream habang nagsasalita ka; awtomatikong tumutugtog ang papasok na live na boses" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "la sécurité des messages privés n'a pas encore été entièrement auditée. n'utilise pas pour des situations critiques tant que cet avertissement reste." + "value" : "diffuse pendant que vous parlez ; la voix en direct entrante est lue automatiquement" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "אבטחת ההודעות הפרטיות עדיין לא נבדקה במלואה. אל תשתמש למצבים קריטיים עד שהאזהרה תיעלם." + "value" : "משדר בזמן שאתה מדבר; קול חי נכנס מושמע אוטומטית" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "निजी संदेश सुरक्षा का अभी पूरा ऑडिट नहीं हुआ है। यह चेतावनी हटने तक गंभीर स्थितियों में उपयोग न करें।" + "value" : "बोलते समय स्ट्रीम होता है; आने वाली लाइव आवाज़ अपने आप चलती है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "keamanan pesan pribadi belum diaudit sepenuhnya. jangan dipakai untuk situasi kritis sampai peringatan ini hilang." + "value" : "streaming saat Anda berbicara; suara langsung yang masuk diputar otomatis" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "la sicurezza dei messaggi privati non è stata ancora auditata completamente. non usarli in situazioni critiche finché questo avviso resta." + "value" : "trasmette mentre parli; la voce in diretta in arrivo viene riprodotta automaticamente" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "プライベートメッセージの安全性はまだ完全に監査されていません。この警告が消えるまで重要な場面では使わないでください。" + "value" : "話しながらライブ配信。受信したライブ音声は自動再生されます" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "비공개 메시지 보안은 아직 완전히 감사받지 않았습니다. 이 경고가 사라질 때까지 중요한 상황에서는 사용하지 마세요." + "value" : "말하는 동안 실시간 전송됩니다. 수신된 라이브 음성은 자동 재생됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "keamanan pesan pribadi belum diaudit sepenuhnya. jangan diguna untuk situasi kritis sampai peringatan ini hilang." + "value" : "strim semasa anda bercakap; suara langsung masuk dimainkan secara automatik" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "व्यक्तिगत सन्देशको सुरक्षा पूर्ण रूपमा अडिट भएको छैन। यो चेतावनी हट्दासम्म गम्भीर अवस्थामा प्रयोग नगर्नु।" + "value" : "बोल्दै गर्दा स्ट्रिम हुन्छ; आगमन लाइभ आवाज स्वतः बज्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "de beveiliging van privéberichten is nog niet volledig geaudit. gebruik dit niet in kritieke situaties totdat deze melding verdwijnt." + "value" : "streamt terwijl je praat; inkomende live spraak wordt automatisch afgespeeld" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "bezpieczeństwo wiadomości prywatnych nie zostało jeszcze w pełni sprawdzone. nie używaj w sytuacjach krytycznych, dopóki to ostrzeżenie nie zniknie." + "value" : "przesyła na żywo podczas mówienia; przychodzący głos na żywo odtwarza się automatycznie" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "a segurança das mensagens privadas ainda não foi totalmente auditada. não uses em situações críticas até este aviso desaparecer." + "value" : "transmite enquanto fala; a voz ao vivo recebida é reproduzida automaticamente" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "a segurança das mensagens privadas ainda não foi totalmente auditada. não use em situações críticas até que este aviso desapareça." + "value" : "transmite enquanto você fala; a voz ao vivo recebida toca automaticamente" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "безопасность приватных сообщений ещё не прошла полный аудит. не используй для критичных случаев, пока предупреждение не исчезнет." + "value" : "передаёт, пока вы говорите; входящий живой голос воспроизводится автоматически" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "säkerheten för privata meddelanden är ännu inte fullständigt granskad. använd inte i kritiska situationer förrän detta meddelande försvinner." + "value" : "strömmar medan du pratar; inkommande live-röst spelas upp automatiskt" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "தனிப்பட்ட செய்தி பாதுகாப்பு இன்னும் முழுமையாக ஆய்வு செய்யப்படவில்லை. இந்த எச்சரிக்கை மறையும் வரை முக்கிய அவசரங்களுக்கு பயன்படுத்தாதீர்கள்." + "value" : "நீங்கள் பேசும்போதே நேரலையாக அனுப்பப்படும்; உள்வரும் நேரலை குரல் தானாக ஒலிக்கும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ความปลอดภัยของข้อความส่วนตัวยังไม่ได้รับการตรวจสอบทั้งหมด อย่าใช้ในสถานการณ์วิกฤติจนกว่าคำเตือนนี้จะหายไป" + "value" : "สตรีมขณะพูด เสียงสดขาเข้าจะเล่นอัตโนมัติ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "özel mesaj güvenliği henüz tamamen denetlenmedi. bu uyarı kaybolana kadar kritik durumlarda kullanmayın." + "value" : "siz konuşurken canlı iletilir; gelen canlı ses otomatik çalınır" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "безпека приватних повідомлень ще не пройшла повний аудит. не використовуй для критичних ситуацій, поки це попередження не зникне." + "value" : "передає, поки ви говорите; вхідний живий голос відтворюється автоматично" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "نجی پیغامات کی سیکیورٹی کا ابھی مکمل آڈٹ نہیں ہوا۔ اس انتباہ کے ختم ہونے تک اسے اہم حالات میں استعمال نہ کریں۔" + "value" : "بولتے وقت لائیو نشر ہوتا ہے؛ موصول ہونے والی لائیو آواز خود بخود چلتی ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "bảo mật tin nhắn riêng tư vẫn chưa được kiểm toán đầy đủ. đừng dùng cho tình huống quan trọng cho tới khi cảnh báo này biến mất." + "value" : "phát trực tiếp khi bạn nói; giọng nói trực tiếp đến sẽ tự phát" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "私信安全尚未完全审计。在此警告消失前不要用于关键情境。" + "value" : "边说边实时传输;收到的实时语音会自动播放" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "私信安全尚未完全審計。在此警告消失前不要用於關鍵情境。" + "value" : "邊說邊即時傳輸;收到的即時語音會自動播放" } } } }, - "app_info.warning.title" : { - "extractionState" : "manual", + "app_info.voice.live.title" : { + "comment" : "Title of the live voice messages setting", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تحذير" + "value" : "رسائل صوتية مباشرة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "সতর্কতা" + "value" : "লাইভ ভয়েস বার্তা" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "WARNUNG" + "value" : "live-sprachnachrichten" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "WARNING" + "value" : "live voice messages" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "ADVERTENCIA" + "value" : "mensajes de voz en vivo" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "BABALA" + "value" : "live na mga voice message" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "AVERTISSEMENT" + "value" : "messages vocaux en direct" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "אזהרה" + "value" : "הודעות קוליות חיות" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "चेतावनी" + "value" : "लाइव वॉयस संदेश" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "PERINGATAN" + "value" : "pesan suara langsung" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "AVVISO" + "value" : "messaggi vocali in diretta" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "警告" + "value" : "ライブ音声メッセージ" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "경고" + "value" : "라이브 음성 메시지" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "PERINGATAN" + "value" : "mesej suara langsung" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "चेतावनी" + "value" : "लाइभ भ्वाइस सन्देशहरू" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "WAARSCHUWING" + "value" : "live spraakberichten" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "OSTRZEŻENIE" + "value" : "wiadomości głosowe na żywo" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "AVISO" + "value" : "mensagens de voz ao vivo" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "AVISO" + "value" : "mensagens de voz ao vivo" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ПРЕДУПРЕЖДЕНИЕ" + "value" : "живые голосовые сообщения" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "VARNING" + "value" : "live-röstmeddelanden" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "எச்சரிக்கை" + "value" : "நேரலை குரல் செய்திகள்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "คำเตือน" + "value" : "ข้อความเสียงสด" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "UYARI" + "value" : "canlı sesli mesajlar" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "ПОПЕРЕДЖЕННЯ" + "value" : "живі голосові повідомлення" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "انتباہ" + "value" : "لائیو صوتی پیغامات" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "CẢNH BÁO" + "value" : "tin nhắn thoại trực tiếp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "警告" + "value" : "实时语音消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "警告" + "value" : "即時語音訊息" + } + } + } + }, + "app_info.voice.title" : { + "comment" : "Section header for voice settings in the app info sheet", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الصوت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ভয়েস" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "AUDIO" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VOICE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "VOZ" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "BOSES" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "VOIX" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קול" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वॉयस" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "SUARA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "VOCE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボイス" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음성" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "SUARA" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आवाज" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "SPRAAK" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "GŁOS" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "VOZ" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "VOZ" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ГОЛОС" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "RÖST" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "குரல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เสียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SES" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ГОЛОС" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آواز" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "GIỌNG NÓI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语音" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "語音" + } + } + } + }, + "bridge_people.accessibility.row_hint" : { + "comment" : "Accessibility hint for a person listed in the bridge section of the people sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "في منطقتك، متصل عبر الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আপনার এলাকায়, সেতুর মাধ্যমে সংযুক্ত" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In deiner Gegend, verbunden über die Brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In your area, connected through the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En tu zona, conectado a través del puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nasa inyong lugar, konektado sa pamamagitan ng tulay" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dans votre zone, connecté via le pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "באזור שלך, מחובר דרך הגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आपके क्षेत्र में, पुल के माध्यम से जुड़ा हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di area Anda, terhubung melalui jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nella tua zona, connesso tramite il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "あなたのエリア内、ブリッジ経由で接続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "당신의 지역에서 브리지를 통해 연결됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di kawasan anda, disambungkan melalui jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तपाईंको क्षेत्रमा, पुल मार्फत जोडिएको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "In jouw omgeving, verbonden via de brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "W Twojej okolicy, połączony przez most" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na sua área, ligado através da ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na sua área, conectado pela ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В вашем районе, на связи через мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "I ditt område, ansluten via bron" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "உங்கள் பகுதியில், பாலம் வழியாக இணைக்கப்பட்டுள்ளது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ในพื้นที่ของคุณ เชื่อมต่อผ่านสะพาน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bölgenizde, köprü üzerinden bağlı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вашому районі, на зв'язку через міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آپ کے علاقے میں، پل کے ذریعے منسلک" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trong khu vực của bạn, kết nối qua cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在您的区域内,通过桥接连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在您的區域內,透過橋接連線" + } + } + } + }, + "bridge_people.section_title" : { + "comment" : "Section header in the people sheet for participants reachable via the mesh bridge", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عبر الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সেতুর ওপারে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "über die brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "across the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "al otro lado del puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sa kabila ng tulay" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "de l'autre côté du pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מעבר לגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुल के पार" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "di seberang jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "oltre il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブリッジの向こう側" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리지 건너편" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "di seberang jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुल पारि" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "aan de overkant van de brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "po drugiej stronie mostu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "do outro lado da ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "do outro lado da ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "по ту сторону моста" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "på andra sidan bron" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பாலத்தின் மறுபுறம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อีกฝั่งของสะพาน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "köprünün öte yanında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "по той бік мосту" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پل کے پار" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bên kia cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "桥的另一端" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "橋的另一端" + } + } + } + }, + "Choose an image" : { + "comment" : "A label displayed above a button that allows the user to choose an image to send.", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اختر صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "একটি ছবি নির্বাচন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige una imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumili ng larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une image" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בחר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक चित्र चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un’immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지를 선택하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एउटा तस्वीर चयन गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies een afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher uma imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha uma imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj en bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือกภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir görüntü seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виберіть зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایک تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择图像" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇圖像" } } } @@ -8416,6 +15426,546 @@ } } }, + "content.accessibility.app_info_hint" : { + "comment" : "Accessibility hint on the bitchat/ logo explaining a tap opens app info", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يعرض معلومات التطبيق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অ্যাপের তথ্য দেখায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zeigt app-infos" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "shows app info" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "muestra la información de la app" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapakita ang impormasyon ng app" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "affiche les infos de l'app" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מציג את פרטי האפליקציה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ऐप जानकारी दिखाता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menampilkan info aplikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra le info dell'app" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "アプリ情報を表示します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "앱 정보를 표시합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menunjukkan maklumat aplikasi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एप जानकारी देखाउँछ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toont app-info" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pokazuje informacje o aplikacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra as informações da app" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra informações do app" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показывает информацию о приложении" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visar appinfo" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "செயலி தகவலைக் காட்டும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงข้อมูลแอป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uygulama bilgisini gösterir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показує інформацію про застосунок" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ایپ کی معلومات دکھاتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiển thị thông tin ứng dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示应用信息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示應用程式資訊" + } + } + } + }, + "content.accessibility.attach_photo" : { + "comment" : "Accessibility label for the photo attachment button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إرفاق صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি যুক্ত করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto anhängen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "attach photo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "adjuntar foto" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "maglakip ng larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "joindre une photo" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "צירוף תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़ोटो संलग्न करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lampirkan foto" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "allega foto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "写真を添付" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사진 첨부" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lampirkan foto" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर संलग्न गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto bijvoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dołącz zdjęcie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anexar foto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anexar foto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "прикрепить фото" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bifoga foto" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புகைப்படத்தை இணைக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แนบรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fotoğraf ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "додати фото" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر منسلک کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đính kèm ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "附加照片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "附加照片" + } + } + } + }, + "content.accessibility.attach_photo_hint" : { + "comment" : "Accessibility hint explaining the attachment button opens the photo library", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح مكتبة الصور؛ استخدم إجراء التقاط صورة للكاميرا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবির লাইব্রেরি খোলে; ক্যামেরার জন্য ছবি তোলার অ্যাকশন ব্যবহার করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet die fotobibliothek; nutze die aktion foto aufnehmen für die kamera" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the photo library; use the take photo action for the camera" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre la fototeca; usa la acción tomar foto para la cámara" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "binubuksan ang photo library; gamitin ang aksyong kumuha ng larawan para sa camera" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre la photothèque ; utilise l'action prendre une photo pour l'appareil photo" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את ספריית התמונות; לשימוש במצלמה השתמש בפעולת צילום תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़ोटो लाइब्रेरी खोलता है; कैमरे के लिए फ़ोटो लें क्रिया का उपयोग करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka galeri foto; gunakan aksi ambil foto untuk kamera" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre la libreria foto; usa l'azione scatta foto per la fotocamera" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "写真ライブラリを開きます。カメラには写真を撮るを使用してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사진 보관함을 엽니다. 카메라는 사진 촬영 동작을 사용하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka galeri foto; guna tindakan ambil foto untuk kamera" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर लाइब्रेरी खोल्छ; क्यामेराका लागि तस्बिर खिच्ने कार्य प्रयोग गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent de fotobibliotheek; gebruik de actie foto maken voor de camera" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera bibliotekę zdjęć; użyj akcji zrób zdjęcie, aby użyć aparatu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre a biblioteca de fotos; usa a ação tirar foto para a câmara" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre a biblioteca de fotos; use a ação tirar foto para a câmera" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает библиотеку фото; для камеры используй действие «сделать фото»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar fotobiblioteket; använd ta foto för kameran" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புகைப்படத் தொகுப்பைத் திறக்கும்; கேமராவுக்கு புகைப்படம் எடுக்கும் செயலைப் பயன்படுத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดคลังรูปภาพ ใช้การถ่ายรูปสำหรับกล้อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fotoğraf kitaplığını açar; kamera için fotoğraf çek eylemini kullanın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває бібліотеку фото; для камери скористайся дією зробити фото" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویری لائبریری کھولتا ہے؛ کیمرے کیلئے تصویر لینے کا عمل استعمال کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở thư viện ảnh; dùng thao tác chụp ảnh cho máy ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打开照片图库;使用拍照操作可调用相机" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打開照片圖庫;要用相機請使用拍照操作" + } + } + } + }, "content.accessibility.available_nostr" : { "extractionState" : "manual", "localizations" : { @@ -8774,6 +16324,544 @@ } } }, + "content.accessibility.bridged_count" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld أشخاص آخرون عبر الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্রিজের ওপারে আরও %lld জন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld weitere personen über die brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld more people across the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personas más al otro lado del puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pang tao sa kabila ng bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personnes de plus via le pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עוד %lld אנשים מעבר לגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्रिज के पार %lld और लोग" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld orang lagi di seberang jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "altre %lld persone oltre il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブリッジの先にさらに%lld人" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리지 너머 %lld명 더" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld lagi orang di seberang jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुलपारि थप %lld जना" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld extra mensen via de brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld więcej osób po drugiej stronie mostu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mais %lld pessoas do outro lado da ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mais %lld pessoas do outro lado da ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ещё %lld человек по ту сторону моста" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personer till via bryggan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பாலத்தின் அப்பால் மேலும் %lld பேர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อีก %lld คนที่อีกฝั่งของบริดจ์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "köprünün ötesinde %lld kişi daha" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ще %lld людей по той бік мосту" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پل کے پار %lld مزید لوگ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm %lld người bên kia cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "桥另一侧还有 %lld 人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "橋另一側還有 %lld 人" + } + } + } + }, + "content.accessibility.bridged_message" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وصلت عبر جسر mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ ব্রিজ পেরিয়ে এসেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über eine mesh-brücke angekommen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrived across a mesh bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Llegó a través de un puente mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dumating sa pamamagitan ng mesh bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrivé via un pont mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגיעה דרך גשר mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेश ब्रिज से होकर आया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiba lewat jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrivato attraverso un ponte mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジ経由で届きました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리지를 거쳐 도착함" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiba melalui jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh पुल हुँदै आइपुग्यो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via een mesh-brug binnengekomen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dotarła przez most mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chegou através de uma ponte mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chegou por uma ponte mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пришло через mesh-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kom via en mesh-brygga" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh பாலம் வழியாக வந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มาถึงผ่านบริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir mesh köprüsü üzerinden geldi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Надійшло через mesh-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh پل کے ذریعے پہنچا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đến qua cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "经 mesh 桥接送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "經 mesh 橋接送達" + } + } + } + }, + "content.accessibility.choose_photo" : { + "comment" : "Accessibility label for the macOS photo picker button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اختيار صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি বেছে নিন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "choose photo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "elegir foto" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pumili ng larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "choisir une photo" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "בחירת תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़ोटो चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pilih foto" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "scegli foto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "写真を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사진 선택" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pilih foto" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर छान" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto kiezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wybierz zdjęcie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "escolher foto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "escolher foto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "выбрать фото" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "välj foto" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புகைப்படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เลือกรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fotoğraf seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "вибрати фото" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chọn ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "选择照片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "選擇照片" + } + } + } + }, "content.accessibility.connected_mesh" : { "extractionState" : "manual", "localizations" : { @@ -8953,6 +17041,186 @@ } } }, + "content.accessibility.delivery_detail_hint" : { + "comment" : "Accessibility hint for the delivery status glyph explaining a tap reveals details", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط لعرض تفاصيل التسليم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ডেলিভারির বিবরণ দেখতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tippe, um zustelldetails anzuzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to show delivery details" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca para ver los detalles de entrega" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-tap para makita ang mga detalye ng paghahatid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "touche pour afficher les détails de livraison" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש להצגת פרטי מסירה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डिलीवरी विवरण दिखाने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menampilkan detail pengiriman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca per mostrare i dettagli di consegna" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "タップして配信の詳細を表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "탭하여 전송 세부정보 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menunjukkan butiran penghantaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डेलिभरी विवरण देखाउन ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tik om bezorgdetails te tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij, aby pokazać szczegóły dostarczenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca para mostrar os detalhes de entrega" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toque para ver detalhes da entrega" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нажми, чтобы показать детали доставки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tryck för att visa leveransdetaljer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வழங்கல் விவரங்களைக் காண தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะเพื่อแสดงรายละเอียดการส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teslimat ayrıntılarını göstermek için dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "торкнися, щоб показати деталі доставки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ترسیل کی تفصیلات دیکھنے کیلئے ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm để xem chi tiết gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "轻点显示送达详情" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "輕點顯示送達詳情" + } + } + } + }, "content.accessibility.encryption_status" : { "extractionState" : "manual", "localizations" : { @@ -9132,6 +17400,724 @@ } } }, + "content.accessibility.gateway_active" : { + "comment" : "Accessibility label for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بوابة الإنترنت نشطة، تشارك اتصالك مع mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ইন্টারনেট গেটওয়ে সক্রিয়, মেশের সঙ্গে আপনার সংযোগ ভাগ করছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Internet-gateway aktiv, teilt deine verbindung mit dem mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Internet gateway active, sharing your connection with the mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puerta de enlace a internet activa, compartiendo tu conexión con el mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aktibo ang internet gateway, ibinabahagi ang koneksyon mo sa mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "passerelle internet active, partage ta connexion avec le mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שער אינטרנט פעיל, משתף את החיבור שלך עם ה-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इंटरनेट गेटवे सक्रिय, आपका कनेक्शन मेश के साथ साझा किया जा रहा है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet aktif, membagikan koneksimu dengan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet attivo, condivide la tua connessione con la mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "インターネットゲートウェイが有効、接続をmeshと共有中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "인터넷 게이트웨이 활성화됨, 연결을 mesh와 공유 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet aktif, berkongsi sambunganmu dengan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इन्टरनेट गेटवे सक्रिय, तिम्रो जडान mesh सँग साझेदारी गरिँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internetgateway actief, deelt je verbinding met de mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "brama internetowa aktywna, udostępniasz swoje połączenie sieci mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway de internet ativo, a partilhar a tua ligação com a mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gateway de internet ativo, compartilhando sua conexão com o mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "интернет-шлюз активен, соединение раздаётся в mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internetgateway aktiv, delar din anslutning med mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணைய நுழைவாயில் செயலில் உள்ளது, உங்கள் இணைப்பை mesh உடன் பகிர்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เกตเวย์อินเทอร์เน็ตทำงานอยู่ กำลังแชร์การเชื่อมต่อของคุณกับ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internet ağ geçidi etkin, bağlantınız mesh ile paylaşılıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "інтернет-шлюз активний, ділишся своїм з'єднанням з mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انٹرنیٹ گیٹ وے فعال، آپ کا کنکشن mesh کے ساتھ شیئر کر رہا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cổng internet đang hoạt động, chia sẻ kết nối của bạn với mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "互联网网关已激活,正在与 mesh 共享你的连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "網際網路閘道已啟用,正在與 mesh 分享你的連線" + } + } + } + }, + "content.accessibility.gateway_settings_hint" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يفتح الإعدادات لتشغيل البوابة أو إيقافها" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "গেটওয়ে চালু বা বন্ধ করতে সেটিংস খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnet die einstellungen, um das gateway ein- oder auszuschalten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens settings to turn the gateway on or off" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre los ajustes para activar o desactivar la puerta de enlace" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Binubuksan ang mga setting para i-on o i-off ang gateway" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvre les réglages pour activer ou désactiver la passerelle" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פותח את ההגדרות כדי להפעיל או לכבות את השער" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "गेटवे चालू या बंद करने के लिए सेटिंग्स खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Membuka pengaturan untuk menyalakan atau mematikan gateway" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apre le impostazioni per attivare o disattivare il gateway" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定を開いてゲートウェイのオン/オフを切り替えます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정을 열어 게이트웨이를 켜거나 끕니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Membuka tetapan untuk menghidupkan atau mematikan gateway" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "गेटवे खोल्न वा बन्द गर्न सेटिङहरू खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opent instellingen om de gateway aan of uit te zetten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otwiera ustawienia, aby włączyć lub wyłączyć bramę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre as definições para ligar ou desligar o gateway" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre as configurações para ligar ou desligar o gateway" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает настройки, чтобы включить или выключить шлюз" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öppnar inställningar för att slå på eller av gatewayen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "கேட்வேயை இயக்க அல்லது அணைக்க அமைப்புகளைத் திறக்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดการตั้งค่าเพื่อเปิดหรือปิดเกตเวย์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ağ geçidini açmak veya kapatmak için ayarları açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Відкриває налаштування, щоб увімкнути або вимкнути шлюз" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "گیٹ وے کو آن یا آف کرنے کے لیے ترتیبات کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở cài đặt để bật hoặc tắt gateway" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开设置以开启或关闭网关" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟設定以開啟或關閉閘道" + } + } + } + }, + "content.accessibility.group_chat" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دردشة جماعية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ চ্যাট" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gruppenchat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group chat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chat de grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Group chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Discussion de groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "צ'אט קבוצתי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह चैट" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obrolan grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chat di gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループチャット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 채팅" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sembang kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह च्याट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Groepschat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "czat grupowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Conversa de grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chat de grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "групповой чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppchatt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு உரையாடல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แชทกลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup sohbeti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "груповий чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ چیٹ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "trò chuyện nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群聊" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群組聊天" + } + } + } + }, + "content.accessibility.jump_to_latest" : { + "comment" : "Accessibility label for the jump to latest messages button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الانتقال إلى أحدث الرسائل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সর্বশেষ বার্তায় যান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zu den neuesten nachrichten springen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "jump to latest messages" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ir a los mensajes más recientes" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tumalon sa pinakabagong mga mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aller aux derniers messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "קפיצה להודעות האחרונות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नवीनतम संदेशों पर जाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lompat ke pesan terbaru" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "vai ai messaggi più recenti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "最新のメッセージへ移動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "최신 메시지로 이동" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lompat ke pesan terbaru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पछिल्ला सन्देशमा जाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "naar de nieuwste berichten springen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "przejdź do najnowszych wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ir para as mensagens mais recentes" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pular para as mensagens mais recentes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "перейти к последним сообщениям" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hoppa till senaste meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சமீபத்திய செய்திகளுக்குச் செல்லவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไปยังข้อความล่าสุด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "en son mesajlara atla" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "перейти до останніх повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تازہ ترین پیغامات پر جائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhảy đến tin nhắn mới nhất" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "跳到最新消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "跳至最新訊息" + } + } + } + }, "content.accessibility.location_channels" : { "extractionState" : "manual", "localizations" : { @@ -9311,181 +18297,720 @@ } } }, - "content.accessibility.location_notes" : { + "content.accessibility.nearby_only_off" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "ملاحظات الموقع لهذا المكان" + "value" : "موصول بالجسر: تصل الرسائل أيضًا إلى من هم عبر الجسر" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "এই স্থানের লোকেশন নোট" + "value" : "ব্রিজযুক্ত: বার্তা ব্রিজের ওপারের মানুষের কাছেও পৌঁছায়" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "standortnotizen für diesen ort" + "value" : "Überbrückt: nachrichten erreichen auch personen über die brücke" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "location notes for this place" + "value" : "Bridged: messages also reach people across the bridge" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "notas de ubicación de este lugar" + "value" : "Con puente: los mensajes también llegan a personas al otro lado del puente" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "mga tala para sa lugar na ito" + "value" : "Naka-bridge: umaabot din ang mga mensahe sa mga tao sa kabila ng bridge" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "notes de localisation pour cet endroit" + "value" : "Pont actif : les messages atteignent aussi les gens de l'autre côté du pont" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "הערות מיקום למקום הזה" + "value" : "מגושר: ההודעות מגיעות גם לאנשים מעבר לגשר" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "इस स्थान के लोकेशन नोट" + "value" : "ब्रिज सक्रिय: संदेश ब्रिज के पार के लोगों तक भी पहुँचते हैं" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "catatan lokasi untuk tempat ini" + "value" : "Terjembatani: pesan juga sampai ke orang di seberang jembatan" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "note di posizione per questo posto" + "value" : "Con ponte: i messaggi raggiungono anche le persone oltre il ponte" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "この場所のロケーションノート" + "value" : "ブリッジ中: メッセージはブリッジの先の人にも届きます" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 장소의 위치 노트" + "value" : "브리지 사용: 메시지가 브리지 너머 사람들에게도 전달됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "catatan lokasi untuk tempat ini" + "value" : "Berjambatan: pesan juga sampai kepada orang di seberang jambatan" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "यस ठाउँका स्थान नोटहरू" + "value" : "पुल जोडिएको: सन्देशहरू पुलपारिका मानिससम्म पनि पुग्छन्" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "locatienotities voor deze plek" + "value" : "Overbrugd: berichten bereiken ook mensen via de brug" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "notatki lokalizacyjne dla tego miejsca" + "value" : "Zmostkowane: wiadomości docierają też do osób po drugiej stronie mostu" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "notas de localização deste lugar" + "value" : "Com ponte: as mensagens também chegam a pessoas do outro lado da ponte" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "notas de localização deste lugar" + "value" : "Com ponte: as mensagens também chegam a pessoas do outro lado da ponte" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "заметки для этого места" + "value" : "Через мост: сообщения доходят и до людей по ту сторону моста" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "platsanteckningar för den här platsen" + "value" : "Bryggad: meddelanden når även personer via bryggan" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "இந்த இடத்திற்கான குறிப்புகள்" + "value" : "பாலம் இணைந்தது: செய்திகள் பாலத்தின் அப்பாலுள்ளவர்களையும் சென்றடையும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "บันทึกตำแหน่งสำหรับสถานที่นี้" + "value" : "บริดจ์อยู่: ข้อความยังไปถึงคนที่อีกฝั่งของบริดจ์ด้วย" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "bu yer için konum notları" + "value" : "Köprülü: mesajlar köprünün ötesindeki kişilere de ulaşır" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "замітки про це місце" + "value" : "Через міст: повідомлення доходять і до людей по той бік мосту" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "اس جگہ کیلئے لوکیشن نوٹس" + "value" : "پل فعال: پیغامات پل کے پار لوگوں تک بھی پہنچتے ہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "ghi chú vị trí cho nơi này" + "value" : "Đã bắc cầu: tin nhắn cũng đến người bên kia cầu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此位置的笔记" + "value" : "已桥接:消息也会送达桥另一侧的人" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此位置的筆記" + "value" : "已橋接:訊息也會送達橋另一側的人" + } + } + } + }, + "content.accessibility.nearby_only_on" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قريب فقط: تبقى الرسائل ضمن مدى الراديو" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "শুধু কাছাকাছি: বার্তা রেডিও পরিসরের মধ্যেই থাকে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur in der nähe: nachrichten bleiben innerhalb der funkreichweite" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nearby only: messages stay within radio range" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo cerca: los mensajes se quedan dentro del alcance de radio" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malapit lamang: nananatili ang mga mensahe sa loob ng saklaw ng radyo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proximité uniquement : les messages restent à portée radio" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קרוב בלבד: ההודעות נשארות בטווח הרדיו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "केवल आसपास: संदेश रेडियो दायरे के भीतर रहते हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hanya terdekat: pesan tetap dalam jangkauan radio" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo vicinanze: i messaggi restano entro la portata radio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのみ: メッセージは電波範囲内にとどまります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처 전용: 메시지가 전파 범위 안에만 머뭅니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berdekatan sahaja: pesan kekal dalam jangkauan radio" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिक मात्र: सन्देशहरू रेडियो पहुँचभित्रै रहन्छन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alleen dichtbij: berichten blijven binnen radiobereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tylko w pobliżu: wiadomości pozostają w zasięgu radiowym" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto: as mensagens ficam dentro do alcance de rádio" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto: as mensagens ficam dentro do alcance de rádio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только рядом: сообщения остаются в радиусе радиосвязи" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endast i närheten: meddelanden stannar inom radioräckvidd" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகில் மட்டும்: செய்திகள் ரேடியோ வரம்புக்குள் இருக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เฉพาะใกล้เคียง: ข้อความอยู่ภายในระยะวิทยุ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca yakın: mesajlar telsiz menzili içinde kalır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лише поблизу: повідомлення залишаються в радіусі радіозв'язку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صرف قریبی: پیغامات ریڈیو رینج کے اندر رہتے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ gần đây: tin nhắn ở trong phạm vi sóng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅限附近:消息只在无线电范围内传播" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "僅限附近:訊息只在無線電範圍內傳播" + } + } + } + }, + "content.accessibility.notices" : { + "comment" : "Accessibility label for the notices button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyurular" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + } + } + }, + "content.accessibility.notices_new" : { + "comment" : "Accessibility value for the notices button when unseen pins arrived", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld جديد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldটি নতুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld neu" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuevos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bago" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nouvelles" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld חדשות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld नई" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuovi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld件の新着" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld개 새 항목" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld baharu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld नयाँ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nieuw" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nowych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld novos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld novos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld новых" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nya" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld புதியவை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ใหม่ %lld รายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld yeni" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld нових" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld نئے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 条新公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 則新公告" } } } @@ -9669,6 +19194,366 @@ } } }, + "content.accessibility.peers_connected" : { + "comment" : "Accessibility value when peers are reachable", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "متصل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সংযুক্ত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nakakonekta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connecté" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחובר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "जुड़ा हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "terhubung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connesso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "接続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "연결됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bersambung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "जडान भयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "połączono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ligado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conectado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "подключено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ansluten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணைக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เชื่อมต่อแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bağlı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "з'єднано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جڑا ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已連線" + } + } + } + }, + "content.accessibility.peers_none" : { + "comment" : "Accessibility value when no peers are reachable", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا أحد يمكن الوصول إليه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কেউ পৌঁছানোর মতো নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "niemand erreichbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no one reachable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nadie alcanzable" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "walang taong maabot" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "personne n'est joignable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אף אחד לא נגיש" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कोई पहुँच योग्य नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak ada yang bisa dijangkau" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nessuno raggiungibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "到達可能な相手がいません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "도달 가능한 사람 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tiada siapa boleh dicapai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कोही पुग्न सकिँदैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "niemand bereikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nikt nieosiągalny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ninguém acessível" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ninguém alcançável" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "никого не достать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingen nåbar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "யாரையும் அணுக முடியாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่มีใครที่เข้าถึงได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kimseye ulaşılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нікого не досяжно" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کوئی قابل رسائی نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không ai có thể tiếp cận" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "没有可达的人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "沒有可到達的人" + } + } + } + }, "content.accessibility.people_count" : { "extractionState" : "manual", "localizations" : { @@ -10590,6 +20475,546 @@ } } }, + "content.accessibility.record_voice_hint" : { + "comment" : "Accessibility hint explaining double-tap toggles voice recording", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط ضغطة مزدوجة لبدء التسجيل، واضغط مرة أخرى للإرسال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "রেকর্ডিং শুরু করতে দুইবার ট্যাপ করুন, পাঠাতে আবার দুইবার ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "doppeltippen zum aufnehmen, erneut doppeltippen zum senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "double-tap to start recording, double-tap again to send" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca dos veces para empezar a grabar, toca dos veces de nuevo para enviar" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-double tap para simulan ang pagre-record, i-double tap ulit para ipadala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "double-touche pour démarrer l'enregistrement, double-touche à nouveau pour envoyer" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש הקשה כפולה כדי להתחיל הקלטה, הקש שוב כדי לשלוח" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रिकॉर्डिंग शुरू करने के लिए दो बार टैप करें, भेजने के लिए फिर दो बार टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk dua kali untuk mulai merekam, ketuk dua kali lagi untuk mengirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca due volte per iniziare a registrare, tocca di nuovo due volte per inviare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ダブルタップで録音開始、もう一度ダブルタップで送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "두 번 탭하여 녹음 시작, 다시 두 번 탭하여 전송" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk dua kali untuk mula merakam, ketuk dua kali lagi untuk menghantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रेकर्ड सुरु गर्न दुई पटक ट्याप गर, पठाउन फेरि दुई पटक ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dubbeltik om op te nemen, dubbeltik opnieuw om te verzenden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij dwukrotnie, aby rozpocząć nagrywanie, stuknij ponownie, aby wysłać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca duas vezes para começar a gravar, toca duas vezes de novo para enviar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toque duas vezes para começar a gravar, toque duas vezes de novo para enviar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "двойной тап — начать запись, ещё один двойной тап — отправить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dubbeltryck för att börja spela in, dubbeltryck igen för att skicka" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பதிவைத் தொடங்க இரட்டைத் தட்டவும், அனுப்ப மீண்டும் இரட்டைத் தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะสองครั้งเพื่อเริ่มบันทึก แตะสองครั้งอีกครั้งเพื่อส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kaydı başlatmak için çift dokunun, göndermek için tekrar çift dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "двічі торкнися, щоб почати запис, торкнися ще раз, щоб надіслати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ریکارڈنگ شروع کرنے کیلئے ڈبل ٹیپ کریں، بھیجنے کیلئے دوبارہ ڈبل ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm hai lần để bắt đầu ghi, chạm hai lần nữa để gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "双击开始录音,再次双击发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "雙擊開始錄音,再次雙擊發送" + } + } + } + }, + "content.accessibility.record_voice_note" : { + "comment" : "Accessibility label for the voice note button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تسجيل ملاحظة صوتية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট রেকর্ড করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht aufnehmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "record voice note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grabar nota de voz" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mag-record ng voice note" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enregistrer une note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקלטת הערה קולית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट रिकॉर्ड करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rekam catatan suara" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "registra nota vocale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモを録音" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 녹음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rakam nota suara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट रेकर्ड गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht opnemen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nagraj notatkę głosową" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gravar nota de voz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gravar mensagem de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "записать голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spela in röstmeddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பைப் பதிவு செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "บันทึกข้อความเสียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not kaydet" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "записати голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ ریکارڈ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ghi chú giọng nói" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "录制语音消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "錄製語音訊息" + } + } + } + }, + "content.accessibility.recording" : { + "comment" : "Accessibility value announced while a voice note is recording", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جارٍ التسجيل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "রেকর্ড হচ্ছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aufnahme" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grabando" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nagre-record" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enregistrement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מקליט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रिकॉर्डिंग हो रही है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "merekam" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "registrazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "録音中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "녹음 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "merakam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रेकर्ड हुँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opnemen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nagrywanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a gravar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gravando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "запись" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spelar in" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பதிவு செய்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังบันทึก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kaydediliyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "запис" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ریکارڈنگ جاری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đang ghi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "录音中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "錄音中" + } + } + } + }, "content.accessibility.remove_favorite" : { "extractionState" : "manual", "localizations" : { @@ -11306,6 +21731,365 @@ } } }, + "content.accessibility.someone_speaking" : { + "comment" : "Accessibility value on the mic button naming who is talking live in the public channel", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ يتحدث الآن" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ কথা বলছেন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ spricht gerade" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is speaking" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ está hablando" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nagsasalita si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ est en train de parler" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ מדבר כעת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ बोल रहे हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sedang berbicara" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sta parlando" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@が話しています" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 님이 말하는 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sedang bercakap" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ बोल्दै हुनुहुन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is aan het spreken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ mówi" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ está a falar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ está falando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ говорит" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ pratar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ பேசுகிறார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ กำลังพูด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ konuşuyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ говорить" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ بول رہے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ đang nói" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 正在讲话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 正在說話" + } + } + } + }, + "content.accessibility.take_photo" : { + "comment" : "Accessibility action name for taking a photo with the camera", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "التقاط صورة بالكاميرا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ক্যামেরা দিয়ে ছবি তুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto mit kamera aufnehmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "take photo with camera" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tomar foto con la cámara" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumuha ng larawan gamit ang camera" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "prendre une photo avec l'appareil" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "צילום תמונה במצלמה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कैमरे से फ़ोटो लें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ambil foto dengan kamera" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "scatta foto con la fotocamera" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "カメラで写真を撮る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "카메라로 사진 촬영" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ambil foto dengan kamera" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "क्यामेराले तस्बिर खिच" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto maken met camera" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zrób zdjęcie aparatem" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tirar foto com a câmara" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tirar foto com a câmera" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сделать фото камерой" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta foto med kameran" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "கேமராவால் புகைப்படம் எடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ถ่ายรูปด้วยกล้อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamerayla fotoğraf çek" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зробити фото камерою" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کیمرے سے تصویر لیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chụp ảnh bằng máy ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用相机拍照" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用相機拍照" + } + } + } + }, "content.accessibility.toggle_bookmark" : { "extractionState" : "manual", "localizations" : { @@ -11485,181 +22269,182 @@ } } }, - "content.accessibility.toggle_favorite_hint" : { + "content.accessibility.verification" : { + "comment" : "Accessibility label for the verification QR button", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", - "value" : "اضغط مرتين لتبديل حالة المفضلة" + "state" : "needs_review", + "value" : "التحقق من التشفير" } }, "bn" : { "stringUnit" : { - "state" : "translated", - "value" : "প্রিয় অবস্থা বদলাতে দুইবার ট্যাপ করুন" + "state" : "needs_review", + "value" : "এনক্রিপশন যাচাই করুন" } }, "de" : { "stringUnit" : { - "state" : "translated", - "value" : "doppelt tippen, um favoritenstatus zu wechseln" + "state" : "needs_review", + "value" : "verschlüsselung verifizieren" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "double tap to toggle favorite status" + "value" : "verify encryption" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "toca dos veces para alternar el estado de favorito" + "value" : "verificar cifrado" } }, "fil" : { "stringUnit" : { - "state" : "translated", - "value" : "i-double tap para i-toggle ang paborito" + "state" : "needs_review", + "value" : "beripikahin ang pag-encrypt" } }, "fr" : { "stringUnit" : { - "state" : "translated", - "value" : "tape deux fois pour basculer le statut favori" + "state" : "needs_review", + "value" : "vérifier le chiffrement" } }, "he" : { "stringUnit" : { - "state" : "translated", - "value" : "הקש פעמיים כדי להחליף מצב מועדפים" + "state" : "needs_review", + "value" : "אימות הצפנה" } }, "hi" : { "stringUnit" : { - "state" : "translated", - "value" : "पसंदीदा स्थिति बदलने के लिए डबल टैप करें" + "state" : "needs_review", + "value" : "एन्क्रिप्शन सत्यापित करें" } }, "id" : { "stringUnit" : { - "state" : "translated", - "value" : "ketuk dua kali untuk mengubah status favorit" + "state" : "needs_review", + "value" : "verifikasi enkripsi" } }, "it" : { "stringUnit" : { - "state" : "translated", - "value" : "tocca due volte per cambiare stato preferito" + "state" : "needs_review", + "value" : "verifica la cifratura" } }, "ja" : { "stringUnit" : { - "state" : "translated", - "value" : "ダブルタップでお気に入り状態を切り替え" + "state" : "needs_review", + "value" : "暗号化を確認" } }, "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "두 번 탭하여 즐겨찾기 상태 토글" + "state" : "needs_review", + "value" : "암호화 확인" } }, "ms" : { "stringUnit" : { - "state" : "translated", - "value" : "ketuk dua kali untuk mengubah status favorit" + "state" : "needs_review", + "value" : "sahkan enkripsi" } }, "ne" : { "stringUnit" : { - "state" : "translated", - "value" : "मनपर्ने स्थिति बदल्न दोहोरो ट्याप गर" + "state" : "needs_review", + "value" : "सङ्केत प्रमाणित गर" } }, "nl" : { "stringUnit" : { - "state" : "translated", - "value" : "dubbelklikken om favoriet te schakelen" + "state" : "needs_review", + "value" : "versleuteling verifiëren" } }, "pl" : { "stringUnit" : { - "state" : "translated", - "value" : "stuknij dwukrotnie, aby zmienić stan ulubionych" + "state" : "needs_review", + "value" : "zweryfikuj szyfrowanie" } }, "pt" : { "stringUnit" : { - "state" : "translated", - "value" : "toca duas vezes para alternar o estado de favorito" + "state" : "needs_review", + "value" : "verificar a encriptação" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", - "value" : "toque duas vezes para alternar status de favorito" + "state" : "needs_review", + "value" : "verificar criptografia" } }, "ru" : { "stringUnit" : { - "state" : "translated", - "value" : "дважды тапни, чтобы переключить статус избранного" + "state" : "needs_review", + "value" : "проверить шифрование" } }, "sv" : { "stringUnit" : { - "state" : "translated", - "value" : "dubbeltryck för att växla favoritstatus" + "state" : "needs_review", + "value" : "verifiera kryptering" } }, "ta" : { "stringUnit" : { - "state" : "translated", - "value" : "பிரியப்பட்ட நிலையை மாற்ற இருமுறை தட்டவும்" + "state" : "needs_review", + "value" : "குறியாக்கத்தைச் சரிபார்க்கவும்" } }, "th" : { "stringUnit" : { - "state" : "translated", - "value" : "แตะสองครั้งเพื่อสลับสถานะรายการโปรด" + "state" : "needs_review", + "value" : "ยืนยันการเข้ารหัส" } }, "tr" : { "stringUnit" : { - "state" : "translated", - "value" : "favori durumunu değiştirmek için çift dokunun" + "state" : "needs_review", + "value" : "şifrelemeyi doğrula" } }, "uk" : { "stringUnit" : { - "state" : "translated", - "value" : "торкни двічі, щоб змінити статус вибраного" + "state" : "needs_review", + "value" : "перевірити шифрування" } }, "ur" : { "stringUnit" : { - "state" : "translated", - "value" : "پسندیدہ حالت بدلنے کیلئے دو بار ٹیپ کریں" + "state" : "needs_review", + "value" : "انکرپشن کی توثیق کریں" } }, "vi" : { "stringUnit" : { - "state" : "translated", - "value" : "chạm hai lần để chuyển trạng thái yêu thích" + "state" : "needs_review", + "value" : "xác minh mã hóa" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "双击切换收藏状态" + "state" : "needs_review", + "value" : "验证加密" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "雙擊切換收藏狀態" + "state" : "needs_review", + "value" : "驗證加密" } } } @@ -12559,6 +23344,186 @@ } } }, + "content.actions.resend" : { + "comment" : "Context menu action that resends a failed private message", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إعادة الإرسال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আবার পাঠান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "erneut senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "resend" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "reenviar" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipadala muli" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "renvoyer" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שלח שוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फिर भेजें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kirim ulang" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reinvia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "再送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "다시 전송" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hantar semula" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पुनः पठाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opnieuw verzenden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyślij ponownie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reenviar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reenviar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправить снова" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skicka igen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மீண்டும் அனுப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งอีกครั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yeniden gönder" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надіслати знову" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دوبارہ بھیجیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi lại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "重新发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "重新發送" + } + } + } + }, "content.actions.slap" : { "extractionState" : "manual", "localizations" : { @@ -14170,6 +25135,366 @@ } } }, + "content.clear.confirm_action" : { + "comment" : "Destructive confirmation button that clears the current chat", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مسح الدردشة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "চ্যাট মুছুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chat leeren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear chat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpiar chat" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "effacer la discussion" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נקה צ'אט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चैट साफ़ करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus obrolan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "svuota chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "チャットをクリア" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "채팅 지우기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kosongkan sembang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "च्याट खाली गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chat wissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyczyść czat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar conversa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar chat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистить чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rensa chatt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உரையாடலை அழி" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ล้างแชท" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sohbeti temizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистити чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "چیٹ صاف کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa cuộc trò chuyện" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "清除聊天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "清除聊天" + } + } + } + }, + "content.clear.confirm_title" : { + "comment" : "Title of the confirmation dialog shown before clearing the current chat", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مسح هذه الدردشة؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই চ্যাট মুছবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diesen chat leeren?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear this chat?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿limpiar este chat?" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang chat na ito?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "effacer cette discussion ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לנקות את הצ'אט הזה?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यह चैट साफ़ करें?" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus obrolan ini?" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "svuotare questa chat?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このチャットをクリアしますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 채팅을 지울까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kosongkan sembang ini?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो च्याट खाली गर्ने?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deze chat wissen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyczyścić ten czat?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar esta conversa?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar este chat?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистить этот чат?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rensa den här chatten?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த உரையாடலை அழிக்கவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ล้างแชทนี้หรือไม่?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu sohbet temizlensin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистити цей чат?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "یہ چیٹ صاف کریں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa cuộc trò chuyện này?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "清除此聊天?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "清除此聊天?" + } + } + } + }, "content.commands.block" : { "extractionState" : "manual", "localizations" : { @@ -14528,6 +25853,186 @@ } } }, + "content.commands.drop" : { + "comment" : "Description of the /drop command in the command suggestions panel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت ملاحظة في هذا المكان لمدة 24 ساعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই জায়গায় 24 ঘণ্টার জন্য একটি নোট পিন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "eine notiz für 24 std. an diesem ort anheften" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin a note to this place for 24h" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija una nota en este lugar por 24 h" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-pin ng note sa lugar na ito nang 24 na oras" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épingle une note à cet endroit pendant 24 h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמד פתק למקום הזה ל-24 שעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस जगह पर 24 घंटे के लिए एक नोट पिन करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan catatan di tempat ini selama 24 jam" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "fissa una nota in questo luogo per 24 ore" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この場所に24時間メモをピン留め" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 장소에 24시간 동안 쪽지 고정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinkan nota di tempat ini selama 24 jam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस ठाउँमा 24 घण्टाका लागि नोट पिन गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin een notitie op deze plek voor 24 uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypnij notatkę do tego miejsca na 24 godz." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixar uma nota neste local por 24 h" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixar uma nota neste lugar por 24 h" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепить заметку в этом месте на 24 ч" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "fäst en anteckning på den här platsen i 24 tim" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த இடத்தில் 24 மணி நேரத்திற்கு ஒரு குறிப்பை பொருத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักโน้ตไว้ที่นี่เป็นเวลา 24 ชม." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu yere 24 saatliğine bir not sabitle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріпити нотатку в цьому місці на 24 год" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس جگہ پر 24 گھنٹے کے لیے ایک نوٹ پن کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim một ghi chú tại nơi này trong 24 giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在此地固定一条留言 24 小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在此地固定一則留言 24 小時" + } + } + } + }, "content.commands.favorite" : { "extractionState" : "manual", "localizations" : { @@ -14707,6 +26212,365 @@ } } }, + "content.commands.group" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إنشاء أو إدارة المجموعات الخاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত গ্রুপ তৈরি বা পরিচালনা করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "private gruppen erstellen oder verwalten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create or manage private groups" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "crear o gestionar grupos privados" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lumikha o mamahala ng mga pribadong grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "créer ou gérer des groupes privés" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "יצירה או ניהול של קבוצות פרטיות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी समूह बनाएँ या प्रबंधित करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buat atau kelola grup pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "crea o gestisci gruppi privati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートグループを作成または管理" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 그룹 생성 또는 관리" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cipta atau urus kumpulan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी समूह सिर्जना वा व्यवस्थापन गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privégroepen maken of beheren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "twórz lub zarządzaj prywatnymi grupami" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "criar ou gerir grupos privados" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "criar ou gerenciar grupos privados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создать частные группы или управлять ими" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skapa eller hantera privata grupper" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட குழுக்களை உருவாக்கவும் அல்லது நிர்வகிக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สร้างหรือจัดการกลุ่มส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel gruplar oluştur veya yönet" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "створюй або керуй приватними групами" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی گروپ بنائیں یا ان کا نظم کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tạo hoặc quản lý nhóm riêng tư" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "创建或管理私密群组" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "建立或管理私密群組" + } + } + } + }, + "content.commands.help" : { + "comment" : "Description of the /help command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "عرض الأوامر المتاحة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "উপলব্ধ কমান্ড দেখান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verfügbare befehle anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show available commands" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mostrar los comandos disponibles" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipakita ang mga available na utos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afficher les commandes disponibles" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הצג פקודות זמינות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपलब्ध कमांड दिखाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tampilkan perintah yang tersedia" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra i comandi disponibili" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "利用可能なコマンドを表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용 가능한 명령어 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tunjukkan perintah yang tersedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपलब्ध आदेशहरू देखाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beschikbare commando's tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pokaż dostępne komendy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar os comandos disponíveis" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar comandos disponíveis" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показать доступные команды" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visa tillgängliga kommandon" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "கிடைக்கும் கட்டளைகளைக் காட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงคำสั่งที่ใช้ได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanılabilir komutları göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показати доступні команди" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دستیاب کمانڈز دکھائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiển thị các lệnh khả dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示可用指令" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示可用指令" + } + } + } + }, "content.commands.hug" : { "extractionState" : "manual", "localizations" : { @@ -15065,6 +26929,366 @@ } } }, + "content.commands.pay" : { + "comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إرسال رمز cashu ecash في هذه الدردشة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই চ্যাটে একটি ক্যাশু ইক্যাশ টোকেন পাঠান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "einen cashu-ecash-token in diesem chat senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send a cashu ecash token in this chat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar un token de ecash Cashu en este chat" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "magpadala ng cashu ecash token sa chat na ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoyer un token ecash cashu dans cette discussion" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שליחת אסימון cashu ecash בצ'אט הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस चैट में कैशु ईकैश टोकन भेजें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kirim token cashu ecash di obrolan ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invia un token ecash cashu in questa chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このチャットでcashuのecashトークンを送る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 채팅에서 cashu ecash 토큰 보내기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hantar token cashu ecash dalam sembang ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो च्याटमा cashu इ-क्यास टोकन पठाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "een cashu-ecash-token in deze chat sturen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyślij token ecash Cashu na tym czacie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviar um token ecash cashu nesta conversa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviar um token ecash cashu neste chat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправить токен cashu ecash в этот чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skicka en Cashu ecash-token i den här chatten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த உரையாடலில் Cashu ecash டோக்கனை அனுப்பவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งโทเคน ecash ของ cashu ในแชทนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu sohbette cashu ecash tokenı gönder" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надіслати токен cashu ecash у цьому чаті" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس چیٹ میں Cashu ecash ٹوکن بھیجیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi token cashu ecash trong cuộc trò chuyện này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在此聊天中发送 cashu ecash 代币" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在此聊天中發送 cashu ecash 代幣" + } + } + } + }, + "content.commands.ping" : { + "comment" : "Description of the /ping command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "قياس زمن الذهاب والإياب إلى قرين mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কোনো মেশ পিয়ারে রাউন্ড-ট্রিপ সময় মাপুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die roundtrip-zeit zu einem mesh-peer messen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "measure round-trip time to a mesh peer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "medir el tiempo de ida y vuelta a un peer del mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sukatin ang round-trip time papunta sa isang mesh peer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesurer le temps d'aller-retour vers un pair mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מדידת זמן הלוך ושוב לעמית mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "किसी मेश पीयर तक राउंड-ट्रिप समय मापें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukur waktu bolak-balik ke peer mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "misura il tempo di andata e ritorno verso un peer mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meshピアへの往復時間を測定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 피어까지의 왕복 시간 측정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukur masa pergi-balik ke peer mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh सहकर्मीसम्मको राउन्ड-ट्रिप समय नाप" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "de retourtijd naar een mesh-peer meten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zmierz czas do peera mesh i z powrotem" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "medir o tempo de ida e volta até um par mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "medir o tempo de ida e volta até um par do mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "измерить время отклика до mesh-пира" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mät tur-och-retur-tid till en mesh-peer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh peer க்கு செல்லவந்த நேரத்தை அளவிடவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วัดเวลาไป-กลับไปยังเพียร์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bir mesh eşine gidiş-dönüş süresini ölç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "виміряти час туди-назад до піра mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ہم منصب تک راؤنڈ ٹرپ وقت ناپیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đo thời gian khứ hồi đến một nút mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "测量到 mesh 同伴的往返时间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "測量到 mesh 同伴的往返時間" + } + } + } + }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { @@ -15244,6 +27468,186 @@ } } }, + "content.commands.trace" : { + "comment" : "Description of the /trace command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تقدير مسار mesh إلى قرين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কোনো পিয়ার পর্যন্ত মেশ পথ অনুমান করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "den mesh-pfad zu einem peer schätzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimate the mesh path to a peer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimar la ruta del mesh hasta un peer" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tantiyahin ang mesh path papunta sa isang peer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimer le chemin mesh vers un pair" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הערכת נתיב ה-mesh לעמית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "किसी पीयर तक मेश पथ का अनुमान लगाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "perkirakan jalur mesh ke peer" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stima il percorso mesh verso un peer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ピアへのmeshの経路を推定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "피어까지의 mesh 경로 추정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anggarkan laluan mesh ke peer" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सहकर्मीसम्मको mesh मार्ग अनुमान गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "het mesh-pad naar een peer schatten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "oszacuj ścieżkę mesh do peera" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimar o caminho mesh até um par" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimar o caminho no mesh até um par" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оценить mesh-путь до пира" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uppskatta mesh-vägen till en peer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peer க்கான mesh பாதையை மதிப்பிடவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ประมาณเส้นทาง mesh ไปยังเพียร์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bir eşe giden mesh yolunu tahmin et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оцінити шлях mesh до піра" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کسی ہم منصب تک mesh راستے کا تخمینہ لگائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ước tính đường đi mesh đến một nút" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "估计到某个同伴的 mesh 路径" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "估算到同伴的 mesh 路徑" + } + } + } + }, "content.commands.unblock" : { "extractionState" : "manual", "localizations" : { @@ -15274,7 +27678,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "desbloquear a un usuario" + "value" : "desbloquear a una persona" } }, "fil" : { @@ -15781,6 +28185,364 @@ } } }, + "content.composer.nearby_only_off" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "موصول بالجسر — تصل إلى أشخاص خارج مدى الراديو في هذه المنطقة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্রিজযুক্ত — এই এলাকায় রেডিও পরিসরের বাইরের মানুষের কাছেও পৌঁছায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überbrückt — erreicht personen außerhalb der funkreichweite in dieser gegend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bridged — reaches people beyond radio range in this area" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con puente — llega a personas fuera del alcance de radio en esta zona" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naka-bridge — umaabot sa mga taong lampas sa saklaw ng radyo sa lugar na ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pont actif — atteint les gens hors de portée radio dans cette zone" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מגושר — מגיעה לאנשים מעבר לטווח הרדיו באזור הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्रिज सक्रिय — इस क्षेत्र में रेडियो दायरे से बाहर के लोगों तक पहुँचता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terjembatani — sampai ke orang di luar jangkauan radio di area ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con ponte — raggiunge persone oltre la portata radio in questa zona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブリッジ中 — このエリアの電波範囲外の人にも届きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리지 사용 — 이 지역의 전파 범위 밖 사람들에게도 전달됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berjambatan — sampai kepada orang di luar jangkauan radio di kawasan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुल जोडिएको — यस क्षेत्रमा रेडियो पहुँचभन्दा बाहिरका मानिससम्म पुग्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overbrugd — bereikt mensen buiten radiobereik in deze omgeving" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmostkowane — dociera do osób poza zasięgiem radiowym w tej okolicy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com ponte — chega a pessoas fora do alcance de rádio nesta zona" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com ponte — chega a pessoas fora do alcance de rádio nesta área" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через мост — доходит до людей за пределами радиуса радиосвязи в этом районе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bryggad — når personer utom radioräckvidd i det här området" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பாலம் இணைந்தது — இந்தப் பகுதியில் ரேடியோ வரம்புக்கு அப்பாலுள்ளவர்களைச் சென்றடையும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บริดจ์อยู่ — ไปถึงคนที่อยู่นอกระยะวิทยุในพื้นที่นี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Köprülü — bu bölgedeki telsiz menzili dışındaki kişilere ulaşır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через міст — доходить до людей поза радіусом радіозв'язку в цьому районі" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پل فعال — اس علاقے میں ریڈیو رینج سے باہر لوگوں تک پہنچتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bắc cầu — đến được người ngoài phạm vi sóng trong khu vực này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已桥接 — 可送达本地区无线电范围之外的人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已橋接 — 可送達本地區無線電範圍之外的人" + } + } + } + }, + "content.composer.nearby_only_on" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قريب فقط — لن تعبر هذه الرسالة الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "শুধু কাছাকাছি — এই বার্তা ব্রিজ পার হবে না" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur in der nähe — diese nachricht überquert die brücke nicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nearby only — this message won't cross the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo cerca — este mensaje no cruzará el puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malapit lamang — hindi tatawid sa bridge ang mensaheng ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proximité uniquement — ce message ne franchira pas le pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קרוב בלבד — ההודעה הזו לא תחצה את הגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "केवल आसपास — यह संदेश ब्रिज पार नहीं करेगा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hanya terdekat — pesan ini tidak akan menyeberangi jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo vicinanze — questo messaggio non attraverserà il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのみ — このメッセージはブリッジを越えません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처 전용 — 이 메시지는 브리지를 건너지 않습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berdekatan sahaja — pesan ini tidak akan menyeberangi jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिक मात्र — यो सन्देश पुल तर्दैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alleen dichtbij — dit bericht gaat de brug niet over" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tylko w pobliżu — ta wiadomość nie przejdzie przez most" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto — esta mensagem não vai atravessar a ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto — esta mensagem não vai atravessar a ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только рядом — это сообщение не пересечёт мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endast i närheten — det här meddelandet korsar inte bryggan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகில் மட்டும் — இந்தச் செய்தி பாலத்தைத் தாண்டாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เฉพาะใกล้เคียง — ข้อความนี้จะไม่ข้ามบริดจ์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca yakın — bu mesaj köprüyü geçmeyecek" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лише поблизу — це повідомлення не перетне міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صرف قریبی — یہ پیغام پل پار نہیں کرے گا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ gần đây — tin nhắn này sẽ không qua cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅限附近 — 这条消息不会跨越桥接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "僅限附近 — 這則訊息不會跨越橋接" + } + } + } + }, "content.delivery.delivered_members" : { "extractionState" : "manual", "localizations" : { @@ -16503,175 +29265,535 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "المستخدم محظور" + "value" : "هذا الشخص محظور" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "ব্যবহারকারী ব্লক করা" + "value" : "এই ব্যক্তি ব্লক করা" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "nutzer blockiert" + "value" : "diese person ist blockiert" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "user is blocked" + "value" : "this person is blocked" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "el usuario está bloqueado" + "value" : "esta persona está bloqueada" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "na-block ang user" + "value" : "naka-block ang taong ito" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "utilisateur bloqué" + "value" : "cette personne est bloquée" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "המשתמש חסום" + "value" : "האדם הזה חסום" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "उपयोगकर्ता ब्लॉक है" + "value" : "यह व्यक्ति ब्लॉक है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "pengguna diblokir" + "value" : "orang ini diblokir" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "utente bloccato" + "value" : "questa persona è bloccata" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ユーザーをブロック中" + "value" : "この人をブロック中" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "차단된 사용자입니다" + "value" : "차단된 사람입니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "pengguna diblokir" + "value" : "orang ini diblokir" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "प्रयोगकर्ता ब्लक गरिएको" + "value" : "यो व्यक्ति ब्लक गरिएको छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "gebruiker is geblokkeerd" + "value" : "deze persoon is geblokkeerd" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "użytkownik zablokowany" + "value" : "ta osoba jest zablokowana" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "utilizador bloqueado" + "value" : "esta pessoa está bloqueada" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "usuário bloqueado" + "value" : "esta pessoa está bloqueada" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "пользователь заблокирован" + "value" : "этот человек заблокирован" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "användaren är blockerad" + "value" : "den här personen är blockerad" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "பயனர் தடுக்கப்பட்டுள்ளார்" + "value" : "இந்த நபர் தடுக்கப்பட்டுள்ளார்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ผู้ใช้ถูกบล็อก" + "value" : "คนนี้ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "kullanıcı engellendi" + "value" : "bu kişi engellendi" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "користувач заблокований" + "value" : "цю людину заблоковано" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "صارف بلاک ہے" + "value" : "یہ شخص بلاک ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "người dùng bị chặn" + "value" : "người này bị chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "用户已被屏蔽" + "value" : "此人已被屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用者已被屏蔽" + "value" : "此人已被屏蔽" + } + } + } + }, + "content.delivery.reason.encryption_failed" : { + "comment" : "Failure reason shown when a message could not be encrypted for the peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فشل التشفير" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপশন ব্যর্থ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verschlüsselung fehlgeschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cifrado fallido" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nabigo ang pag-encrypt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec du chiffrement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההצפנה נכשלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्शन विफल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cifratura fallita" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化に失敗" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화 실패" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सङ्केत असफल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "versleuteling mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "szyfrowanie nie powiodło się" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha na encriptação" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha na criptografia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрование не удалось" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kryptering misslyckades" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குறியாக்கம் தோல்வியடைந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้ารหัสไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "şifreleme başarısız" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрування не вдалося" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انکرپشن ناکام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mã hóa thất bại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "加密失败" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "加密失敗" + } + } + } + }, + "content.delivery.reason.not_delivered" : { + "comment" : "Failure reason shown when the router gave up delivering a message", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لم يُسلَّم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পৌঁছায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nicht zugestellt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "not delivered" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no entregado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi naihatid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non livré" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא נמסר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डिलीवर नहीं हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak terkirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non consegnato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未配信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송되지 않음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डेलिभर भएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "niet bezorgd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie dostarczono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não entregue" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não entregue" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не доставлено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inte levererat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வழங்கப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่ได้ส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teslim edilmedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не доставлено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نہیں پہنچا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa gửi được" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未送達" } } } @@ -17213,181 +30335,3242 @@ } } }, - "content.delivery.reason.unreachable" : { + "content.delivery.reason.voice_send_failed" : { + "comment" : "Failure reason shown when a voice note could not be sent", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", - "value" : "القرين غير متاح" + "state" : "needs_review", + "value" : "فشل إرسال الملاحظة الصوتية" } }, "bn" : { "stringUnit" : { - "state" : "translated", - "value" : "পিয়ার পৌঁছানো যাচ্ছে না" + "state" : "needs_review", + "value" : "ভয়েস নোট পাঠানো যায়নি" } }, "de" : { "stringUnit" : { - "state" : "translated", - "value" : "peer nicht erreichbar" + "state" : "needs_review", + "value" : "sprachnachricht konnte nicht gesendet werden" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "peer not reachable" + "value" : "voice note failed to send" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "el destinatario no es alcanzable" + "value" : "no se pudo enviar la nota de voz" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nabigong maipadala ang voice note" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec de l'envoi de la note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שליחת ההערה הקולית נכשלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट भेजने में विफल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara gagal dikirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio della nota vocale non riuscito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモの送信に失敗" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 전송 실패" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara gagal dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट पठाउन असफल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht kon niet worden verzonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się wysłać notatki głosowej" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha ao enviar a nota de voz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível enviar a mensagem de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось отправить голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet kunde inte skickas" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு அனுப்ப முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความเสียงไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not gönderilemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося надіслати голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بھیجنے میں ناکام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi ghi chú giọng nói thất bại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "语音消息发送失败" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "語音訊息發送失敗" + } + } + } + }, + "content.delivery.reason.voice_too_large" : { + "comment" : "Failure reason shown when a voice note exceeds the size limit", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الملاحظة الصوتية كبيرة جدًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট খুব বড়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht zu groß" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note too large" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nota de voz demasiado grande" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "masyadong malaki ang voice note" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "note vocale trop volumineuse" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההערה הקולית גדולה מדי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट बहुत बड़ा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara terlalu besar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota vocale troppo grande" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモが大きすぎます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모가 너무 큼" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara terlalu besar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट धेरै ठूलो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht te groot" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "notatka głosowa zbyt duża" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota de voz demasiado grande" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem de voz grande demais" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосовое сообщение слишком большое" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet är för stort" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு மிகப் பெரியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความเสียงใหญ่เกินไป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not çok büyük" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосова нотатка завелика" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بہت بڑا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ghi chú giọng nói quá lớn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "语音消息过大" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "語音訊息過大" + } + } + } + }, + "content.delivery.sending" : { + "comment" : "Delivery status description while a private message is being sent", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جارٍ الإرسال..." + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পাঠানো হচ্ছে..." + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wird gesendet ..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviando..." + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapadala..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoi..." + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שולח..." + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भेजा जा रहा है..." + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mengirim..." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio in corso..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "送信中..." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송 중..." + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menghantar..." + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पठाइँदै..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verzenden..." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wysyłanie..." + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a enviar..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviando..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправка..." + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skickar..." + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அனுப்புகிறது..." + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังส่ง..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gönderiliyor..." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надсилання..." + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بھیجا جا رہا ہے..." + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đang gửi..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "发送中..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "發送中..." + } + } + } + }, + "content.delivery.sent" : { + "comment" : "Delivery status description for a sent but not yet confirmed private message", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أُرسِل — لا يوجد تأكيد تسليم بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পাঠানো হয়েছে — এখনো ডেলিভারি নিশ্চিতকরণ নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gesendet — noch keine zustellbestätigung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent — no delivery confirmation yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviado — aún sin confirmación de entrega" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "naipadala — wala pang kumpirmasyon ng paghahatid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoyé — aucune confirmation de livraison pour l'instant" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נשלח — אין עדיין אישור מסירה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भेजा गया — अभी तक डिलीवरी की पुष्टि नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "terkirim — belum ada konfirmasi pengiriman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inviato — ancora nessuna conferma di consegna" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "送信済み — まだ配信確認がありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송됨 — 아직 전송 확인 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dihantar — belum ada pengesahan penghantaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पठाइयो — अझै डेलिभरी पुष्टि छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verzonden — nog geen bezorgbevestiging" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wysłano — brak potwierdzenia dostarczenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviado — ainda sem confirmação de entrega" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviada — ainda sem confirmação de entrega" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправлено — подтверждения доставки пока нет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skickat — ingen leveransbekräftelse än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அனுப்பப்பட்டது — இன்னும் வழங்கல் உறுதிப்படுத்தல் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งแล้ว — ยังไม่มีการยืนยันการส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gönderildi — henüz teslim onayı yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надіслано — ще немає підтвердження доставки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بھیج دیا گیا — ابھی ترسیل کی تصدیق نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã gửi — chưa có xác nhận nhận được" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已发送 — 尚无送达确认" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已發送 — 尚無送達確認" + } + } + } + }, + "content.echoes.divider" : { + "comment" : "System line shown above dimmed archived messages replayed on the mesh timeline at launch", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "سُمع هنا سابقًا · آخر 6 ساعات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আগে এখানে শোনা গেছে · গত ৬ ঘণ্টা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "vorhin hier gehört · letzte 6 std." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "heard here earlier · last 6h" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "escuchado aquí antes · últimas 6 h" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "hindi maabot ang peer" + "value" : "narinig dito kanina · huling 6 oras" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "pair injoignable" + "value" : "entendu ici plus tôt · 6 dernières h" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "עמית לא זמין" + "value" : "נשמע כאן קודם · 6 שעות אחרונות" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "पीयर पहुंच योग्य नहीं" + "value" : "पहले यहाँ सुना गया · पिछले 6 घंटे" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "peer tidak dapat dijangkau" + "value" : "terdengar di sini sebelumnya · 6 jam terakhir" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "peer irraggiungibile" + "value" : "sentito qui prima · ultime 6 ore" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ピアに到達できません" + "value" : "ここで聞こえた · 過去6時間" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "피어에 연결할 수 없습니다" + "value" : "여기서 들린 대화 · 지난 6시간" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "peer tidak dapat dijangkau" + "value" : "didengar di sini tadi · 6 jam lepas" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "पीयर पहुँचयोग्य छैन" + "value" : "पहिले यहाँ सुनिएको · पछिल्लो ६ घण्टा" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "peer onbereikbaar" + "value" : "eerder hier gehoord · afgelopen 6 u" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "peer nieosiągalny" + "value" : "słyszane tu wcześniej · ostatnie 6 godz." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "par inacessível" + "value" : "ouvido aqui antes · últimas 6 h" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "par inalcançável" + "value" : "ouvido aqui antes · últimas 6 h" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "пир недостижим" + "value" : "слышано здесь ранее · последние 6 ч" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "peer ej nåbar" + "value" : "hört här tidigare · senaste 6 tim" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "peer அணுக முடியவில்லை" + "value" : "முன்பு இங்கே கேட்டது · கடந்த 6 மணி" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ติดต่อเพียร์ไม่ได้" + "value" : "ได้ยินที่นี่ก่อนหน้านี้ · 6 ชม.ที่ผ่านมา" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "eşe ulaşılamıyor" + "value" : "daha önce burada duyuldu · son 6 sa" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "пір недосяжний" + "value" : "почуто тут раніше · останні 6 год" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "peer تک رسائی نہیں" + "value" : "پہلے یہاں سنا گیا · گزشتہ 6 گھنٹے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "không liên lạc được với nút" + "value" : "đã nghe ở đây trước đó · 6 giờ qua" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "同伴不可达" + "value" : "之前在这里听到 · 最近6小时" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "同伴不可達" + "value" : "之前在這裡聽到 · 最近6小時" + } + } + } + }, + "content.empty.activity_many" : { + "comment" : "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أشخاص يتحدثون في #%@ — انقر للانضمام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "লোকজন #%@-এ কথা বলছে — যোগ দিতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "leute reden in #%@ — tippen zum beitreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "people are talking in #%@ — tap to join" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hay gente hablando en #%@ — toca para unirte" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "may mga nag-uusap sa #%@ — i-tap para sumali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "des gens parlent dans #%@ — appuyez pour rejoindre" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אנשים מדברים ב-#%@ — הקש כדי להצטרף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ में लोग बात कर रहे हैं — शामिल होने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "orang-orang sedang mengobrol di #%@ — ketuk untuk bergabung" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "c'è gente che parla in #%@ — tocca per unirti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ で人々が話しています — タップして参加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@에서 사람들이 이야기 중 — 탭하여 참여" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ramai sedang berbual di #%@ — ketik untuk sertai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ मा मानिसहरू कुरा गर्दैछन् — सामेल हुन ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensen praten in #%@ — tik om mee te doen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ludzie rozmawiają w #%@ — stuknij, aby dołączyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "há pessoas a falar em #%@ — toque para entrar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "tem gente conversando em #%@ — toque para entrar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "люди общаются в #%@ — нажмите, чтобы присоединиться" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "folk pratar i #%@ — tryck för att gå med" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ இல் மக்கள் பேசுகிறார்கள் — சேர தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "หลายคนกำลังคุยใน #%@ — แตะเพื่อเข้าร่วม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ içinde insanlar konuşuyor — katılmak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "люди спілкуються в #%@ — торкніться, щоб приєднатися" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ میں لوگ بات کر رہے ہیں — شامل ہونے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mọi người đang trò chuyện trong #%@ — chạm để tham gia" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大家正在 #%@ 聊天 — 点按加入" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "大家正在 #%@ 聊天 — 點按加入" + } + } + } + }, + "content.empty.activity_one" : { + "comment" : "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أحدهم يتحدث في #%@ — انقر للانضمام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কেউ #%@-এ কথা বলছে — যোগ দিতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "jemand redet in #%@ — tippen zum beitreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "someone is talking in #%@ — tap to join" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alguien está hablando en #%@ — toca para unirte" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "may nagsasalita sa #%@ — i-tap para sumali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "quelqu'un parle dans #%@ — appuyez pour rejoindre" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מישהו מדבר ב-#%@ — הקש כדי להצטרף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ में कोई बात कर रहा है — शामिल होने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "seseorang sedang mengobrol di #%@ — ketuk untuk bergabung" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "qualcuno sta parlando in #%@ — tocca per unirti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ で誰かが話しています — タップして参加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@에서 누군가 이야기 중 — 탭하여 참여" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "seseorang sedang berbual di #%@ — ketik untuk sertai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ मा कोही कुरा गर्दैछ — सामेल हुन ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "iemand praat in #%@ — tik om mee te doen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ktoś rozmawia w #%@ — stuknij, aby dołączyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "alguém está a falar em #%@ — toque para entrar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "alguém está conversando em #%@ — toque para entrar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "кто-то общается в #%@ — нажмите, чтобы присоединиться" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "någon pratar i #%@ — tryck för att gå med" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ இல் யாரோ பேசுகிறார்கள் — சேர தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มีคนกำลังคุยใน #%@ — แตะเพื่อเข้าร่วม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ içinde biri konuşuyor — katılmak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "хтось спілкується в #%@ — торкніться, щоб приєднатися" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ میں کوئی بات کر رہا ہے — شامل ہونے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có người đang trò chuyện trong #%@ — chạm để tham gia" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "有人正在 #%@ 聊天 — 点按加入" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "有人正在 #%@ 聊天 — 點按加入" + } + } + } + }, + "content.empty.check_notes" : { + "comment" : "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تحقّق من الملاحظات المتروكة هنا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখানে রাখা নোট আছে কি না দেখুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nachsehen, ob hier notizen hinterlassen wurden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "check for notes left here" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "buscar notas dejadas aquí" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "tingnan kung may mga note na naiwan dito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "vérifier s'il y a des notes laissées ici" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בדיקה אם הושארו כאן פתקים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "देखें कि यहाँ नोट छोड़े गए हैं या नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "periksa catatan yang ditinggalkan di sini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "controlla se ci sono note lasciate qui" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここに残されたメモを確認" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기 남겨진 쪽지 확인" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "semak nota yang ditinggalkan di sini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kijk of hier notities zijn achtergelaten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "sprawdź, czy zostawiono tutaj notatki" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ver se há notas deixadas aqui" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ver se há notas deixadas aqui" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "проверить, есть ли здесь заметки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kolla om anteckningar lämnats här" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buraya bırakılan notlara bak" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "перевірити, чи залишено тут нотатки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "kiểm tra ghi chú để lại ở đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看这里留下的留言" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看這裡留下的留言" + } + } + } + }, + "content.empty.location_intro" : { + "comment" : "First line of an empty geohash timeline naming the channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أنت في #%@ — قناة موقع عامة عبر الإنترنت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি #%@-এ আছেন — ইন্টারনেটের ওপর একটি পাবলিক লোকেশন চ্যানেল" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist in #%@ — ein öffentlicher standortkanal über das internet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're in #%@ — a public location channel over the internet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estás en #%@ — un canal de ubicación público por internet" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nasa #%@ ka — isang pampublikong channel ng lokasyon sa internet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu es dans #%@ — un canal de localisation public sur internet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה ב-#%@ — ערוץ מיקום ציבורי דרך האינטרנט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप #%@ में हैं — इंटरनेट पर एक सार्वजनिक लोकेशन चैनल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu di #%@ — kanal lokasi publik lewat internet" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei in #%@ — un canale di posizione pubblico su internet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ にいます — インターネット経由の公開位置チャンネルです" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ 에 있습니다 — 인터넷을 통한 공개 위치 채널입니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda di #%@ — kanal lokasi awam melalui internet" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी #%@ मा छौ — इन्टरनेटमाथिको सार्वजनिक स्थान च्यानल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent in #%@ — een openbaar locatiekanaal via internet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jesteś w #%@ — publiczny kanał lokalizacji przez internet" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estás em #%@ — um canal de localização público pela internet" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você está em #%@ — um canal público de localização pela internet" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты в #%@ — публичном локальном канале через интернет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är i #%@ — en publik platskanal över internet" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் #%@ இல் இருக்கிறீர்கள் — இணையம் வழியாக ஒரு பொது இருப்பிட சேனல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณอยู่ใน #%@ — ช่องตามตำแหน่งสาธารณะผ่านอินเทอร์เน็ต" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ kanalındasın — internet üzerinden herkese açık bir konum kanalı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти в #%@ — публічний канал локації через інтернет" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ #%@ میں ہیں — انٹرنیٹ پر ایک عوامی لوکیشن چینل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đang ở #%@ — một kênh vị trí công khai qua internet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你在 #%@ — 一个通过互联网的公共位置频道" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你在 #%@ — 一個透過網際網路的公開位置頻道" + } + } + } + }, + "content.empty.mesh_intro" : { + "comment" : "First line of the empty mesh timeline explaining what the mesh channel is", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أنت في #mesh — تصل إلى الأشخاص ضمن نطاق bluetooth" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি #mesh-এ আছেন — ব্লুটুথ পরিসরের মধ্যে মানুষের কাছে পৌঁছায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist auf #mesh — erreicht menschen in bluetooth-reichweite" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're on #mesh — reaches people within bluetooth range" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estás en #mesh — llega a personas dentro del alcance de Bluetooth" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nasa #mesh ka — umaabot sa mga taong nasa saklaw ng bluetooth" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu es sur #mesh — atteint les personnes à portée bluetooth" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה ב-#mesh — מגיע לאנשים בטווח bluetooth" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप #mesh पर हैं — ब्लूटूथ रेंज के भीतर लोगों तक पहुँचता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu di #mesh — menjangkau orang dalam jangkauan bluetooth" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei su #mesh — raggiunge le persone a portata bluetooth" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh にいます — bluetooth圏内の人に届きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh 에 있습니다 — bluetooth 범위 내의 사람에게 도달합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda di #mesh — mencapai orang dalam jangkauan bluetooth" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी #mesh मा छौ — bluetooth दायराभित्रका मानिससम्म पुग्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent op #mesh — bereikt mensen binnen bluetoothbereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jesteś na #mesh — dociera do osób w zasięgu Bluetooth" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estás em #mesh — alcança pessoas dentro do alcance bluetooth" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você está no #mesh — alcança pessoas dentro do alcance do bluetooth" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты в #mesh — достаёт людей в радиусе bluetooth" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är på #mesh — når personer inom Bluetooth-räckvidd" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் #mesh இல் இருக்கிறீர்கள் — bluetooth வரம்பிற்குள் உள்ளவர்களை அடையும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณอยู่ใน #mesh — เข้าถึงคนในระยะ bluetooth" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh kanalındasın — Bluetooth menzilindeki kişilere ulaşır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти на #mesh — досягає людей у радіусі bluetooth" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ #mesh پر ہیں — Bluetooth رینج میں لوگوں تک پہنچتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đang ở #mesh — tiếp cận mọi người trong phạm vi Bluetooth" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你在 #mesh — 可触达 bluetooth 范围内的人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你在 #mesh — 可到達 bluetooth 範圍內的人" + } + } + } + }, + "content.empty.mesh_waiting" : { + "comment" : "Second line of the empty mesh timeline saying no peers are in range yet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا أحد في النطاق بعد... ستظهر الرسائل هنا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এখনো কেউ পরিসরে নেই... বার্তা এখানে দেখা যাবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "noch niemand in reichweite ... nachrichten erscheinen hier" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nobody in range yet... messages appear here" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nadie a tu alcance todavía... los mensajes aparecerán aquí" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wala pang tao sa saklaw... dito lalabas ang mga mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "personne à portée pour l'instant... les messages apparaîtront ici" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אף אחד לא בטווח עדיין... הודעות יופיעו כאן" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अभी तक कोई रेंज में नहीं... संदेश यहाँ दिखाई देंगे" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada siapa pun dalam jangkauan... pesan akan muncul di sini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ancora nessuno a portata... i messaggi appariranno qui" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "まだ圏内に誰もいません... メッセージはここに表示されます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "아직 범위 내에 아무도 없습니다... 메시지가 여기에 표시됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada sesiapa dalam jangkauan... pesan akan muncul di sini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अझै दायरामा कोही छैन... सन्देश यहाँ देखिन्छन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nog niemand in bereik... berichten verschijnen hier" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nikogo w zasięgu... wiadomości pojawią się tutaj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ainda ninguém ao alcance... as mensagens aparecem aqui" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ninguém no alcance ainda... as mensagens aparecem aqui" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "пока никого рядом... сообщения появятся здесь" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingen inom räckvidd än... meddelanden visas här" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இன்னும் வரம்பில் யாரும் இல்லை... செய்திகள் இங்கே தோன்றும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่มีใครอยู่ในระยะ... ข้อความจะปรากฏที่นี่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menzilde henüz kimse yok... mesajlar burada görünür" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поки нікого в радіусі... повідомлення з'являться тут" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ابھی رینج میں کوئی نہیں... پیغامات یہاں ظاہر ہوں گے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa có ai trong phạm vi... tin nhắn sẽ xuất hiện ở đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "范围内还没有人... 消息会显示在这里" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "範圍內還沒有人... 訊息會顯示在這裡" + } + } + } + }, + "content.empty.notes_many" : { + "comment" : "Empty mesh timeline hint counting notes left at this place", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تُركت %lld ملاحظات هنا — انقر للقراءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখানে %lldটি নোট রাখা আছে — পড়তে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notizen hier hinterlassen — tippen zum lesen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notes left here — tap to read" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notas dejadas aquí — toca para leer" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld note ang naiwan dito — i-tap para basahin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notes laissées ici — appuyez pour lire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld פתקים הושארו כאן — הקש לקריאה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ %lld नोट छोड़े गए हैं — पढ़ने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld catatan ditinggalkan di sini — ketuk untuk membaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld note lasciate qui — tocca per leggere" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここに%lld件のメモがあります — タップして読む" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기 남겨진 쪽지 %lld개 — 탭하여 읽기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nota ditinggalkan di sini — ketik untuk baca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ %lld नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notities hier achtergelaten — tik om te lezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notatek zostawionych tutaj — stuknij, aby przeczytać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notas deixadas aqui — toque para ler" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notas deixadas aqui — toque para ler" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "здесь оставлено заметок: %lld — нажмите, чтобы прочитать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld anteckningar lämnade här — tryck för att läsa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இங்கே %lld குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มี %lld โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buraya %lld not bırakıldı — okumak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "тут залишено %lld нотаток — торкніться, щоб прочитати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہاں %lld نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có %lld ghi chú để lại ở đây — chạm để đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这里留有 %lld 条留言 — 点按阅读" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這裡留有 %lld 則留言 — 點按閱讀" + } + } + } + }, + "content.empty.notes_one" : { + "comment" : "Empty mesh timeline hint when exactly one note was left at this place", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تُركت ملاحظة واحدة هنا — انقر للقراءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 notiz hier hinterlassen — tippen zum lesen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 note left here — tap to read" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota dejada aquí — toca para leer" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 note ang naiwan dito — i-tap para basahin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 note laissée ici — appuyez pour lire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פתק אחד הושאר כאן — הקש לקריאה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 catatan ditinggalkan di sini — ketuk untuk membaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota lasciata qui — tocca per leggere" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここに1件のメモがあります — タップして読む" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기 남겨진 쪽지 1개 — 탭하여 읽기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota ditinggalkan di sini — ketik untuk baca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 notitie hier achtergelaten — tik om te lezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 notatka zostawiona tutaj — stuknij, aby przeczytać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota deixada aqui — toque para ler" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota deixada aqui — toque para ler" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "здесь оставлена 1 заметка — нажмите, чтобы прочитать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 anteckning lämnad här — tryck för att läsa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buraya 1 not bırakıldı — okumak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "тут залишено 1 нотатку — торкніться, щоб прочитати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có 1 ghi chú để lại ở đây — chạm để đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这里留有 1 条留言 — 点按阅读" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這裡留有 1 則留言 — 點按閱讀" + } + } + } + }, + "content.empty.sightings_many" : { + "comment" : "Empty mesh timeline stat counting devices that came within range today", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرّ %lld جهاز ضمن النطاق اليوم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আজ %lldটি ডিভাইস রেঞ্জের মধ্যে দিয়ে গেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld geräte waren heute in reichweite" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld devices passed within range today" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivos pasaron dentro del alcance hoy" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld device ang dumaan sa saklaw ngayong araw" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld appareils sont passés à portée aujourd'hui" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld מכשירים עברו בטווח היום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज %lld डिवाइस रेंज में से गुज़रे" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld perangkat lewat dalam jangkauan hari ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivi sono passati nel raggio oggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日%lld台のデバイスが範囲内を通過" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 기기 %lld대가 범위 안을 지나갔어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld peranti lalu dalam jangkauan hari ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज %lld यन्त्रहरू दायराभित्र आए" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld apparaten kwamen vandaag binnen bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld urządzeń było dziś w zasięgu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivos passaram dentro do alcance hoje" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivos passaram dentro do alcance hoje" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сегодня %lld устройств прошло в зоне досягаемости" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld enheter passerade inom räckvidd idag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்று %lld சாதனங்கள் வரம்பிற்குள் கடந்தன" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วันนี้มี %lld อุปกรณ์ผ่านเข้ามาในระยะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bugün %lld cihaz menzilden geçti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "сьогодні %lld пристроїв пройшло в зоні досяжності" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آج %lld ڈیوائسز رینج میں سے گزریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hôm nay có %lld thiết bị đi qua trong tầm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 %lld 台设备经过范围内" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 %lld 台裝置經過範圍內" + } + } + } + }, + "content.empty.sightings_one" : { + "comment" : "Empty mesh timeline stat when exactly one device came within range today", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرّ جهاز واحد ضمن النطاق اليوم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আজ 1টি ডিভাইস রেঞ্জের মধ্যে দিয়ে গেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 gerät war heute in reichweite" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 device passed within range today" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo pasó dentro del alcance hoy" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 device ang dumaan sa saklaw ngayong araw" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 appareil est passé à portée aujourd'hui" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מכשיר אחד עבר בטווח היום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज 1 डिवाइस रेंज में से गुज़रा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 perangkat lewat dalam jangkauan hari ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo è passato nel raggio oggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日1台のデバイスが範囲内を通過" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 기기 1대가 범위 안을 지나갔어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 peranti lalu dalam jangkauan hari ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज 1 यन्त्र दायराभित्र आयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 apparaat kwam vandaag binnen bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 urządzenie było dziś w zasięgu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo passou dentro do alcance hoje" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo passou dentro do alcance hoje" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сегодня 1 устройство прошло в зоне досягаемости" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 enhet passerade inom räckvidd idag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்று 1 சாதனம் வரம்பிற்குள் கடந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วันนี้มี 1 อุปกรณ์ผ่านเข้ามาในระยะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bugün 1 cihaz menzilden geçti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "сьогодні 1 пристрій пройшов у зоні досяжності" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آج 1 ڈیوائس رینج میں سے گزری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hôm nay có 1 thiết bị đi qua trong tầm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 1 台设备经过范围内" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 1 台裝置經過範圍內" + } + } + } + }, + "content.empty.switch_hint" : { + "comment" : "Empty timeline hint pointing at the channel switcher and the help screen", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط اسم القناة بالأعلى للتبديل · اضغط bitchat/ للمساعدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "বদলাতে উপরের চ্যানেলের নাম ট্যাপ করুন · সাহায্যের জন্য bitchat/ ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tippe oben auf den kanalnamen zum wechseln · tippe bitchat/ für hilfe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap the channel name above to switch · tap bitchat/ for help" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca el nombre del canal de arriba para cambiar · toca bitchat/ para ayuda" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-tap ang pangalan ng channel sa itaas para magpalit · i-tap ang bitchat/ para sa tulong" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "touche le nom du canal ci-dessus pour changer · touche bitchat/ pour l'aide" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש על שם הערוץ למעלה כדי להחליף · הקש על bitchat/ לעזרה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "स्विच करने के लिए ऊपर चैनल का नाम टैप करें · मदद के लिए bitchat/ टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk nama kanal di atas untuk beralih · ketuk bitchat/ untuk bantuan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca il nome del canale in alto per cambiare · tocca bitchat/ per l'aiuto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "上のチャンネル名をタップして切り替え · bitchat/ をタップしてヘルプ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "위의 채널 이름을 탭하여 전환 · bitchat/ 를 탭하여 도움말" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk nama kanal di atas untuk beralih · ketuk bitchat/ untuk bantuan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "बदल्न माथिको च्यानल नाम ट्याप गर · मद्दतका लागि bitchat/ ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tik op de kanaalnaam hierboven om te wisselen · tik op bitchat/ voor hulp" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij nazwę kanału powyżej, aby przełączyć · stuknij bitchat/ po pomoc" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca no nome do canal acima para trocar · toca em bitchat/ para ajuda" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toque o nome do canal acima para trocar · toque bitchat/ para ajuda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нажми на имя канала выше, чтобы переключиться · нажми bitchat/ для справки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tryck på kanalnamnet ovan för att byta · tryck på bitchat/ för hjälp" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மாற்ற மேலே உள்ள சேனல் பெயரைத் தட்டவும் · உதவிக்கு bitchat/ ஐத் தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะชื่อช่องด้านบนเพื่อสลับ · แตะ bitchat/ เพื่อดูวิธีใช้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "değiştirmek için yukarıdaki kanal adına dokunun · yardım için bitchat/ dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "торкнися назви каналу вгорі, щоб перемкнути · торкнися bitchat/ для довідки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تبدیل کرنے کیلئے اوپر چینل کے نام پر ٹیپ کریں · مدد کیلئے bitchat/ پر ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm tên kênh phía trên để chuyển · chạm bitchat/ để được trợ giúp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "轻点上方频道名切换 · 轻点 bitchat/ 获取帮助" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "輕點上方頻道名稱切換 · 輕點 bitchat/ 取得協助" + } + } + } + }, + "content.header.gateway_active" : { + "comment" : "Tooltip for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مشاركة اتصال الإنترنت مع أقران mesh القريبين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কাছাকাছি মেশ পিয়ারদের সঙ্গে আপনার ইন্টারনেট সংযোগ ভাগ করছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teilt deine internetverbindung mit nahen mesh-peers" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing your internet connection with nearby mesh peers" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartiendo tu conexión a internet con peers cercanos del mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ibinabahagi ang iyong koneksyon sa internet sa mga kalapit na mesh peer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "partage ta connexion internet avec les pairs mesh à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "משתף את חיבור האינטרנט שלך עם עמיתי mesh קרובים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपका इंटरनेट कनेक्शन आसपास के मेश पीयरों के साथ साझा किया जा रहा है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membagikan koneksi internetmu dengan peer mesh terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "condivide la tua connessione internet con i peer mesh vicini" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "近くのmeshピアとインターネット接続を共有中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "근처 mesh 피어와 인터넷 연결을 공유 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "berkongsi sambungan internetmu dengan peer mesh berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिम्रो इन्टरनेट जडान नजिकका mesh सहकर्मीसँग साझेदारी गरिँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deelt je internetverbinding met mesh-peers in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Udostępniasz swoje połączenie internetowe pobliskim peerom mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a partilhar a tua ligação à internet com pares mesh próximos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Compartilhando sua conexão de internet com pares do mesh por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "раздаёт твоё интернет-соединение ближайшим mesh-пирам" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Delar din internetanslutning med mesh-peers i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உங்கள் இணைய இணைப்பை அருகிலுள்ள mesh peer-களுடன் பகிர்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังแชร์การเชื่อมต่ออินเทอร์เน็ตของคุณกับเพียร์ mesh ที่อยู่ใกล้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "İnternet bağlantınızı yakındaki mesh eşleriyle paylaşıyorsunuz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ділишся своїм інтернет-з'єднанням з ближніми пірами mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "قریبی mesh ہم منصبوں کے ساتھ اپنا انٹرنیٹ کنکشن شیئر کر رہا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chia sẻ kết nối internet của bạn với các nút mesh gần đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "正在与附近 mesh 同伴共享你的互联网连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "正在與附近的 mesh 同伴分享你的網際網路連線" + } + } + } + }, + "content.header.notices" : { + "comment" : "Tooltip for the notices button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات: منشورات مثبتة لهذه المنطقة وشبكة mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ: এই এলাকা ও মেশের জন্য পিন করা পোস্ট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise: angeheftete Beiträge für diese Gegend und das Mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices: pinned posts for this area and the mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicaciones fijadas para esta zona y el mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mga paunawa: naka-pin na post para sa lugar na ito at sa mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annonces : publications épinglées pour cette zone et le mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות: פוסטים נעוצים לאזור הזה ול-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ: इस क्षेत्र और मेश के लिए पिन की गई पोस्ट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman: kiriman yang disematkan untuk area ini dan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi: post appuntati per questa zona e il mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ: このエリアとmeshのピン留め投稿" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지: 이 지역과 mesh에 고정된 게시물" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman: kiriman disemat untuk kawasan ini dan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू: यो क्षेत्र र मेशका लागि पिन गरिएका पोस्ट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen: vastgeprikte berichten voor deze omgeving en het mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogłoszenia: przypięte wpisy dla tej okolicy i mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicações afixadas para esta zona e o mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicações fixadas para esta área e o mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Объявления: закреплённые записи для этого места и mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslag: uppnålade inlägg för det här området och mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்: இந்தப் பகுதி மற்றும் மெஷுக்கான பின் செய்யப்பட்ட பதிவுகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ: โพสต์ที่ปักหมุดสำหรับบริเวณนี้และ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyurular: bu bölge ve mesh için sabitlenmiş gönderiler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оголошення: закріплені дописи для цієї місцевості та mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات: اس علاقے اور mesh کیلئے پن شدہ پوسٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo: bài ghim cho khu vực này và mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告:此区域和 mesh 的置顶帖子" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告:此區域和 mesh 的置頂貼文" } } } @@ -17750,181 +33933,181 @@ } } }, - "content.input.message_placeholder" : { + "content.input.group_placeholder" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", - "value" : "اكتب رسالة..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "bn" : { "stringUnit" : { - "state" : "translated", - "value" : "একটি বার্তা লিখুন..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "de" : { "stringUnit" : { - "state" : "translated", - "value" : "nachricht eingeben..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "type a message..." + "value" : "create|invite|leave|list" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "escribe un mensaje..." + "value" : "create|invite|leave|list" } }, "fil" : { "stringUnit" : { - "state" : "translated", - "value" : "mag-type ng mensahe..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "fr" : { "stringUnit" : { - "state" : "translated", - "value" : "écris un message..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "he" : { "stringUnit" : { - "state" : "translated", - "value" : "כתוב הודעה..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "hi" : { "stringUnit" : { - "state" : "translated", - "value" : "संदेश लिखें..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "id" : { "stringUnit" : { - "state" : "translated", - "value" : "ketik pesan..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "it" : { "stringUnit" : { - "state" : "translated", - "value" : "scrivi un messaggio..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "ja" : { "stringUnit" : { - "state" : "translated", - "value" : "メッセージを入力..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "메시지를 입력하세요..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "ms" : { "stringUnit" : { - "state" : "translated", - "value" : "ketik pesan..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "ne" : { "stringUnit" : { - "state" : "translated", - "value" : "सन्देश टाइप गर..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "nl" : { "stringUnit" : { - "state" : "translated", - "value" : "typ een bericht..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "pl" : { "stringUnit" : { - "state" : "translated", - "value" : "wpisz wiadomość..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "pt" : { "stringUnit" : { - "state" : "translated", - "value" : "escreve uma mensagem..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", - "value" : "digite uma mensagem..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "ru" : { "stringUnit" : { - "state" : "translated", - "value" : "напиши сообщение..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "sv" : { "stringUnit" : { - "state" : "translated", - "value" : "skriv ett meddelande..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "ta" : { "stringUnit" : { - "state" : "translated", - "value" : "ஒரு செய்தியைத் தட்டச்சு செய்க..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "th" : { "stringUnit" : { - "state" : "translated", - "value" : "พิมพ์ข้อความ..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "tr" : { "stringUnit" : { - "state" : "translated", - "value" : "bir mesaj yazın..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "uk" : { "stringUnit" : { - "state" : "translated", - "value" : "напиши повідомлення..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "ur" : { "stringUnit" : { - "state" : "translated", - "value" : "پیغام ٹائپ کریں..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "vi" : { "stringUnit" : { - "state" : "translated", - "value" : "nhập tin nhắn..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "输入消息..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "輸入訊息..." + "state" : "needs_review", + "value" : "create|invite|leave|list" } } } @@ -18108,6 +34291,1086 @@ } } }, + "content.input.note_placeholder" : { + "comment" : "Placeholder argument name for the /drop command suggestion", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "رسالة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বার্তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nachricht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "message" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הודעה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "संदेश" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pesan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "messaggio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सन्देश" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bericht" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wiadomość" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "meddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "செய்தி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อความ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesaj" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پیغام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tin nhắn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "訊息" + } + } + } + }, + "content.input.placeholder.location" : { + "comment" : "Composer placeholder for a public geohash channel, naming it", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #%@ — عام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@-এ বার্তা — পাবলিক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #%@ — öffentlich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message #%@ — public" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #%@ — público" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensahe sa #%@ — pampubliko" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #%@ — public" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#%@ — ציבורי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #%@ — सार्वजनिक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — publik" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #%@ — pubblico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ にメッセージ — 公開" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ 에 메시지 — 공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — awam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ मा सन्देश — सार्वजनिक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #%@ — openbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #%@ — publiczna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #%@ — público" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #%@ — público" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #%@ — публичное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #%@ — publikt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ க்கு செய்தி — பொது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #%@ — สาธารณะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #%@ — herkese açık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #%@ — публічне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #%@ — عوامی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #%@ — công khai" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "发消息到 #%@ — 公开" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "發訊息到 #%@ — 公開" + } + } + } + }, + "content.input.placeholder.mesh" : { + "comment" : "Composer placeholder for the public mesh channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #mesh — عام، قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #mesh — öffentlich, in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message #mesh — public, nearby" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #mesh — público, cerca" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensahe sa #mesh — pampubliko, malapit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #mesh — public, à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#mesh — ציבורי, קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #mesh — सार्वजनिक, आसपास" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — publik, terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #mesh — pubblico, nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh にメッセージ — 公開、近くの人へ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh 에 메시지 — 공개, 근처" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — awam, berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #mesh — openbaar, in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #mesh — publiczna, w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #mesh — público, próximo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #mesh — público, por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #mesh — публичное, рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #mesh — publikt, i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh க்கு செய்தி — பொது, அருகில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #mesh — herkese açık, yakında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #mesh — публічне, поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #mesh — عوامی، قریبی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #mesh — công khai, gần đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "发消息到 #mesh — 公开、附近" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "發訊息到 #mesh — 公開、附近" + } + } + } + }, + "content.input.placeholder.private" : { + "comment" : "Composer placeholder inside a private chat, naming the conversation partner", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة %@ — خاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে বার্তা — ব্যক্তিগত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an %@ — privat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message %@ — private" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje %@ — privado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensahe kay %@ — pribado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message à %@ — privé" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-%@ — פרטי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश %@ — निजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a %@ — privato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ にメッセージ — プライベート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 에게 메시지 — 비공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई सन्देश — निजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar %@ — privé" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość do %@ — prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para %@ — privado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para %@ — privado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение для %@ — личное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande %@ — privat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ க்கு செய்தி — தனிப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง %@ — ส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj %@ — özel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення %@ — приватне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام %@ — نجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn %@ — riêng tư" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "发消息给 %@ — 私密" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "發訊息給 %@ — 私密" + } + } + } + }, + "content.input.token_placeholder" : { + "comment" : "Placeholder shown after /pay in the command suggestion panel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رمز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টোকেন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טוקן" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "トークン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "토큰" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "токен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டோக்கன்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "โทเคน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "токен" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹوکن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "代币" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + } + } + }, + "content.jump.new_count" : { + "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld নতুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld neu" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuevos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld bago" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nouveaux" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld חדשות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld नए" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nuovi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld 件の新着" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새 메시지 %lld개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld baru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld नयाँ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nieuw" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nowych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld novas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld novas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld новых" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nya" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld புதியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ใหม่ %lld รายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld yeni" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld нових" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld نئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld 条新消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld 則新訊息" + } + } + } + }, "content.location.enable" : { "extractionState" : "manual", "localizations" : { @@ -19003,185 +36266,6 @@ } } }, - "content.notes.title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ملاحظات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোট" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "notizen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "notes" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "notas" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mga tala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "notes" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הערות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "नोट्स" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "catatan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "note" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ノート" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "노트" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "catatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नोट" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "notities" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "notatki" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "notas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "notas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "заметки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "anteckningar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "குறிப்புகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บันทึก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "notlar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "замітки" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "نوٹس" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ghi chú" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "笔记" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "筆記" - } - } - } - }, "content.payment.cashu" : { "extractionState" : "manual", "localizations" : { @@ -19361,6 +36445,186 @@ } } }, + "content.payment.copy_token" : { + "comment" : "Context menu action copying a Cashu token to the pasteboard", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نسخ الرمز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টোকেন কপি করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token kopieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "copy token" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "copiar token" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kopyahin ang token" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copier le token" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "העתק טוקן" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन कॉपी करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "salin token" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copia token" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "トークンをコピー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "토큰 복사" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "salin token" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन कपी गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token kopiëren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kopiuj token" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copiar token" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copiar token" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скопировать токен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kopiera token" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டோக்கனை நகலெடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คัดลอกโทเคน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tokenı kopyala" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скопіювати токен" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹوکن کاپی کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sao chép token" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "复制代币" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "複製代幣" + } + } + } + }, "content.payment.lightning" : { "extractionState" : "manual", "localizations" : { @@ -19540,6 +36804,905 @@ } } }, + "content.payment.redeem_wallet" : { + "comment" : "Context menu action opening a Cashu token in an ecash wallet app", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاسترداد في المحفظة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ওয়ালেটে রিডিম করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in wallet einlösen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem in wallet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "canjear en la cartera" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-redeem sa wallet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utiliser dans le wallet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מימוש בארנק" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉलेट में रिडीम करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tukarkan di dompet" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riscatta nel wallet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ウォレットで受け取る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "지갑에서 받기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tebus dalam dompet" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वालेटमा भजाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inwisselen in wallet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zrealizuj w portfelu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na wallet" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na carteira" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "обменять в кошельке" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lös in i plånbok" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வாலெட்டில் மீட்டெடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แลกในกระเป๋าเงิน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cüzdanda kullan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "погасити в гаманці" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "والیٹ میں ریڈیم کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đổi trong ví" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在钱包中兑换" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在錢包中兌換" + } + } + } + }, + "content.payment.redeem_web" : { + "comment" : "Context menu action opening a Cashu token in the web redemption page", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاسترداد على الويب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ওয়েবে রিডিম করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "im web einlösen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem on web" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "canjear en la web" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-redeem sa web" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utiliser sur le web" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מימוש באינטרנט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वेब पर रिडीम करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tukarkan di web" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riscatta sul web" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ウェブで受け取る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "웹에서 받기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tebus di web" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वेबमा भजाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inwisselen op web" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zrealizuj w sieci" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na web" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na web" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "обменять в вебе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lös in på webben" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வெப்பில் மீட்டெடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แลกบนเว็บ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "webde kullan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "погасити у вебі" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ویب پر ریڈیم کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đổi trên web" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在网页上兑换" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在網頁上兌換" + } + } + } + }, + "content.private.caption" : { + "comment" : "Caption above the private chat composer before encryption is established", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محادثة خاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত কথোপকথন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privates gespräch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "private conversation" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversación privada" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pribadong pag-uusap" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversation privée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שיחה פרטית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी बातचीत" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "percakapan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversazione privata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートな会話" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 대화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "perbualan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी कुराकानी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privégesprek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rozmowa prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversa privada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversa privada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "личный разговор" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat konversation" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட உரையாடல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "การสนทนาส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel konuşma" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна розмова" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی گفتگو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cuộc trò chuyện riêng tư" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "私密对话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "私密對話" + } + } + } + }, + "content.private.caption_encrypted" : { + "comment" : "Caption above the private chat composer once the session is end-to-end encrypted", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خاص · مشفّر من طرف إلى طرف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত · এন্ড-টু-এন্ড এনক্রিপ্টেড" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-verschlüsselt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "private · end-to-end encrypted" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "privado · cifrado de extremo a extremo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pribado · end-to-end na naka-encrypt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · chiffré de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פרטי · מוצפן מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एंड-टू-एंड एन्क्रिप्टेड" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pribadi · terenkripsi ujung ke ujung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privato · cifrato end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベート · エンドツーエンド暗号化" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 · 종단간 암호화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peribadi · terenkripsi ujung ke ujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एन्ड-टु-एन्ड सङ्केत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · end-to-end-versleuteld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "prywatna · szyfrowana end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privado · encriptado ponta a ponta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privado · criptografado ponto a ponto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "лично · сквозное шифрование" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-krypterad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்டது · முனை-முதல்-முனை குறியாக்கம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่วนตัว · เข้ารหัสแบบครบวงจร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel · uçtan uca şifreli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна · наскрізно зашифрована" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی · اینڈ ٹو اینڈ خفیہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riêng tư · mã hóa đầu cuối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "私密 · 端到端加密" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "私密 · 端到端加密" + } + } + } + }, + "content.private.caption_group" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مجموعة مشفّرة · للأعضاء فقط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপ্টেড গ্রুপ · শুধু সদস্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verschlüsselte gruppe · nur mitglieder" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encrypted group · members only" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupo cifrado · solo miembros" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "naka-encrypt na grupo · mga miyembro lamang" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupe chiffré · membres uniquement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "קבוצה מוצפנת · לחברים בלבד" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्टेड समूह · केवल सदस्य" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup terenkripsi · hanya anggota" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppo cifrato · solo membri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化グループ · メンバー限定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화된 그룹 · 멤버 전용" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan terenkripsi · ahli sahaja" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सङ्केतित समूह · सदस्य मात्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "versleutelde groep · alleen leden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "szyfrowana grupa · tylko członkowie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupo encriptado · apenas membros" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupo criptografado · apenas membros" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зашифрованная группа · только участники" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "krypterad grupp · endast medlemmar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குறியாக்கம் செய்யப்பட்ட குழு · உறுப்பினர்கள் மட்டுமே" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่มที่เข้ารหัส · เฉพาะสมาชิก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "şifreli grup · yalnızca üyeler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зашифрована група · лише учасники" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خفیہ گروپ · صرف اراکین" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm mã hóa · chỉ thành viên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "加密群组 · 仅限成员" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "加密群組 · 僅限成員" + } + } + } + }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { @@ -22046,6 +40209,186 @@ } } }, + "fingerprint.badge.vouched" : { + "comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ موثوق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ সমর্থিত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ VERBÜRGT" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ VOUCHED" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ AVALADO" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ GINARANTIYAHAN" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ATTESTÉ" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ בערבות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ अनुशंसित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ DIJAMIN" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ GARANTITO" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 保証済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 보증됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ DIJAMIN" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ जमानत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ INGESTAAN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ PORĘCZONY" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ATESTADO" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ENDOSSADO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ПОРУЧЕНО" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ INTYGAD" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ உத்தரவாதம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ รับรองแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ KEFİL OLUNDU" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ЗАСВІДЧЕНО" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ضمانت شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ĐÃ BẢO CHỨNG" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 已担保" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 已擔保" + } + } + } + }, "fingerprint.handshake_pending" : { "extractionState" : "manual", "localizations" : { @@ -22583,6 +40926,820 @@ } } }, + "fingerprint.message.vouched_by" : { + "comment" : "How many people the user verified have vouched for this peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "زكّاه %#@people@ ممن تحققت منهم" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d أشخاص" + } + }, + "many" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d شخصًا" + } + }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d شخص" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d شخص" + } + }, + "two" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d شخصان" + } + }, + "zero" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d أشخاص" + } + } + } + } + } + } + }, + "bn" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + } + } + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbürgt von %#@people@, die du verifiziert hast" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d Person" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d Personen" + } + } + } + } + } + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by %#@people@ you verified" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d person" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d people" + } + } + } + } + } + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avalado por %#@people@ que verificaste" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d persona" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d personas" + } + } + } + } + } + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ginarantiyahan ng %#@people@ na na-verify mo" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d tao" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d tao" + } + } + } + } + } + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "recommandé par %#@people@ que tu as vérifiées" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d personne" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d personnes" + } + } + } + } + } + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ערבו לו %#@people@ שאימתת" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "many" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d אנשים" + } + }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d אדם" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d אנשים" + } + }, + "two" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d אנשים" + } + } + } + } + } + } + }, + "hi" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके सत्यापित %d व्यक्ति द्वारा अनुशंसित" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके सत्यापित %d लोगों द्वारा अनुशंसित" + } + } + } + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh %#@people@ yang kamu verifikasi" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d orang" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d orang" + } + } + } + } + } + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "garantito da %#@people@ che hai verificato" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d persona" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d persone" + } + } + } + } + } + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%#@people@ が保証しています(あなたが確認済み)" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d人" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d人" + } + } + } + } + } + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "당신이 확인한 %#@people@이(가) 보증함" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d명" + } + } + } + } + } + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh %#@people@ yang anda sahkan" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d orang" + } + } + } + } + } + } + }, + "ne" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीले प्रमाणित गरेका %d व्यक्तिले जमानत गरेका" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीले प्रमाणित गरेका %d व्यक्तिले जमानत गरेका" + } + } + } + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aanbevolen door %#@people@ die je hebt geverifieerd" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d persoon" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d personen" + } + } + } + } + } + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "poręczone przez %#@people@, które zweryfikowano" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d osoby" + } + }, + "many" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d osób" + } + }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d osobę" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d osoby" + } + } + } + } + } + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "avalizado por %#@people@ que verificaste" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d pessoa" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d pessoas" + } + } + } + } + } + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "avalizado por %#@people@ que você verificou" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d pessoa" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d pessoas" + } + } + } + } + } + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "за него поручились %#@people@, кого ты проверил" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человека" + } + }, + "many" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человек" + } + }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человек" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человека" + } + } + } + } + } + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "intygad av %#@people@ som du har verifierat" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d person" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d personer" + } + } + } + } + } + } + }, + "ta" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் சரிபார்த்த %d நபரால் உத்தரவாதம் அளிக்கப்பட்டது" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் சரிபார்த்த %d நபர்களால் உத்தரவாதம் அளிக்கப்பட்டது" + } + } + } + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รับรองโดย %#@people@ ที่คุณยืนยันแล้ว" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d คน" + } + } + } + } + } + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "doğruladığın %#@people@ kefil oldu" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d kişi" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d kişi" + } + } + } + } + } + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "за нього поручилися %#@people@, яких ти перевірив" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d особи" + } + }, + "many" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d осіб" + } + }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d особа" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d особи" + } + } + } + } + } + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کے تصدیق شدہ %#@people@ نے ضمانت دی ہے" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d شخص" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d افراد" + } + } + } + } + } + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "được bảo chứng bởi %#@people@ mà bạn đã xác minh" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d người" + } + } + } + } + } + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "由你验证过的 %#@people@ 担保" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d 人" + } + } + } + } + } + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "由你驗證過的 %#@people@ 擔保" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d 人" + } + } + } + } + } + } + } + } + }, "fingerprint.their_label" : { "extractionState" : "manual", "localizations" : { @@ -23657,6 +42814,546 @@ } } }, + "geohash_people.state.nearby" : { + "comment" : "State label for someone physically in the location channel's area", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "في هذه المنطقة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই এলাকায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in diesem gebiet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "in this area" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "en esta zona" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nasa lugar na ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dans cette zone" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "באזור הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस क्षेत्र में" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "di area ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in questa zona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このエリア内" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 지역 내" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "di kawasan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यही क्षेत्रमा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in dit gebied" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "w tym obszarze" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nesta área" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nesta área" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "в этой зоне" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i det här området" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்தப் பகுதியில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ในพื้นที่นี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu bölgede" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "у цій зоні" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس علاقے میں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "trong khu vực này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在此区域内" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在此區域內" + } + } + } + }, + "geohash_people.state.teleported" : { + "comment" : "State label for someone who joined the location channel from elsewhere", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "منتقل فوريًا من مكان آخر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অন্য কোথাও থেকে টেলিপোর্টেড" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "von woanders teleportiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported from elsewhere" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "teletransportado desde otro lugar" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nag-teleport mula sa ibang lugar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "téléporté d'ailleurs" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "עבר בטלפורט ממקום אחר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कहीं और से टेलीपोर्टेड" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport dari tempat lain" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletrasportato da altrove" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "別の場所からテレポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "다른 곳에서 텔레포트" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport dari tempat lain" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अन्त कतैबाट टेलिपोर्ट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "van elders geteleporteerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleportowany skądinąd" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletransportado de outro lugar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletransportado de outro lugar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортирован из другого места" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleporterad från annan plats" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வேறு இடத்திலிருந்து டெலிபோர்ட் செய்யப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เทเลพอร์ตจากที่อื่น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "başka bir yerden ışınlandı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортований звідкись" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کہیں اور سے ٹیلی پورٹ ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dịch chuyển từ nơi khác" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "从别处瞬移而来" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "從其他地方瞬移而來" + } + } + } + }, + "geohash_people.state.you" : { + "comment" : "State label marking your own row in the people list", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أنت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tú" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ikaw" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toi" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "あなた" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "나" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jij" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sen" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你" + } + } + } + }, "geohash_people.tooltip.blocked" : { "extractionState" : "manual", "localizations" : { @@ -24015,6 +43712,1620 @@ } } }, + "groups.accessibility.open_group_hint" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح الدردشة الجماعية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ চ্যাট খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Öffnet den gruppenchat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the group chat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre el chat de grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Binubuksan ang group chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ouvre la discussion de groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את הצ'אט הקבוצתי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह चैट खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka obrolan grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Apre la chat di gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループチャットを開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 채팅을 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka sembang kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह च्याट खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Opent de groepschat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Otwiera czat grupowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Abre a conversa de grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Abre o chat do grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает групповой чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Öppnar gruppchatten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு உரையாடலைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแชทกลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Grup sohbetini açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Відкриває груповий чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ چیٹ کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở cuộc trò chuyện nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打开该群聊" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打開群組聊天" + } + } + } + }, + "groups.member_count %@" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + } + } + }, + "groups.section.header" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "المجموعات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "groups" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mga grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "קבוצות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूहहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groepen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupper" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுக்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruplar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群组" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群組" + } + } + } + }, + "groups.state.creator" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "المُنشئ" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নির্মাতা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ersteller" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creator" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creador" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tagalikha" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Créateur" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "יוצר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निर्माता" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pembuat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Creatore" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "作成者" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "생성자" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pencipta" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सिर्जनाकर्ता" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Maker" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Twórca" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Criador" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Criador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создатель" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skapare" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உருவாக்கியவர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ผู้สร้าง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Oluşturan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Створювач" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بنانے والا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "người tạo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "创建者" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "建立者" + } + } + } + }, + "Images are only available in mesh chats." : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الصور متاحة فقط في محادثات الميش." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder sind nur im Mesh-Chat verfügbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Images are only available in mesh chats." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las imágenes solo están disponibles en los chats de mesh." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ang mga larawan ay available lamang sa mga mesh chat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les images sont uniquement disponibles dans les discussions mesh." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תמונות זמינות רק בצ׳אט של mesh." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gambar hanya tersedia di obrolan mesh." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le immagini sono disponibili solo nelle chat mesh." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像はメッシュチャットでのみ利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imej hanya tersedia dalam sembang mesh." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obrazy są dostępne tylko na czatach mesh." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изображения доступны только в mesh-чатах." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder är bara tillgängliga i mesh-chattar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зображення доступні лише в mesh-чатах." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图片仅可在 mesh 聊天中使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圖片僅能在 mesh 聊天中使用。" + } + } + } + }, + "live %@" : { + "comment" : "Recording HUD label while a voice message streams live to the recipient", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مباشر %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "লাইভ %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "live %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "live %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "en vivo %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "live %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "en direct %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שידור חי %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लाइव %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "langsung %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "in diretta %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライブ %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이브 %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "langsung %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "लाइभ %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "live %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "na żywo %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ao vivo %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ao vivo %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в эфире %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "live %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நேரலை %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "สด %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "canlı %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "наживо %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "لائیو %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trực tiếp %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直播 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "直播 %@" + } + } + } + }, + "location_channels.accessibility.add_bookmark" : { + "comment" : "Accessibility action name for bookmarking a channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إضافة إشارة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "চ্যানেল বুকমার্ক করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanal mit lesezeichen versehen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bookmark channel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "marcar canal" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-bookmark ang channel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mettre le canal en signet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הוסף סימנייה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चैनल बुकमार्क करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tandai kanal" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aggiungi il canale ai segnalibri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "チャンネルをブックマーク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "채널 북마크" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tanda buku kanal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "च्यानल बुकमार्क गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanaal toevoegen aan bladwijzers" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dodaj kanał do zakładek" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "marcar canal" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "marcar canal" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "добавить канал в закладки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bokmärk kanal" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சேனலைப் புக்மார்க் செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "บุ๊กมาร์กช่อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanalı yer imlerine ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "додати канал у закладки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "چینل کو بُک مارک کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đánh dấu kênh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "为频道添加书签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "將頻道加入書簽" + } + } + } + }, + "location_channels.accessibility.remove_bookmark" : { + "comment" : "Accessibility action name for removing a channel bookmark", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إزالة الإشارة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "বুকমার্ক সরান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lesezeichen entfernen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remove bookmark" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quitar marcador" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "alisin ang bookmark" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "supprimer le signet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הסר סימנייה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "बुकमार्क हटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus tanda" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rimuovi segnalibro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ブックマークを削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "북마크 제거" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buang tanda buku" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "बुकमार्क हटाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bladwijzer verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usuń zakładkę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "remover marcador" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "remover marcador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "удалить закладку" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta bort bokmärke" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புக்மார்க்கை நீக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ลบบุ๊กมาร์ก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yer imini kaldır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалити закладку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بُک مارک ہٹائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bỏ đánh dấu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "移除书签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "移除書簽" + } + } + } + }, + "location_channels.accessibility.switch_hint" : { + "comment" : "Accessibility hint on a channel row explaining activation switches to it", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "التبديل إلى هذه القناة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই চ্যানেলে বদলে যায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wechselt zu diesem kanal" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "switches to this channel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cambia a este canal" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lumilipat sa channel na ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bascule vers ce canal" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחליף לערוץ הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस चैनल पर स्विच करता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beralih ke kanal ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "passa a questo canale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このチャンネルに切り替えます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 채널로 전환합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beralih ke kanal ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो च्यानलमा बदल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "schakelt naar dit kanaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "przełącza na ten kanał" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "muda para este canal" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "troca para este canal" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "переключает на этот канал" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "byter till den här kanalen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த சேனலுக்கு மாறும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สลับไปยังช่องนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu kanala geçer" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "перемикає на цей канал" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس چینل پر منتقل ہوتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chuyển sang kênh này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "切换到此频道" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "切換到此頻道" + } + } + } + }, "location_channels.action.open_settings" : { "extractionState" : "manual", "localizations" : { @@ -25268,6 +46579,186 @@ } } }, + "location_channels.grant_to_find" : { + "comment" : "Hint shown in the channel list instead of a loading spinner when location permission is missing", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "امنح إذن الموقع للعثور على القنوات القريبة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি চ্যানেল খুঁজতে অবস্থানের অনুমতি দিন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "standortzugriff erlauben, um kanäle in der nähe zu finden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "grant location access to find nearby channels" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "concede acceso a la ubicación para encontrar canales cercanos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "payagan ang access sa lokasyon para makahanap ng mga kalapit na channel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "autorisez l'accès à la position pour trouver les canaux à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אפשרו גישה למיקום כדי למצוא ערוצים בקרבת מקום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास के चैनल खोजने के लिए स्थान की अनुमति दें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "izinkan akses lokasi untuk menemukan channel terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "consenti l'accesso alla posizione per trovare i canali vicini" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのチャンネルを見つけるには位置情報へのアクセスを許可してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주변 채널을 찾으려면 위치 접근을 허용하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "benarkan akses lokasi untuk mencari saluran berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकैका च्यानलहरू फेला पार्न स्थान पहुँच दिनुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geef locatietoegang om kanalen in de buurt te vinden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przyznaj dostęp do lokalizacji, aby znaleźć pobliskie kanały" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "conceda acesso à localização para encontrar canais próximos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "conceda acesso à localização para encontrar canais próximos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "разрешите доступ к геопозиции, чтобы найти каналы поблизости" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ge platsåtkomst för att hitta kanaler i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள சேனல்களைக் கண்டறிய இருப்பிட அணுகலை வழங்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อนุญาตการเข้าถึงตำแหน่งเพื่อค้นหาช่องใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki kanalları bulmak için konum erişimine izin verin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "надайте доступ до геолокації, щоб знайти канали поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی چینلز تلاش کرنے کے لیے مقام تک رسائی دیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cấp quyền truy cập vị trí để tìm các kênh gần đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授予位置权限以查找附近的频道" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "授予位置權限以尋找附近的頻道" + } + } + } + }, "location_channels.loading_nearby" : { "extractionState" : "manual", "localizations" : { @@ -28695,6 +50186,186 @@ } } }, + "location_notes.connecting_relays" : { + "comment" : "Status line while geo notes wait for a relay connection (e.g. Tor still bootstrapping)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جارٍ الاتصال بالمرحّلات…" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রিলেতে সংযোগ হচ্ছে…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbinde mit relays…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connecting to relays…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectando a los relays…" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kumokonekta sa mga relay…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "connexion aux relais…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מתחבר לממסרים…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिले से कनेक्ट हो रहा है…" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghubungkan ke relay…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "connessione ai relay…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーに接続中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이에 연결 중…" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyambung ke relay…" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिलेमा जडान हुँदैछ…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbinden met relays…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "łączenie z relay…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "a ligar aos relés…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectando aos relés…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "подключение к релеям…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ansluter till reläer…" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ரிலேயுடன் இணைக்கிறது…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลังเชื่อมต่อรีเลย์…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay'lere bağlanılıyor…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "підключення до релеїв…" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریلے سے منسلک ہو رہا ہے…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đang kết nối đến relay…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在连接中继…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在連接中繼…" + } + } + } + }, "location_notes.description" : { "extractionState" : "manual", "localizations" : { @@ -28874,364 +50545,6 @@ } } }, - "location_notes.empty_subtitle" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "كن أول من يضيف هنا." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই জায়গার জন্য প্রথম নোট যোগ করুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "sei die erste person, die hier eine notiz hinterlässt." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "be the first to add one for this spot." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "sé la primera persona en añadir una en este lugar." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "maging unang magdagdag para sa puntong ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "sois la première personne à en ajouter ici." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "היה הראשון להוסיף כאן." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "इस जगह के लिए पहला नोट आप जोड़ें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "jadilah orang pertama yang menambahkannya di sini." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "fai tu la prima nota qui." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ここで最初のノートを残そう。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 장소에 첫 번째 노트를 남겨보세요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "jadilah orang pertama yang menambahkannya di sini." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यस ठाउँमा नोट थप्ने पहिलो व्यक्ती बन।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wees de eerste die er hier een toevoegt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dodaj pierwszą notatkę dla tego miejsca" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "sê o primeiro a adicionar uma nota para este sítio." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "seja a primeira pessoa a adicionar uma aqui." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "стань первым, кто добавит здесь заметку." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "var först med en anteckning här" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த இடத்திற்கு முதலில் ஒரு குறிப்பைச் சேர்க்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เป็นคนแรกที่เพิ่มบันทึกให้จุดนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu yer için ilk notu sen ekle." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "стань першим, хто додасть тут замітку." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اس مقام کیلئے پہلا نوٹ آپ شامل کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "hãy là người đầu tiên thêm ghi chú tại đây" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "成为这里的第一条笔记。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "成為這裡的第一條筆記。" - } - } - } - }, - "location_notes.empty_title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا توجد ملاحظات بعد" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এখনও কোনো নোট নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "noch keine notizen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no notes yet" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "aún no hay notas" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "wala pang tala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pas encore de notes" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין הערות עדיין" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अभी कोई नोट नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "belum ada catatan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ancora nessuna nota" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ノートはまだありません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "아직 노트가 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "belum ada catatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अहिले नोट छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nog geen notities" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "brak notatek" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ainda sem notas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhuma nota ainda" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "заметок пока нет" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inga anteckningar än" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இன்னும் குறிப்புகள் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยังไม่มีบันทึก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "henüz not yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "заміток ще немає" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ابھی تک کوئی نوٹس نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chưa có ghi chú" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚无笔记" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚無筆記" - } - } - } - }, "location_notes.error.failed_to_send" : { "extractionState" : "manual", "localizations" : { @@ -29590,569 +50903,6 @@ } } }, - "location_notes.header" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d ملاحظات" - } - }, - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d ملاحظة" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d ملاحظة" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d ملاحظة" - } - }, - "two" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d ملاحظتان" - } - }, - "zero" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d ملاحظات" - } - } - } - } - } - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d notiz" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d notizen" - } - } - } - } - } - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d note" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d notes" - } - } - } - } - } - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d nota" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d notas" - } - } - } - } - } - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d note" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d notes" - } - } - } - } - } - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d הערות" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d הערה" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d הערות" - } - }, - "two" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d הערות" - } - } - } - } - } - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d catatan" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d catatan" - } - } - } - } - } - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d nota" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d note" - } - } - } - } - } - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d件のノート" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d件のノート" - } - } - } - } - } - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d개의 노트" - } - } - } - } - } - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d नोट" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d नोटहरू" - } - } - } - } - } - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d nota" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d notas" - } - } - } - } - } - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d заметки" - } - }, - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d заметок" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d заметка" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d заметки" - } - } - } - } - } - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d замітки" - } - }, - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d заміток" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d замітка" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d замітки" - } - } - } - } - } - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - }, - "substitutions" : { - "note_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d 条笔记" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d 条笔记" - } - } - } - } - } - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$#@note_count@" - } - } - } - }, "location_notes.loading_notes" : { "extractionState" : "manual", "localizations" : { @@ -30332,185 +51082,6 @@ } } }, - "location_notes.loading_recent" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "جار تحميل الملاحظات الحديثة…" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সাম্প্রতিক নোট লোড হচ্ছে…" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "aktuelle notizen werden geladen…" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "loading recent notes…" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "cargando notas recientes…" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "nagse-load ng pinakahuling mga tala…" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "chargement des notes récentes…" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "טוען הערות אחרונות…" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "हाल के नोट लोड हो रहे हैं…" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "memuat catatan terbaru…" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "caricamento note recenti…" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "最新ノートを読み込み中…" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "최근 노트 로딩 중…" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "memuat catatan terbaru…" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "हालैका नोट लोड गर्दै…" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "recentste notities laden…" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ładowanie ostatnich notatek…" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "a carregar notas recentes…" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "carregando notas recentes…" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "загрузка свежих заметок…" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "laddar senaste anteckningar…" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "சமீபத்திய குறிப்புகள் ஏற்றப்படுகின்றன…" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "กำลังโหลดบันทึกล่าสุด…" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "son notlar yükleniyor…" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "завантаження свіжих заміток…" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "حالیہ نوٹس لوڈ ہو رہے ہیں…" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đang tải ghi chú gần đây…" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在加载最新笔记…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在加載最新筆記…" - } - } - } - }, "location_notes.no_relays_nearby" : { "extractionState" : "manual", "localizations" : { @@ -30690,364 +51261,6 @@ } } }, - "location_notes.placeholder" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "أضف ملاحظة لهذا المكان" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই স্থানের জন্য একটি নোট যোগ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "notiz für diesen ort hinzufügen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "add a note for this place" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "añade una nota para este lugar" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "magdagdag ng tala para sa lugar na ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ajoute une note pour cet endroit" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הוסף הערה למקום הזה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "इस स्थान के लिए नोट जोड़ें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tambahkan catatan untuk tempat ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "aggiungi una nota per questo posto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この場所のノートを追加" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 장소에 대한 노트 추가" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tambahkan catatan untuk tempat ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यस स्थानका लागि नोट थप" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "voeg een notitie toe voor deze plek" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dodaj notatkę do tego miejsca" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "adiciona uma nota para este local" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "adicione uma nota para este lugar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "добавь заметку для этого места" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "lägg till en anteckning för den här platsen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த இடத்திற்கான ஒரு குறிப்பைச் சேர்க்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เพิ่มบันทึกให้สถานที่นี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu yer için bir not ekleyin" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "додай замітку для цього місця" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اس جگہ کیلئے نوٹ شامل کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "thêm ghi chú cho nơi này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "为此地点添加笔记" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "為此地點添加筆記" - } - } - } - }, - "location_notes.relays_paused" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المرحلات الجغرافية غير متاحة؛ الملاحظات متوقفة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "জিও রিলে অনুপলব্ধ; নোট স্থগিত" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo-relays nicht verfügbar; notizen pausiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo relays unavailable; notes paused" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "relays geográficos no disponibles; notas en pausa" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi magagamit ang mga geo relay; naka-pause ang mga tala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "relais géo indisponibles ; notes en pause" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ממסרי geo אינם זמינים; הערות הושהו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "जियो रिले अनुपलब्ध; नोट रुके हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay geo tidak tersedia; catatan dijeda" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay geo non disponibili; note in pausa" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ジオリレーが利用不可: ノート一時停止" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo 릴레이를 사용할 수 없습니다; 노트가 일시 중지됩니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay geo tidak tersedia; catatan dijeda" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "georelay उपलब्ध छैन; नोट रोकिएको" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo-relays niet beschikbaar; notities gepauzeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo relay niedostępne; notatki wstrzymane" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "relés geográficos indisponíveis; notas em pausa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "relays geográficos indisponíveis; notas pausadas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "геореле недоступны; заметки приостановлены" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo-reläer otillgängliga; anteckningar pausade" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo relay கிடைக்கவில்லை; குறிப்புகள் இடைநிறுத்தப்பட்டுள்ளன" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo relay ไม่พร้อมใช้งาน บันทึกถูกหยุดชั่วคราว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo röleler kullanılamıyor; notlar durduruldu" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "гео-релеї недоступні; замітки призупинено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo relay دستیاب نہیں؛ نوٹس موقوف" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay địa lý không sẵn có; ghi chú bị tạm dừng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "地理中继不可用;笔记已暂停" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "地理中繼不可用;筆記已暫停" - } - } - } - }, "location_notes.relays_retry_hint" : { "extractionState" : "manual", "localizations" : { @@ -31227,6 +51440,4504 @@ } } }, + "media.accessibility.cancel_send" : { + "comment" : "Accessibility label for the cancel button on an in-flight media send", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إلغاء الإرسال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পাঠানো বাতিল করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "senden abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancel sending" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancelar envío" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanselahin ang pagpapadala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "annuler l'envoi" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ביטול שליחה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भेजना रद्द करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "batalkan pengiriman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "annulla invio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "送信をキャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송 취소" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "batalkan penghantaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पठाउने रद्द गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verzenden annuleren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anuluj wysyłanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cancelar envio" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cancelar envio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отменить отправку" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "avbryt sändning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அனுப்புவதை ரத்து செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยกเลิกการส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "göndermeyi iptal et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скасувати надсилання" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بھیجنا منسوخ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hủy gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "取消发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "取消發送" + } + } + } + }, + "media.image.accessibility.hidden" : { + "comment" : "Accessibility label for a blurred incoming image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "صورة مخفية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "লুকানো ছবি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verstecktes bild" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hidden image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen oculta" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nakatagong larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image masquée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "תמונה מוסתרת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "छिपा हुआ चित्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar tersembunyi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine nascosta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "非表示の画像" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "숨겨진 이미지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej tersembunyi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "लुकाइएको तस्बिर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verborgen afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukryty obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem oculta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem oculta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скрытое изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dold bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மறைக்கப்பட்ட படம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รูปภาพที่ซ่อนอยู่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gizli görsel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приховане зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "چھپی ہوئی تصویر" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh ẩn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已隐藏的图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "隱藏的圖片" + } + } + } + }, + "media.image.accessibility.hint.open" : { + "comment" : "Accessibility hint for a revealed image; activating it opens the image full screen", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح الصورة بملء الشاشة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবিটি পূর্ণ স্ক্রিনে খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet das bild im vollbild" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the image full screen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre la imagen a pantalla completa" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "binubuksan ang larawan nang full screen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre l'image en plein écran" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את התמונה במסך מלא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र को पूरी स्क्रीन पर खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka gambar layar penuh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre l'immagine a schermo intero" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を全画面で開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 전체 화면으로 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka imej skrin penuh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर पूरा स्क्रिनमा खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent de afbeelding op volledig scherm" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera obraz na pełnym ekranie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre a imagem em ecrã inteiro" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre a imagem em tela cheia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает изображение на весь экран" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar bilden i helskärm" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை முழுத் திரையில் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดรูปภาพแบบเต็มหน้าจอ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli tam ekran açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває зображення на весь екран" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر کو پوری اسکرین پر کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở hình ảnh toàn màn hình" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "全屏打开图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "全螢幕打開圖片" + } + } + } + }, + "media.image.accessibility.hint.reveal" : { + "comment" : "Accessibility hint for a blurred image; activating it reveals the image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يكشف الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবিটি প্রকাশ করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zeigt das bild" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveals the image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "revela la imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapakita ang larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "révèle l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חושף את התמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र दिखाता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menampilkan gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rivela l'immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を表示します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 표시합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menunjukkan imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर देखाउँछ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toont de afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odsłania obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revela a imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revela a imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показывает изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visar bilden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை வெளிப்படுத்தும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli gösterir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показує зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر ظاہر کرتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示圖片" + } + } + } + }, + "media.image.accessibility.revealed" : { + "comment" : "Accessibility label for a revealed image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "圖片" + } + } + } + }, + "media.image.accessibility.sending" : { + "comment" : "Accessibility label for an image that is still sending", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جارٍ إرسال الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি পাঠানো হচ্ছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild wird gesendet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviando imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapadala ang larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoi de l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שולח תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र भेजा जा रहा है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mengirim gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を送信中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 전송 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menghantar imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर पठाइँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding verzenden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wysyłanie obrazu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a enviar imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviando imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправка изображения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skickar bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை அனுப்புகிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังส่งรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel gönderiliyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надсилання зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر بھیجی جا رہی ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đang gửi hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "正在发送图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "圖片發送中" + } + } + } + }, + "media.image.accessibility.unavailable" : { + "comment" : "Accessibility label for an image whose file could not be loaded", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الصورة غير متاحة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি অনুপলব্ধ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image unavailable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen no disponible" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi magagamit ang larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image indisponible" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "התמונה לא זמינה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र उपलब्ध नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar tidak tersedia" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine non disponibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を利用できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 사용할 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej tidak tersedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर उपलब्ध छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding niet beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obraz niedostępny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem indisponível" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem indisponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "изображение недоступно" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild otillgänglig" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படம் கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถใช้รูปภาพได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel kullanılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зображення недоступне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر دستیاب نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh không khả dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "图片不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "圖片不可用" + } + } + } + }, + "media.image.action.delete" : { + "comment" : "Context menu action that deletes a received image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "حذف الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি মুছুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild löschen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eliminar imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "supprimer l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחק תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र हटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "elimina immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 삭제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "padam imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर मेटाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usuń obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "eliminar imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apagar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "удалить изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta bort bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை நீக்கு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ลบรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli sil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалити зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر حذف کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "删除图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "刪除圖片" + } + } + } + }, + "media.image.action.hide" : { + "comment" : "Context menu action that re-blurs a revealed image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إخفاء الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি লুকান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild verbergen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hide image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "itago ang larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "masquer l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הסתר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र छिपाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sembunyikan gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nascondi immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を非表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 숨기기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sembunyikan imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर लुकाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding verbergen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukryj obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ocultar imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ocultar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скрыть изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dölj bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை மறை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ซ่อนรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli gizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приховати зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر چھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ẩn hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "隐藏图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "隱藏圖片" + } + } + } + }, + "media.image.action.open" : { + "comment" : "Context menu action that opens an image full screen", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فتح الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি খুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild öffnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abrir imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buksan ang larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvrir l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פתח תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र खोलें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apri immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を開く" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 열기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर खोल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding openen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwórz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abrir imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abrir imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открыть изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppna bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தைத் திற" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкрити зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر کھولیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打开图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打開圖片" + } + } + } + }, + "media.image.action.reveal" : { + "comment" : "Context menu action that reveals a blurred image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "كشف الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি প্রকাশ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveal image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "revelar imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipakita ang larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "révéler l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חשוף תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र दिखाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tampilkan gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rivela immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tunjukkan imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर देखाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odsłoń obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revelar imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revelar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показать изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visa bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை வெளிப்படுத்து" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показати зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر ظاہر کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示圖片" + } + } + } + }, + "media.image.delete_confirm_message" : { + "comment" : "Body of the confirmation dialog before deleting a received image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا يمكن التراجع عن هذا — قد لا يكون المرسِل في النطاق لإرسالها مرة أخرى." + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এটি ফেরানো যাবে না — প্রেরক আবার পাঠানোর মতো পরিসরে নাও থাকতে পারেন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "das kann nicht rückgängig gemacht werden — der absender ist möglicherweise nicht in reichweite, um es erneut zu senden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "this cannot be undone — the sender may not be in range to send it again." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "esto no se puede deshacer — puede que el remitente no esté a tu alcance para enviarla de nuevo." + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi na ito maibabalik — maaaring wala na sa saklaw ang nagpadala para maipadala itong muli." + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cette action est irréversible — l'expéditeur n'est peut-être pas à portée pour la renvoyer." + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אי אפשר לבטל את זה — ייתכן שהשולח לא בטווח כדי לשלוח אותה שוב." + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इसे पूर्ववत नहीं किया जा सकता — भेजने वाला शायद इसे दोबारा भेजने की रेंज में न हो।" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ini tidak bisa dibatalkan — pengirim mungkin tidak dalam jangkauan untuk mengirimnya lagi." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "questa azione non può essere annullata — il mittente potrebbe non essere a portata per inviarla di nuovo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "この操作は取り消せません — 送信者が圏内におらず、再送信できない場合があります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 작업은 취소할 수 없습니다 — 보낸 사람이 범위 내에 없어 다시 보내지 못할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ini tidak boleh dibatalkan — penghantar mungkin tiada dalam jangkauan untuk menghantarnya semula." + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो फिर्ता गर्न सकिँदैन — पठाउने फेरि पठाउन दायरामा नहुन सक्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dit kan niet ongedaan worden gemaakt — de afzender is mogelijk niet in bereik om het opnieuw te sturen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tej operacji nie można cofnąć — nadawca może być poza zasięgiem, aby wysłać go ponownie." + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "isto não pode ser anulado — o remetente pode não estar ao alcance para a enviar de novo." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "isso não pode ser desfeito — quem enviou pode não estar no alcance para enviar de novo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "это нельзя отменить — отправитель может быть вне зоны, чтобы отправить снова." + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "detta kan inte ångras — avsändaren kanske inte är inom räckhåll för att skicka den igen." + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இதை மீட்டெடுக்க முடியாது — அனுப்பியவர் மீண்டும் அனுப்பும் வரம்பில் இல்லாமல் இருக்கலாம்." + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "การกระทำนี้ไม่สามารถยกเลิกได้ — ผู้ส่งอาจไม่อยู่ในระยะที่จะส่งอีกครั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu geri alınamaz — gönderen tekrar göndermek için menzilde olmayabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "це не можна скасувати — відправник може бути поза радіусом, щоб надіслати його знову." + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اسے واپس نہیں کیا جا سکتا — ممکن ہے بھیجنے والا اسے دوبارہ بھیجنے کیلئے رینج میں نہ ہو۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể hoàn tác — người gửi có thể không trong phạm vi để gửi lại." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "此操作无法撤销 — 发送者可能不在范围内,无法再次发送。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "此操作無法復原 — 發送者可能不在範圍內,無法再次發送。" + } + } + } + }, + "media.image.delete_confirm_title" : { + "comment" : "Title of the confirmation dialog before deleting a received image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "حذف هذه الصورة؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই ছবি মুছবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dieses bild löschen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete this image?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿eliminar esta imagen?" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang larawang ito?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "supprimer cette image ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "למחוק את התמונה הזו?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यह चित्र हटाएँ?" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus gambar ini?" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "eliminare questa immagine?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "この画像を削除しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 이미지를 삭제할까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "padam imej ini?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो तस्बिर मेटाउने?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deze afbeelding verwijderen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usunąć ten obraz?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "eliminar esta imagem?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apagar esta imagem?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "удалить это изображение?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta bort den här bilden?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்தப் படத்தை நீக்கவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ลบรูปภาพนี้หรือไม่?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu görsel silinsin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалити це зображення?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "یہ تصویر حذف کریں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa hình ảnh này?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "删除此图片?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "刪除此圖片?" + } + } + } + }, + "media.image.tap_to_reveal" : { + "comment" : "Caption on a blurred incoming image inviting a tap to reveal it", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط للكشف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রকাশ করতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zum anzeigen tippen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to reveal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca para revelar" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-tap para ipakita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "touche pour révéler" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש לחשיפה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "दिखाने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menampilkan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca per rivelare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "タップして表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "탭하여 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menunjukkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "देखाउन ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tik om te tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij, aby odsłonić" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca para revelar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toque para revelar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нажми, чтобы показать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tryck för att visa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வெளிப்படுத்த தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะเพื่อแสดง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "göstermek için dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "торкнися, щоб показати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ظاہر کرنے کیلئے ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm để hiện" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "轻点显示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "輕點顯示" + } + } + } + }, + "media.voice.accessibility.live" : { + "comment" : "Accessibility label announcing a live incoming voice message", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "رسالة صوتية مباشرة واردة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আগত লাইভ ভয়েস বার্তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "eingehende live-sprachnachricht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "incoming live voice message" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje de voz en vivo entrante" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "papasok na live na voice message" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "message vocal en direct entrant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הודעה קולית חיה נכנסת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आने वाला लाइव वॉयस संदेश" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pesan suara langsung masuk" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "messaggio vocale in diretta in arrivo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライブ音声メッセージを受信中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수신 중인 라이브 음성 메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesej suara langsung masuk" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आगमन लाइभ भ्वाइस सन्देश" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "inkomend live spraakbericht" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przychodząca wiadomość głosowa na żywo" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem de voz ao vivo a chegar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem de voz ao vivo chegando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "входящее живое голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inkommande live-röstmeddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "உள்வரும் நேரலை குரல் செய்தி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อความเสียงสดขาเข้า" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gelen canlı sesli mesaj" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "вхідне живе голосове повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "موصول ہونے والا لائیو صوتی پیغام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tin nhắn thoại trực tiếp đang đến" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在接收实时语音消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在接收即時語音訊息" + } + } + } + }, + "media.voice.accessibility.pause" : { + "comment" : "Accessibility label for pausing voice note playback", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إيقاف الملاحظة الصوتية مؤقتًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট থামান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht pausieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pause voice note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "pausar nota de voz" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-pause ang voice note" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mettre la note vocale en pause" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "השהיית הערה קולית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट रोकें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jeda catatan suara" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "metti in pausa la nota vocale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモを一時停止" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 일시정지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jeda nota suara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट रोक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht pauzeren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wstrzymaj notatkę głosową" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "colocar a nota de voz em pausa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pausar mensagem de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приостановить голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pausa röstmeddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பை இடைநிறுத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "หยุดข้อความเสียงชั่วคราว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli notu duraklat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "призупинити голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ روکیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tạm dừng ghi chú giọng nói" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暂停语音消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暫停語音訊息" + } + } + } + }, + "media.voice.accessibility.play" : { + "comment" : "Accessibility label for playing a voice note", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تشغيل الملاحظة الصوتية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট চালান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht abspielen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "play voice note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "reproducir nota de voz" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-play ang voice note" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lire la note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "השמעת הערה קולית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट चलाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "putar catatan suara" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riproduci la nota vocale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモを再生" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 재생" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mainkan nota suara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट बजाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht afspelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odtwórz notatkę głosową" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reproduzir a nota de voz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reproduzir mensagem de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "воспроизвести голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spela upp röstmeddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பை இயக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เล่นข้อความเสียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli notu oynat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відтворити голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ چلائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "phát ghi chú giọng nói" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "播放语音消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "播放語音訊息" + } + } + } + }, + "media.voice.live_badge" : { + "comment" : "Badge on a voice message that is currently streaming in live", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مباشر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "লাইভ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "EN VIVO" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DIRECT" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "חי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लाइव" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "LANGSUNG" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライブ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이브" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "LANGSUNG" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "लाइभ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "NA ŻYWO" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "AO VIVO" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "AO VIVO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЭФИР" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நேரலை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "สด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CANLI" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "НАЖИВО" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "لائیو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TRỰC TIẾP" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直播" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "直播" + } + } + } + }, + "mesh_peers.accessibility.open_dm_hint" : { + "comment" : "Accessibility hint on a peer row explaining activation opens a private chat", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح دردشة خاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "একটি ব্যক্তিগত চ্যাট খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet einen privaten chat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens a private chat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre un chat privado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "binubuksan ang isang pribadong chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre une discussion privée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח צ'אט פרטי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी चैट खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka obrolan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre una chat privata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートチャットを開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 채팅을 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka sembang peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी च्याट खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent een privéchat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera czat prywatny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre uma conversa privada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre um chat privado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает личный чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar en privat chatt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட உரையாடலைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแชทส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel bir sohbet açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває приватний чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی چیٹ کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở cuộc trò chuyện riêng tư" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打开私聊" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打開私聊" + } + } + } + }, + "mesh_peers.action.fingerprint" : { + "comment" : "Context menu action that shows a peer's fingerprint/verification screen", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "عرض البصمة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ফিঙ্গারপ্রিন্ট দেখান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fingerabdruck anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show fingerprint" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mostrar huella" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipakita ang fingerprint" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afficher l'empreinte" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הצג טביעת אצבע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़िंगरप्रिंट दिखाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tampilkan sidik" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra impronta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "フィンガープリントを表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "지문 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tunjukkan sidik" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फिंगरप्रिन्ट देखाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "vingerafdruk tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pokaż odcisk" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar impressão digital" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar impressão digital" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показать отпечаток" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visa fingeravtryck" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "கைரேகையைக் காட்டு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงลายนิ้วมือ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "parmak izini göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показати відбиток" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فنگرپرنٹ دکھائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện vân tay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示指纹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示指紋" + } + } + } + }, + "mesh_peers.state.blocked" : { + "comment" : "State label for a blocked peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محظور" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্লক করা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloqueado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "na-block" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חסום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लॉक किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ブロック中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "차단됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zablokowany" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தடுக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ถูกบล็อก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "engellendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بلاک شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã chặn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽" + } + } + } + }, + "mesh_peers.state.favorite" : { + "comment" : "State label for a favorited peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مفضّل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রিয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paborito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מועדף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पसंदीदा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "preferito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "お気に入り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "즐겨찾기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मनपर्ने" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favoriet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ulubiony" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorito" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorito" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "избранное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சிறப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คนโปรด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "улюблений" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پسندیدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "收藏" + } + } + } + }, + "mesh_peers.state.offline" : { + "comment" : "State label for a peer that is not currently reachable", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "غير متصل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অফলাইন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sin conexión" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hors ligne" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא מקוון" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ऑफ़लाइन" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "オフライン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "오프라인" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अफलाइन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ஆஃப்லைன்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ออฟไลน์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "çevrimdışı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آف لائن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ngoại tuyến" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "离线" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "離線" + } + } + } + }, + "mesh_peers.state.unread" : { + "comment" : "State label for a peer with unread private messages", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسائل جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নতুন বার্তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "neue nachrichten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "new messages" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensajes nuevos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "may bagong mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nouveaux messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעות חדשות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नए संदेश" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nuovi messaggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新着メッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새 메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan baru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नयाँ सन्देश" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nieuwe berichten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nowe wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "novas mensagens" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "novas mensagens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "новые сообщения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nya meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புதிய செய்திகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความใหม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yeni mesajlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нові повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نئے پیغامات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tin nhắn mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新訊息" + } + } + } + }, + "mesh_peers.state.vouched" : { + "comment" : "State label for a peer vouched for by someone the user verified", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "موثوق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সমর্থিত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbürgt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avalado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ginarantiyahan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "attesté" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "בערבות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अनुशंसित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "garantito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "保証済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "보증됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "जमानत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingestaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "poręczony" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atestado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "endossado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поручились" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "intygad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உத்தரவாதம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รับรองแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kefil olundu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "засвідчено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ضمانت شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã bảo chứng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已担保" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已擔保" + } + } + } + }, "mesh_peers.tooltip.new_messages" : { "extractionState" : "manual", "localizations" : { @@ -31406,6 +56117,2346 @@ } } }, + "mesh_peers.tooltip.vouched" : { + "comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "موثوق من قِبل شخص تحقّقت منه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন কারো দ্বারা সমর্থিত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbürgt von jemandem, den du verifiziert hast" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by someone you verified" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avalado por alguien que verificaste" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ginarantiyahan ng taong na-beripika mo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "attesté par une personne que tu as vérifiée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מישהו שאימתת ערב לו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके किसी सत्यापित व्यक्ति द्वारा अनुशंसित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh seseorang yang kamu verifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "garantito da qualcuno che hai verificato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "あなたが確認した人が保証しています" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "당신이 확인한 사람이 보증함" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh seseorang yang anda sahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीले प्रमाणित गरेका कसैले जमानत गरेको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingestaan door iemand die je hebt geverifieerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "poręczony przez kogoś, kogo zweryfikowałeś" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atestado por alguém que verificaste" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "endossado por alguém que você verificou" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "за него поручился тот, кого ты проверил" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "intygad av någon du verifierat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் சரிபார்த்த ஒருவரால் உத்தரவாதம் அளிக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รับรองโดยคนที่คุณยืนยันแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "doğruladığın biri tarafından kefil olundu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "засвідчено кимось, кого ти підтвердив" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کسی ایسے شخص کی ضمانت جس کی آپ نے توثیق کی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "được bảo chứng bởi người bạn đã xác minh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "由你已验证的人担保" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "由你驗證過的人擔保" + } + } + } + }, + "notices.accessibility.close" : { + "comment" : "Accessibility label for the notices close button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إغلاق الإعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ বন্ধ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Isara ang mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer les annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סגור מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ बंद करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせを閉じる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지 닫기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचना बन्द गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknij ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stäng anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகளை மூடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปิดประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyuruları kapat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрити оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات بند کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đóng thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉公告" + } + } + } + }, + "notices.accessibility.scope" : { + "comment" : "Accessibility label for the geo/mesh scope toggle", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نطاق الإعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশের পরিধি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich der Hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices scope" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ámbito de los avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saklaw ng mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portée des annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טווח המודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाओं का दायरा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cakupan pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ambito degli avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせの範囲" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지 범위" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skop pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाको दायरा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereik van mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakres ogłoszeń" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Âmbito dos avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escopo dos avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Раздел объявлений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslagens omfattning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகளின் வரம்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ขอบเขตประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyuru kapsamı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Розділ оголошень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات کا دائرہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phạm vi thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告范围" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告範圍" + } + } + } + }, + "notices.alert.urgent_collapsed" : { + "comment" : "Local chat line when several urgent notices arrive together", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld إعلانات عاجلة جديدة — اضغط على الدبوس للعرض" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lldটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld neue dringende hinweise — tippe zum ansehen auf den pin" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld new urgent notices — tap the pin to view" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld avisos urgentes nuevos — toca el pin para verlos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld bagong agarang paunawa — i-tap ang pin para makita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nouvelles annonces urgentes — touche l'épingle pour voir" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld מודעות דחופות חדשות — הקש על הנעץ לצפייה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld pengumuman mendesak baru — ketuk pin untuk melihat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nuovi avvisi urgenti — tocca la puntina per vederli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 新しい緊急のお知らせが%lld件 — ピンをタップして表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 새 긴급 공지 %lld개 — 핀을 탭하여 확인" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld pengumuman segera baharu — ketik pin untuk melihat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nieuwe dringende mededelingen — tik op de pin om te bekijken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld novos avisos urgentes — toca no pin para ver" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld avisos urgentes novos — toque no pin para ver" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld новых срочных объявлений — нажми на булавку, чтобы посмотреть" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nya brådskande anslag — tryck på nålen för att visa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 ประกาศด่วนใหม่ %lld รายการ — แตะหมุดเพื่อดู" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld yeni acil duyuru — görmek için raptiyeye dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld нових термінових оголошень — натисни на шпильку, щоб переглянути" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld thông báo khẩn mới — chạm vào ghim để xem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld 条新紧急公告 — 点按图钉查看" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld 則新緊急公告 — 點按圖釘查看" + } + } + } + }, + "notices.alert.urgent_single" : { + "comment" : "Local chat line when one urgent notice is pinned nearby", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 إعلان عاجل من @%@: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@-এর জরুরি নোটিশ: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 dringender hinweis von @%@: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 urgent notice from @%@: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 agarang paunawa mula kay @%@: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 annonce urgente de @%@ : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 מודעה דחופה מאת @%@: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ की ज़रूरी सूचना: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pengumuman mendesak dari @%@: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 avviso urgente da @%@: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@からの緊急のお知らせ: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@님의 긴급 공지: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pengumuman segera daripada @%@: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@को जरुरी सूचना: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 dringende mededeling van @%@: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pilne ogłoszenie od @%@: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 срочное объявление от @%@: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 brådskande anslag från @%@: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ இன் அவசர அறிவிப்பு: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 ประกาศด่วนจาก @%@: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ kişisinden acil duyuru: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 термінове оголошення від @%@: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ کا فوری اعلان: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 thông báo khẩn từ @%@: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 来自 @%@ 的紧急公告:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 來自 @%@ 的緊急公告:%@" + } + } + } + }, + "notices.description.mesh" : { + "comment" : "Explainer for the mesh tab of the notices sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hefte kurze hinweise für leute in deiner nähe an. sie springen von handy zu handy, auch offline, und verschwinden nach ein paar tagen von selbst." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija avisos cortos para la gente cercana. saltan de teléfono a teléfono, incluso sin conexión, y desaparecen solos después de unos días." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-pin ng maiikling paunawa para sa mga taong nasa paligid mo. lumilipat ito mula sa isang telepono patungo sa iba, kahit offline, at kusang nawawala pagkatapos ng ilang araw." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épingle de courtes annonces pour les gens autour de toi. elles passent de téléphone en téléphone, même hors ligne, et disparaissent d'elles-mêmes après quelques jours." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "nåla upp korta anslag för folk i närheten. de hoppar från telefon till telefon, även offline, och försvinner av sig själva efter några dagar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim thông báo ngắn cho những người quanh bạn. chúng truyền từ điện thoại này sang điện thoại khác, kể cả khi ngoại tuyến, và tự biến mất sau vài ngày." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。" + } + } + } + }, + "notices.expiry.permanent" : { + "comment" : "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "دائم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "স্থায়ী" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dauerhaft" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קבוע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थायी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無期限" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "영구" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kekal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थायी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "na stałe" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "навсегда" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நிரந்தரம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ถาวร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kalıcı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "назавжди" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مستقل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "vĩnh viễn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "永久" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "永久" + } + } + } + }, + "notices.fades" : { + "comment" : "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتلاشى %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ মুছে যাবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verblasst %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "fades %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se desvanece %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "maglalaho %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "s'efface %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דוהה %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ मिट जाएगा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "memudar %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "svanisce %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@に消えます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 사라짐" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pudar %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ मेटिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "vervaagt %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zniknie %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "desvanece %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "desaparece %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "исчезнет %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tonar bort %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ மறையும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือนหาย %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kaybolur" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "зникне %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ مٹ جائے گا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mờ dần %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@消失" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@消失" + } + } + } + }, + "notices.source.mesh" : { + "comment" : "Source badge for notices carried by the mesh", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + } + } + }, + "notices.source.nostr" : { + "comment" : "Source badge for notices seen on internet relays", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নেট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "netz" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "red" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רשת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नेट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rete" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ネット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "넷" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नेट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "sieć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "rede" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "rede" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сеть" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "nät" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நெட்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เน็ต" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ağ" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "мережа" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نیٹ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mạng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "網路" + } + } + } + }, + "notices.tab.geo" : { + "comment" : "Segmented control label for geohash-scoped notices", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جغرافي" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এলাকা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "géo" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אזור" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्षेत्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "area" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エリア" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지역" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kawasan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्षेत्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பகுதி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "พื้นที่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bölge" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "علاقہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khu vực" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "区域" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "區域" + } + } + } + }, + "notices.tab.mesh" : { + "comment" : "Segmented control label for mesh-local notices", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + } + } + }, + "notices.title" : { + "comment" : "Title prefix of the unified notices sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "duyurular" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + } + } + }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { @@ -31794,7 +58845,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede iniciar un chat con %@: el usuario está bloqueado." + "value" : "no se puede iniciar un chat con %@: esta persona está bloqueada." } }, "fil" : { @@ -31806,7 +58857,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "impossible de démarrer un chat avec %@ : utilisateur bloqué." + "value" : "impossible de démarrer un chat avec %@ : cette personne est bloquée." } }, "he" : { @@ -31830,7 +58881,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile avviare una chat con %@: utente bloccato." + "value" : "impossibile avviare una chat con %@: questa persona è bloccata." } }, "ja" : { @@ -31878,7 +58929,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não é possível iniciar chat com %@: usuário bloqueado." + "value" : "não é possível iniciar chat com %@: a pessoa está bloqueada." } }, "ru" : { @@ -31943,364 +58994,6 @@ } } }, - "system.chat.requires_favorite" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يمكن بدء دردشة مع %@: يجب أن تكونا مفضلين متبادلين للتشغيل بدون اتصال." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-এর সঙ্গে চ্যাট শুরু করা যায় না: অফলাইন মেসেজিংয়ের জন্য পারস্পরিক প্রিয় দরকার।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat mit %@ kann nicht gestartet werden: gegenseitige favoriten für offline nötig." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot start chat with %@: mutual favorite required for offline messaging." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede iniciar un chat con %@: necesitas ser favoritos mutuos para mensajería sin conexión." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi makapagsimula ng chat kay %@: kailangan ang mutual na paborito para sa offline na mensahe." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible de démarrer un chat avec %@ : favoris mutuels requis pour le hors ligne." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לא ניתן להתחיל צ'אט עם %@: נדרשים מועדפים הדדיים לאופליין." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ के साथ चैट शुरू नहीं कर सकते: ऑफ़लाइन मैसेजिंग के लिए आपसी पसंदीदा आवश्यक है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile avviare una chat con %@: servono preferiti reciproci per l'offline." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@とはチャットできません: オフラインには相互のお気に入りが必要です。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@와 채팅을 시작할 수 없습니다: 오프라인 메시지를 보내려면 서로 즐겨찾기에 추가해야 합니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ सँग च्याट सुरु गर्न मिलेन: अफलाइनका लागि दुवै मनपर्ने हुनुपर्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan chat met %@ niet starten: wederzijds favoriet vereist voor offline berichten." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można rozpocząć czatu z %@: potrzebne wzajemne ulubione dla wiadomości offline." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível iniciar o chat com %@: precisam de ser favoritos mútuos para mensagens offline." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível iniciar chat com %@: vocês precisam ser favoritos mútuos para mensagens offline." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "нельзя начать чат с %@: нужны взаимные избранные для офлайна." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte starta chatt med %@: ömsesidig favorit krävs för offline-meddelanden." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ உடன் உரையாடல் தொடங்க முடியாது: ஆஃப்லைன் செய்திகளுக்கு இருபுறமும் பிரியப்பட்டவர்கள் ஆக வேண்டும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่สามารถเริ่มแชทกับ %@: ต้องเป็นรายการโปรดทั้งสองฝ่ายเพื่อใช้งานออฟไลน์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ile sohbet başlatılamıyor: çevrimdışı mesajlaşma için karşılıklı favori gerekiyor." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не можна почати чат з %@: потрібне взаємне вибране для офлайна." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کے ساتھ چیٹ شروع نہیں کر سکتے: آفلائن پیغامات کیلئے باہمی پسندیدہ ہونا ضروری ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể bắt đầu chat với %@: cần yêu thích lẫn nhau để nhắn ngoại tuyến." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法与 %@ 开始聊天:离线消息需要互相关注。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法與 %@ 開始聊天:離線訊息需要互相關注。" - } - } - } - }, - "system.common.user" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مستخدم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যবহারকারী" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nutzer" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "user" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "usuario" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "user" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "utilisateur" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "משתמש" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "उपयोगकर्ता" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "pengguna" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "utente" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ユーザー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용자" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "pengguna" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रयोगकर्ता" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gebruiker" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "użytkownik" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "utilizador" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "usuário" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "пользователь" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "användare" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பயனர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ผู้ใช้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kullanıcı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "користувач" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "صارف" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "người dùng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用户" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用者" - } - } - } - }, "system.dm.blocked_generic" : { "extractionState" : "manual", "localizations" : { @@ -32331,7 +59024,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede enviar el mensaje: el usuario está bloqueado." + "value" : "no se puede enviar el mensaje: esta persona está bloqueada." } }, "fil" : { @@ -32343,7 +59036,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "envoi impossible : utilisateur bloqué." + "value" : "envoi impossible : cette personne est bloquée." } }, "he" : { @@ -32367,7 +59060,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "invio non riuscito: utente bloccato." + "value" : "invio non riuscito: questa persona è bloccata." } }, "ja" : { @@ -32415,7 +59108,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não foi possível enviar: usuário bloqueado." + "value" : "não foi possível enviar: a pessoa está bloqueada." } }, "ru" : { @@ -32510,7 +59203,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede enviar un mensaje a %@: el usuario está bloqueado." + "value" : "no se puede enviar un mensaje a %@: esta persona está bloqueada." } }, "fil" : { @@ -32522,7 +59215,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "impossible d'envoyer à %@ : utilisateur bloqué." + "value" : "impossible d'envoyer à %@ : cette personne est bloquée." } }, "he" : { @@ -32546,7 +59239,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile inviare a %@: utente bloccato." + "value" : "impossibile inviare a %@: questa persona è bloccata." } }, "ja" : { @@ -32594,7 +59287,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não é possível enviar mensagem para %@: usuário bloqueado." + "value" : "não é possível enviar mensagem para %@: a pessoa está bloqueada." } }, "ru" : { @@ -32659,181 +59352,182 @@ } } }, - "system.dm.unreachable" : { + "system.gateway.sent_via_mesh" : { + "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", - "value" : "لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr." + "state" : "needs_review", + "value" : "أُرسل عبر بوابة mesh" } }, "bn" : { "stringUnit" : { - "state" : "translated", - "value" : "%@-কে বার্তা পাঠানো যায় না - পিয়ার মেশ বা নোস্টরে পৌঁছানো যাচ্ছে না।" + "state" : "needs_review", + "value" : "মেশ গেটওয়ের মাধ্যমে পাঠানো হয়েছে" } }, "de" : { "stringUnit" : { - "state" : "translated", - "value" : "senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar." + "state" : "needs_review", + "value" : "über mesh-gateway gesendet" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "cannot send message to %@ - peer is not reachable via mesh or nostr." + "value" : "sent via mesh gateway" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr." + "value" : "enviado por la puerta de enlace del mesh" } }, "fil" : { "stringUnit" : { - "state" : "translated", - "value" : "hindi maipadala kay %@ - hindi maabot ang peer sa mesh o nostr." + "state" : "needs_review", + "value" : "naipadala sa pamamagitan ng mesh gateway" } }, "fr" : { "stringUnit" : { - "state" : "translated", - "value" : "impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr." + "state" : "needs_review", + "value" : "envoyé via la passerelle mesh" } }, "he" : { "stringUnit" : { - "state" : "translated", - "value" : "אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr." + "state" : "needs_review", + "value" : "נשלח דרך שער mesh" } }, "hi" : { "stringUnit" : { - "state" : "translated", - "value" : "%@ को संदेश नहीं भेज सकते - पीयर मेश या नोस्ट्र पर पहुँच योग्य नहीं।" + "state" : "needs_review", + "value" : "मेश गेटवे के ज़रिए भेजा गया" } }, "id" : { "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." + "state" : "needs_review", + "value" : "dikirim via gateway mesh" } }, "it" : { "stringUnit" : { - "state" : "translated", - "value" : "impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr." + "state" : "needs_review", + "value" : "inviato tramite gateway mesh" } }, "ja" : { "stringUnit" : { - "state" : "translated", - "value" : "%@に送れません: 受信者はmeshやnostrで到達できません。" + "state" : "needs_review", + "value" : "meshゲートウェイ経由で送信" } }, "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "%@에게 메시지를 보낼 수 없습니다 - mesh 또는 nostr를 통해 피어에 연결할 수 없습니다." + "state" : "needs_review", + "value" : "mesh 게이트웨이를 통해 전송됨" } }, "ms" : { "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa menghantar ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." + "state" : "needs_review", + "value" : "dihantar via gateway mesh" } }, "ne" : { "stringUnit" : { - "state" : "translated", - "value" : "%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन" + "state" : "needs_review", + "value" : "mesh गेटवे मार्फत पठाइयो" } }, "nl" : { "stringUnit" : { - "state" : "translated", - "value" : "kan geen bericht sturen naar %@ – peer niet bereikbaar via mesh of nostr." + "state" : "needs_review", + "value" : "verzonden via mesh-gateway" } }, "pl" : { "stringUnit" : { - "state" : "translated", - "value" : "nie można wysłać do %@ – peer nieosiągalny przez mesh ani nostr." + "state" : "needs_review", + "value" : "wysłano przez bramę mesh" } }, "pt" : { "stringUnit" : { - "state" : "translated", - "value" : "não é possível enviar mensagem a %@ - o par não está acessível por mesh ou Nostr." + "state" : "needs_review", + "value" : "enviado através do gateway mesh" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", - "value" : "não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr." + "state" : "needs_review", + "value" : "enviado pelo gateway do mesh" } }, "ru" : { "stringUnit" : { - "state" : "translated", - "value" : "нельзя отправить %@: адресат недоступен через mesh или nostr." + "state" : "needs_review", + "value" : "отправлено через mesh-шлюз" } }, "sv" : { "stringUnit" : { - "state" : "translated", - "value" : "kan inte skicka till %@ – peer nås inte via mesh eller nostr." + "state" : "needs_review", + "value" : "skickat via mesh-gateway" } }, "ta" : { "stringUnit" : { - "state" : "translated", - "value" : "%@ க்கு செய்தி அனுப்ப முடியவில்லை - peer mesh அல்லது nostr மூலமாக அணுக முடியவில்லை." + "state" : "needs_review", + "value" : "mesh நுழைவாயில் வழியாக அனுப்பப்பட்டது" } }, "th" : { "stringUnit" : { - "state" : "translated", - "value" : "ไม่สามารถส่งข้อความถึง %@ - ติดต่อเพียร์ผ่าน mesh หรือ nostr ไม่ได้" + "state" : "needs_review", + "value" : "ส่งผ่านเกตเวย์ mesh" } }, "tr" : { "stringUnit" : { - "state" : "translated", - "value" : "%@'a mesaj gönderilemiyor - eş mesh veya Nostr üzerinden ulaşılamıyor." + "state" : "needs_review", + "value" : "mesh ağ geçidi üzerinden gönderildi" } }, "uk" : { "stringUnit" : { - "state" : "translated", - "value" : "неможливо надіслати %@: одержувач недосяжний через mesh або nostr." + "state" : "needs_review", + "value" : "надіслано через шлюз mesh" } }, "ur" : { "stringUnit" : { - "state" : "translated", - "value" : "%@ کو پیغام نہیں بھیج سکتے - peer mesh یا nostr کے ذریعے دستیاب نہیں۔" + "state" : "needs_review", + "value" : "mesh گیٹ وے کے ذریعے بھیجا گیا" } }, "vi" : { "stringUnit" : { - "state" : "translated", - "value" : "không thể gửi tin cho %@ - nút không thể liên lạc qua mesh hoặc nostr." + "state" : "needs_review", + "value" : "đã gửi qua cổng mesh" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "无法向 %@ 发送:对方无法通过 mesh 或 Nostr 到达。" + "state" : "needs_review", + "value" : "已通过 mesh 网关发送" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "無法向 %@ 發送:對方無法透過 mesh 或 Nostr 到達。" + "state" : "needs_review", + "value" : "已透過 mesh 閘道發送" } } } @@ -33196,6 +59890,4839 @@ } } }, + "system.group.already_member" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ عضو بالفعل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ইতিমধ্যে একজন সদস্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ist bereits mitglied" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already a member" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ya es miembro" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "miyembro na si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ est déjà membre" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ כבר חבר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ पहले से ही सदस्य है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ sudah menjadi anggota" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ è già un membro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ はすでにメンバーです" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님은 이미 멤버입니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ sudah menjadi ahli" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ पहिले नै सदस्य हो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is al lid" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ jest już członkiem" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ já é membro" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ já é membro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ уже участник" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ är redan medlem" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ஏற்கனவே ஒரு உறுப்பினர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ เป็นสมาชิกอยู่แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ zaten üye" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ уже учасник" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ پہلے ہی رکن ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ đã là thành viên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 已是成员" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 已是成員" + } + } + } + }, + "system.group.cannot_remove_creator" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا يمكن إزالة المُنشئ" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নির্মাতাকে সরানো যায় না" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "der ersteller kann nicht entfernt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "the creator cannot be removed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede eliminar al creador" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi maaaring alisin ang tagalikha" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "le créateur ne peut pas être retiré" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אי אפשר להסיר את היוצר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निर्माता को हटाया नहीं जा सकता" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pembuat tidak bisa dihapus" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "il creatore non può essere rimosso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "作成者は削除できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "생성자는 제거할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pencipta tidak boleh dibuang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सिर्जनाकर्तालाई हटाउन सकिँदैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "de maker kan niet worden verwijderd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można usunąć twórcy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "o criador não pode ser removido" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "o criador não pode ser removido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создателя нельзя удалить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skaparen kan inte tas bort" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உருவாக்கியவரை நீக்க முடியாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถลบผู้สร้างได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "oluşturan kişi kaldırılamaz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "створювача не можна видалити" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بنانے والے کو ہٹایا نہیں جا سکتا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể xóa người tạo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "创建者无法被移除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法移除建立者" + } + } + } + }, + "system.group.create_failed" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر إنشاء المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ তৈরি করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die gruppe konnte nicht erstellt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not create the group" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo crear el grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi malikha ang grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de créer le groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן ליצור את הקבוצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह नहीं बनाया जा सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa membuat grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile creare il gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループを作成できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹을 생성할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat mencipta kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह सिर्जना गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon de groep niet aanmaken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się utworzyć grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível criar o grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível criar o grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось создать группу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte skapa gruppen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுவை உருவாக்க முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถสร้างกลุ่มได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup oluşturulamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося створити групу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ نہیں بنایا جا سکا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể tạo nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法创建群组" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法建立群組" + } + } + } + }, + "system.group.created" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أُنشئت المجموعة '%@' — استخدم /group invite @name لإضافة أشخاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' গ্রুপ তৈরি হয়েছে — মানুষ যোগ করতে /group invite @name ব্যবহার করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppe '%@' erstellt — nutze /group invite @name, um leute hinzuzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "created group '%@' — use /group invite @name to add people" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupo '%@' creado — usa /group invite @name para añadir personas" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nalikha ang grupong '%@' — gamitin ang /group invite @name para magdagdag ng tao" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupe '%@' créé — utilise /group invite @name pour ajouter des personnes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נוצרה הקבוצה '%@' — השתמש ב-/group invite @name כדי להוסיף אנשים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' बनाया गया — लोगों को जोड़ने के लिए /group invite @name का उपयोग करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup '%@' dibuat — gunakan /group invite @name untuk menambah orang" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppo '%@' creato — usa /group invite @name per aggiungere persone" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ '%@' を作成しました — /group invite @name で人を追加できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 '%@' 을(를) 생성했습니다 — /group invite @name 으로 사람을 추가하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan '%@' dicipta — guna /group invite @name untuk menambah orang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' सिर्जना भयो — मानिस थप्न /group invite @name प्रयोग गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groep '%@' aangemaakt — gebruik /group invite @name om mensen toe te voegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utworzono grupę '%@' — użyj /group invite @name, aby dodać osoby" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupo '%@' criado — usa /group invite @name para adicionar pessoas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupo '%@' criado — use /group invite @nome para adicionar pessoas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создана группа «%@» — используй /group invite @name, чтобы добавить людей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skapade grupp '%@' — använd /group invite @name för att lägga till personer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' குழு உருவாக்கப்பட்டது — நபர்களைச் சேர்க்க /group invite @name ஐப் பயன்படுத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สร้างกลุ่ม '%@' แล้ว — ใช้ /group invite @name เพื่อเพิ่มคน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' grubu oluşturuldu — kişi eklemek için /group invite @name kullanın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "створено групу '%@' — використай /group invite @name, щоб додати людей" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ '%@' بنایا گیا — لوگوں کو شامل کرنے کیلئے /group invite @name استعمال کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã tạo nhóm '%@' — dùng /group invite @name để thêm người" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已创建群组 '%@' — 使用 /group invite @name 添加成员" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已建立群組「%@」— 使用 /group invite @name 添加成員" + } + } + } + }, + "system.group.creator_only" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "منشئ المجموعة فقط يمكنه فعل ذلك" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "শুধু গ্রুপের নির্মাতা এটি করতে পারেন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nur der gruppenersteller kann das tun" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only the group creator can do that" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "solo el creador del grupo puede hacer eso" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tanging ang tagalikha ng grupo ang maaaring gumawa niyan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "seul le créateur du groupe peut faire cela" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "רק יוצר הקבוצה יכול לעשות זאת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "केवल समूह निर्माता ही ऐसा कर सकता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hanya pembuat grup yang bisa melakukan itu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "solo il creatore del gruppo può farlo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループの作成者のみが実行できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 생성자만 할 수 있습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hanya pencipta kumpulan boleh berbuat demikian" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह सिर्जनाकर्ताले मात्र त्यो गर्न सक्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "alleen de maker van de groep kan dat doen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tylko twórca grupy może to zrobić" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "só o criador do grupo pode fazer isso" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "só o criador do grupo pode fazer isso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "это может сделать только создатель группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "endast gruppens skapare kan göra det" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுவை உருவாக்கியவர் மட்டுமே அதைச் செய்ய முடியும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เฉพาะผู้สร้างกลุ่มเท่านั้นที่ทำได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bunu yalnızca grubu oluşturan yapabilir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "це може лише створювач групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "صرف گروپ بنانے والا یہ کر سکتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chỉ người tạo nhóm mới có thể làm điều đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "只有群组创建者才能执行该操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "只有群組建立者可以這麼做" + } + } + } + }, + "system.group.full" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "المجموعة ممتلئة (الحد الأقصى %@ عضو)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ পূর্ণ (সর্বোচ্চ %@ সদস্য)" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppe ist voll (max. %@ mitglieder)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group is full (max %@ members)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "el grupo está lleno (máx. %@ miembros)" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "puno na ang grupo (max %@ miyembro)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "le groupe est plein (max %@ membres)" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקבוצה מלאה (מקסימום %@ חברים)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह भरा हुआ है (अधिकतम %@ सदस्य)" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup penuh (maks %@ anggota)" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "il gruppo è pieno (max %@ membri)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループが満員です(最大 %@ 人)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹이 가득 찼습니다 (최대 %@명)" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan penuh (maks %@ ahli)" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह भरिएको छ (बढीमा %@ सदस्य)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groep is vol (max %@ leden)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupa jest pełna (maks. %@ członków)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "o grupo está cheio (máx. %@ membros)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "o grupo está cheio (máx. %@ membros)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "группа заполнена (макс. %@ участников)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppen är full (max %@ medlemmar)" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு நிரம்பிவிட்டது (அதிகபட்சம் %@ உறுப்பினர்கள்)" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่มเต็มแล้ว (สูงสุด %@ คน)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup dolu (en fazla %@ üye)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "група заповнена (макс. %@ учасників)" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ بھر گیا ہے (زیادہ سے زیادہ %@ اراکین)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm đã đầy (tối đa %@ thành viên)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群组已满(最多 %@ 名成员)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群組已滿(最多 %@ 位成員)" + } + } + } + }, + "system.group.identity_unavailable" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مفاتيح هويتك ليست جاهزة بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনার পরিচয় কী এখনো প্রস্তুত নয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deine identitätsschlüssel sind noch nicht bereit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your identity keys are not ready yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tus claves de identidad aún no están listas" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi pa handa ang iyong mga identity key" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tes clés d'identité ne sont pas encore prêtes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מפתחות הזהות שלך עדיין לא מוכנים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपकी पहचान कुंजियाँ अभी तैयार नहीं हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunci identitasmu belum siap" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "le tue chiavi di identità non sono ancora pronte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "身元確認用のキーがまだ準備できていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "신원 키가 아직 준비되지 않았습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunci identitimu belum sedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिम्रा पहिचान कुञ्जीहरू अझै तयार छैनन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je identiteitssleutels zijn nog niet klaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "twoje klucze tożsamości nie są jeszcze gotowe" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "as tuas chaves de identidade ainda não estão prontas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "suas chaves de identidade ainda não estão prontas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твои ключи личности ещё не готовы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dina identitetsnycklar är inte redo än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உங்கள் அடையாள விசைகள் இன்னும் தயாராகவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คีย์ยืนยันตัวตนของคุณยังไม่พร้อม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kimlik anahtarların henüz hazır değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твої ключі особи ще не готові" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کی شناختی کلیدیں ابھی تیار نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "khóa danh tính của bạn chưa sẵn sàng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你的身份密钥尚未就绪" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你的身份金鑰尚未就緒" + } + } + } + }, + "system.group.invite_failed" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر إنشاء دعوة المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ আমন্ত্রণ তৈরি করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die gruppeneinladung konnte nicht erstellt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not build the group invite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo generar la invitación al grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi mabuo ang imbitasyon sa grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de créer l'invitation au groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לבנות את הזמנת הקבוצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह आमंत्रण नहीं बनाया जा सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa membuat undangan grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile creare l'invito al gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ招待を作成できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 초대를 만들 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat membina jemputan kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह निमन्त्रणा बनाउन सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon de groepsuitnodiging niet aanmaken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się utworzyć zaproszenia do grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível criar o convite do grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível montar o convite do grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось создать приглашение в группу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte skapa gruppinbjudan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு அழைப்பை உருவாக்க முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถสร้างคำเชิญกลุ่มได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup daveti oluşturulamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося створити запрошення до групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ کی دعوت نہیں بنائی جا سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể tạo lời mời nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法生成群组邀请" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法建立群組邀請" + } + } + } + }, + "system.group.invited" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تمت دعوة %1$@ إلى '%2$@'" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@-কে '%2$@'-এ আমন্ত্রণ জানানো হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ zu '%2$@' eingeladen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invited %1$@ to '%2$@'" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "invitaste a %1$@ a '%2$@'" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inimbitahan si %1$@ sa '%2$@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ invité(e) dans '%2$@'" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ הוזמן ל-'%2$@'" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ को '%2$@' में आमंत्रित किया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mengundang %1$@ ke '%2$@'" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ invitato in '%2$@'" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ を '%2$@' に招待しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ 님을 '%2$@' 에 초대했습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menjemput %1$@ ke '%2$@'" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ लाई '%2$@' मा निमन्त्रणा गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ uitgenodigd voor '%2$@'" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zaproszono %1$@ do '%2$@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ convidado para '%2$@'" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ foi convidado para '%2$@'" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ приглашён в «%2$@»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bjöd in %1$@ till '%2$@'" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ ஐ '%2$@' க்கு அழைத்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เชิญ %1$@ เข้า '%2$@' แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ '%2$@' grubuna davet edildi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "запрошено %1$@ до '%2$@'" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ کو '%2$@' میں مدعو کیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã mời %1$@ vào '%2$@'" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已邀请 %1$@ 加入 '%2$@'" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已邀請 %1$@ 加入「%2$@」" + } + } + } + }, + "system.group.joined" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أضافك %2$@ إلى المجموعة '%1$@' — تظهر الآن في قائمة الأشخاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ আপনাকে '%1$@' গ্রুপে যুক্ত করেছেন — এটি এখন আপনার মানুষের তালিকায় দেখা যাবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du wurdest von %2$@ zur gruppe '%1$@' hinzugefügt — sie erscheint jetzt in deiner personenliste" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ te añadió al grupo '%1$@' — ahora aparece en tu lista de personas" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "idinagdag ka ni %2$@ sa grupong '%1$@' — lalabas na ito sa iyong listahan ng mga tao" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu as été ajouté(e) au groupe '%1$@' par %2$@ — il apparaît maintenant dans ta liste de personnes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ הוסיף אותך לקבוצה '%1$@' — היא מופיעה כעת ברשימת האנשים שלך" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपको %2$@ द्वारा समूह '%1$@' में जोड़ा गया — यह अब आपकी लोगों की सूची में दिखाई देता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu ditambahkan ke grup '%1$@' oleh %2$@ — kini muncul di daftar orangmu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei stato aggiunto al gruppo '%1$@' da %2$@ — ora appare nella tua lista di persone" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ によってグループ '%1$@' に追加されました — ピープルリストに表示されます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ 님이 그룹 '%1$@' 에 추가했습니다 — 이제 피플 목록에 표시됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda ditambah ke kumpulan '%1$@' oleh %2$@ — kini ia muncul dalam senarai orangmu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ ले तिमीलाई समूह '%1$@' मा थप्यो — अब यो तिम्रो मानिस सूचीमा देखिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent door %2$@ toegevoegd aan groep '%1$@' — die verschijnt nu in je personenlijst" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ dodał cię do grupy '%1$@' — pojawia się teraz na liście osób" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foste adicionado ao grupo '%1$@' por %2$@ — agora aparece na tua lista de pessoas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você foi adicionado ao grupo '%1$@' por %2$@ — ele agora aparece na sua lista de pessoas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ добавил тебя в группу «%1$@» — теперь она в твоём списке людей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ lade till dig i gruppen '%1$@' — den visas nu i din personlista" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ உங்களை '%1$@' குழுவில் சேர்த்தார் — அது இப்போது உங்கள் நபர்கள் பட்டியலில் தோன்றுகிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ เพิ่มคุณเข้ากลุ่ม '%1$@' — ตอนนี้จะปรากฏในรายชื่อคนของคุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ seni '%1$@' grubuna ekledi — artık kişi listende görünüyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ додав тебе до групи '%1$@' — вона тепер у твоєму списку людей" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ نے آپ کو گروپ '%1$@' میں شامل کیا — یہ اب آپ کی لوگوں کی فہرست میں ظاہر ہوتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đã được %2$@ thêm vào nhóm '%1$@' — nó giờ xuất hiện trong danh sách người của bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ 已将你加入群组 '%1$@' — 现在它会显示在你的成员列表中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ 將你加入了群組「%1$@」— 現在會顯示在你的成員列表中" + } + } + } + }, + "system.group.left" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "غادرت المجموعة '%@'" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' গ্রুপ ছেড়ে দিয়েছেন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppe '%@' verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "left group '%@'" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "saliste del grupo '%@'" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "umalis sa grupong '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupe '%@' quitté" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "עזבת את הקבוצה '%@'" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' छोड़ दिया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "keluar dari grup '%@'" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uscito dal gruppo '%@'" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ '%@' から退出しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 '%@' 에서 나갔습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "keluar dari kumpulan '%@'" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' छोडियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groep '%@' verlaten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opuszczono grupę '%@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "saíste do grupo '%@'" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você saiu do grupo '%@'" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты покинул группу «%@»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lämnade gruppen '%@'" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' குழுவிலிருந்து வெளியேறியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ออกจากกลุ่ม '%@' แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' grubundan ayrıldın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "залишено групу '%@'" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ '%@' چھوڑ دیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã rời nhóm '%@'" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已退出群组 '%@'" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已離開群組「%@」" + } + } + } + }, + "system.group.list_header" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مجموعاتك:" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনার গ্রুপসমূহ:" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deine gruppen:" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your groups:" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tus grupos:" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mga grupo mo:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tes groupes :" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקבוצות שלך:" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके समूह:" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupmu:" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i tuoi gruppi:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "あなたのグループ:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "내 그룹:" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulanmu:" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिम्रा समूहहरू:" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je groepen:" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "twoje grupy:" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "os teus grupos:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "seus grupos:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твои группы:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dina grupper:" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உங்கள் குழுக்கள்:" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่มของคุณ:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupların:" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твої групи:" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کے گروپس:" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm của bạn:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你的群组:" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你的群組:" + } + } + } + }, + "system.group.member_not_found" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' ليس عضوًا في هذه المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' এই গ্রুপের সদস্য নন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' ist kein mitglied dieser gruppe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is not a member of this group" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' no es miembro de este grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi miyembro ng grupong ito si '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' n'est pas membre de ce groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' אינו חבר בקבוצה הזו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' इस समूह का सदस्य नहीं है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bukan anggota grup ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' non è un membro di questo gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' はこのグループのメンバーではありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' 님은 이 그룹의 멤버가 아닙니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bukan ahli kumpulan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' यो समूहको सदस्य होइन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' is geen lid van deze groep" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' nie jest członkiem tej grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não é membro deste grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não é membro deste grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "«%@» не является участником этой группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' är inte medlem i den här gruppen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' இந்தக் குழுவின் உறுப்பினர் அல்ல" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' ไม่ใช่สมาชิกของกลุ่มนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bu grubun üyesi değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' не учасник цієї групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' اس گروپ کا رکن نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' không phải là thành viên của nhóm này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' 不是此群组的成员" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "「%@」不是此群組的成員" + } + } + } + }, + "system.group.name_too_long" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أسماء المجموعات محدودة بـ 40 حرفًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপের নাম ৪০ অক্ষরে সীমাবদ্ধ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppennamen sind auf 40 zeichen begrenzt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group names are limited to 40 characters" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "los nombres de grupo están limitados a 40 caracteres" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hanggang 40 character lamang ang pangalan ng grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "les noms de groupe sont limités à 40 caractères" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שמות קבוצות מוגבלים ל-40 תווים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह के नाम 40 अक्षरों तक सीमित हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nama grup dibatasi 40 karakter" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i nomi dei gruppi sono limitati a 40 caratteri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ名は40文字までです" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 이름은 40자로 제한됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nama kumpulan terhad kepada 40 aksara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह नाम ४० अक्षरमा सीमित छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groepsnamen zijn beperkt tot 40 tekens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nazwy grup są ograniczone do 40 znaków" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "os nomes de grupo estão limitados a 40 caracteres" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nomes de grupo são limitados a 40 caracteres" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "имена групп ограничены 40 символами" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppnamn är begränsade till 40 tecken" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுப் பெயர்கள் 40 எழுத்துகளுக்கு வரம்பிடப்பட்டுள்ளன" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ชื่อกลุ่มจำกัดไม่เกิน 40 ตัวอักษร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup adları en fazla 40 karakter olabilir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "назви груп обмежені 40 символами" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ کے نام 40 حروف تک محدود ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tên nhóm giới hạn 40 ký tự" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群组名最多 40 个字符" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群組名稱最多 40 個字元" + } + } + } + }, + "system.group.none" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لست في أي مجموعة — /group create لبدء واحدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি কোনো গ্রুপে নেই — একটি শুরু করতে /group create " + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist in keiner gruppe — /group create , um eine zu starten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are not in any groups — /group create to start one" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no estás en ningún grupo — /group create para crear uno" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wala ka sa anumang grupo — /group create para magsimula" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu n'es dans aucun groupe — /group create pour en démarrer un" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה לא באף קבוצה — /group create כדי להתחיל אחת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप किसी समूह में नहीं हैं — शुरू करने के लिए /group create " + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu tidak ada di grup mana pun — /group create untuk memulai" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non sei in nessun gruppo — /group create per crearne uno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "どのグループにも参加していません — /group create で作成できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "참여 중인 그룹이 없습니다 — /group create 으로 시작하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda tiada dalam mana-mana kumpulan — /group create untuk memulakan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी कुनै समूहमा छैनौ — सुरु गर्न /group create " + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je zit in geen enkele groep — /group create om er een te starten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie należysz do żadnej grupy — /group create , aby utworzyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não estás em nenhum grupo — /group create para começar um" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você não está em nenhum grupo — /group create para começar um" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты не состоишь ни в одной группе — /group create , чтобы создать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är inte med i någon grupp — /group create för att skapa en" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் எந்தக் குழுவிலும் இல்லை — ஒன்றைத் தொடங்க /group create " + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณยังไม่ได้อยู่ในกลุ่มใด — /group create เพื่อเริ่มกลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiçbir grupta değilsin — bir grup oluşturmak için /group create " + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти не в жодній групі — /group create , щоб створити" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کسی گروپ میں نہیں ہیں — ایک شروع کرنے کیلئے /group create " + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn không ở trong nhóm nào — /group create để tạo một nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你不在任何群组中 — 使用 /group create 创建一个" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你不在任何群組中 — 使用 /group create 建立一個" + } + } + } + }, + "system.group.not_in_group" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "افتح دردشة جماعية أولاً" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রথমে একটি গ্রুপ চ্যাট খুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffne zuerst einen gruppenchat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open a group chat first" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre primero un chat de grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "magbukas muna ng group chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre d'abord une discussion de groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פתח קודם צ'אט קבוצתי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहले कोई समूह चैट खोलें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka obrolan grup dulu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apri prima una chat di gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "先にグループチャットを開いてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "먼저 그룹 채팅을 여세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka sembang kumpulan dahulu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहिले समूह च्याट खोल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "open eerst een groepschat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "najpierw otwórz czat grupowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre primeiro uma conversa de grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abra um chat de grupo primeiro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сначала открой групповой чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppna en gruppchatt först" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "முதலில் ஒரு குழு உரையாடலைத் திறக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแชทกลุ่มก่อน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "önce bir grup sohbeti aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "спочатку відкрий груповий чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پہلے کوئی گروپ چیٹ کھولیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở một cuộc trò chuyện nhóm trước" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "请先打开一个群聊" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "請先打開一個群組聊天" + } + } + } + }, + "system.group.peer_identity_unknown" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا يمكن التحقق من هوية %@ بعد — انتظر إعلانه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-এর পরিচয় এখনো যাচাই করা যাচ্ছে না — তাদের অ্যানাউন্সের জন্য অপেক্ষা করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@s identität kann noch nicht verifiziert werden — warte auf deren announce" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot verify %@'s identity yet — wait for their announce" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no se puede verificar la identidad de %@ — espera su anuncio" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi pa ma-beripika ang pagkakakilanlan ni %@ — hintayin ang kanilang announce" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de vérifier l'identité de %@ pour l'instant — attends son announce" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לאמת את הזהות של %@ עדיין — המתן להכרזה שלו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ की पहचान अभी सत्यापित नहीं की जा सकती — उनके अनाउंस की प्रतीक्षा करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum bisa memverifikasi identitas %@ — tunggu announce mereka" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile verificare ancora l'identità di %@ — attendi il suo announce" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ の身元をまだ確認できません — announceを待ってください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님의 신원을 아직 확인할 수 없습니다 — announce를 기다리세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum boleh mengesahkan identiti %@ — tunggu announce mereka" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को पहिचान अझै प्रमाणित गर्न सकिँदैन — उनको घोषणा पर्ख" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan de identiteit van %@ nog niet verifiëren — wacht op hun announce" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można jeszcze zweryfikować tożsamości %@ — poczekaj na ich ogłoszenie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ainda não é possível verificar a identidade de %@ — aguarda o announce dele" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ainda não é possível verificar a identidade de %@ — aguarde o anúncio dessa pessoa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "пока нельзя проверить личность %@ — дождись его анонса" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan inte verifiera %@:s identitet än — vänta på deras announce" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ இன் அடையாளத்தை இன்னும் சரிபார்க்க முடியவில்லை — அவர்களின் அறிவிப்புக்காகக் காத்திருக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่สามารถยืนยันตัวตนของ %@ — รอ announce ของพวกเขา" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ kimliği henüz doğrulanamıyor — duyurusunu bekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поки не можна підтвердити особу %@ — почекай на їхнє оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کی شناخت ابھی تصدیق نہیں ہو سکتی — ان کے اعلان کا انتظار کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa thể xác minh danh tính của %@ — chờ announce của họ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暂时无法验证 %@ 的身份 — 请等待对方的广播" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "尚無法驗證 %@ 的身份 — 請等待對方的宣告" + } + } + } + }, + "system.group.peer_not_connected" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يجب أن يكون %@ متصلاً عبر mesh لدعوته" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আমন্ত্রণ জানাতে %@-কে মেশে সংযুক্ত থাকতে হবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ muss über mesh verbunden sein, um eingeladen zu werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ must be connected over mesh to be invited" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ debe estar conectado por mesh para ser invitado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kailangang nakakonekta sa mesh si %@ para maimbitahan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ doit être connecté en mesh pour être invité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ חייב להיות מחובר דרך mesh כדי להזמינו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को आमंत्रित करने के लिए मेश पर जुड़ा होना ज़रूरी है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ harus terhubung lewat mesh untuk diundang" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ deve essere connesso via mesh per essere invitato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ は招待するためにmeshで接続している必要があります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 초대하려면 mesh로 연결되어 있어야 합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ mesti bersambung melalui mesh untuk dijemput" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निमन्त्रणा गर्न %@ mesh मार्फत जडान भएको हुनुपर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ moet via mesh verbonden zijn om uitgenodigd te worden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ musi być połączony przez mesh, aby otrzymać zaproszenie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ tem de estar ligado por mesh para ser convidado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ precisa estar conectado pelo mesh para ser convidado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ должен быть подключён по mesh, чтобы получить приглашение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ måste vara ansluten via mesh för att bjudas in" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ அழைக்கப்படுவதற்கு mesh மூலம் இணைக்கப்பட்டிருக்க வேண்டும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ต้องเชื่อมต่อผ่าน mesh จึงจะเชิญได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "davet edilebilmesi için %@ mesh üzerinden bağlı olmalı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ має бути з'єднаний через mesh, щоб отримати запрошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دعوت دینے کیلئے %@ کا mesh پر جڑا ہونا ضروری ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ phải được kết nối qua mesh để được mời" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 必须通过 mesh 连接才能被邀请" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 必須透過 mesh 連線才能被邀請" + } + } + } + }, + "system.group.peer_not_found" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' غير موجود" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' nicht gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' not found" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' no encontrado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi nahanap si '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' introuvable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' לא נמצא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' tidak ditemukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' non trovato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' が見つかりません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' 을(를) 찾을 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' tidak dijumpai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' भेटिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' niet gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie znaleziono '%@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não encontrado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "«%@» не найден" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' hittades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' கண்டறியப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่พบ '%@'" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' не знайдено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không tìm thấy '%@'" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未找到 '%@'" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "找不到「%@」" + } + } + } + }, + "system.group.removed_from" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تمت إزالتك من المجموعة '%@'" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনাকে '%@' গ্রুপ থেকে সরানো হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du wurdest aus der gruppe '%@' entfernt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were removed from group '%@'" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fuiste eliminado del grupo '%@'" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inalis ka sa grupong '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu as été retiré(e) du groupe '%@'" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הוסרת מהקבוצה '%@'" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपको समूह '%@' से हटा दिया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu dikeluarkan dari grup '%@'" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei stato rimosso dal gruppo '%@'" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ '%@' から削除されました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 '%@' 에서 제거되었습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda dikeluarkan dari kumpulan '%@'" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीलाई समूह '%@' बाट हटाइयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent verwijderd uit groep '%@'" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usunięto cię z grupy '%@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foste removido do grupo '%@'" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você foi removido do grupo '%@'" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "тебя удалили из группы «%@»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du togs bort från gruppen '%@'" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' குழுவிலிருந்து நீங்கள் நீக்கப்பட்டீர்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณถูกนำออกจากกลุ่ม '%@'" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' grubundan çıkarıldın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "тебе видалено з групи '%@'" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کو گروپ '%@' سے ہٹا دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đã bị xóa khỏi nhóm '%@'" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你已被移出群组 '%@'" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你已被移出群組「%@」" + } + } + } + }, + "system.group.removed_member" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تمت إزالة %@ وتدوير مفتاح المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে সরানো হয়েছে এবং গ্রুপ কী রোটেট করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ entfernt und den gruppenschlüssel rotiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "removed %@ and rotated the group key" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se eliminó a %@ y se rotó la clave del grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inalis si %@ at ni-rotate ang group key" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ retiré(e) et clé du groupe renouvelée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ הוסר ומפתח הקבוצה סובב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को हटाया और समूह कुंजी घुमाई" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menghapus %@ dan memutar ulang kunci grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ rimosso e chiave del gruppo ruotata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ を削除しグループキーをローテーションしました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 제거하고 그룹 키를 교체했습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuang %@ dan memutar kunci kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई हटाइयो र समूह कुञ्जी घुमाइयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ verwijderd en de groepssleutel geroteerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usunięto %@ i wymieniono klucz grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ removido e chave do grupo rodada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ foi removido e a chave do grupo foi rotacionada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ удалён, ключ группы обновлён" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tog bort %@ och roterade gruppnyckeln" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ஐ நீக்கி குழு விசையைச் சுழற்றியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "นำ %@ ออกและเปลี่ยนคีย์กลุ่มแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ çıkarıldı ve grup anahtarı döndürüldü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалено %@ і замінено ключ групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو ہٹا دیا اور گروپ کی کلید تبدیل کر دی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã xóa %@ và xoay khóa nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已移除 %@ 并轮换了群组密钥" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已移除 %@ 並輪換了群組金鑰" + } + } + } + }, + "system.group.rotate_failed" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تدوير مفتاح المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ কী রোটেট করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "der gruppenschlüssel konnte nicht rotiert werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not rotate the group key" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo rotar la clave del grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi ma-rotate ang group key" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de renouveler la clé du groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לסובב את מפתח הקבוצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह कुंजी नहीं घुमाई जा सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa memutar ulang kunci grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile ruotare la chiave del gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループキーをローテーションできませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 키를 교체할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat memutar kunci kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह कुञ्जी घुमाउन सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon de groepssleutel niet roteren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się wymienić klucza grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível rodar a chave do grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível rotacionar a chave do grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось обновить ключ группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte rotera gruppnyckeln" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு விசையைச் சுழற்ற முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถเปลี่ยนคีย์กลุ่มได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup anahtarı döndürülemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося замінити ключ групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ کی کلید تبدیل نہیں کی جا سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể xoay khóa nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法轮换群组密钥" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法輪換群組金鑰" + } + } + } + }, + "system.group.send_failed" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تشفير الرسالة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "বার্তা এনক্রিপ্ট করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die nachricht konnte nicht verschlüsselt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not encrypt the message" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo cifrar el mensaje" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi ma-encrypt ang mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de chiffrer le message" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן להצפין את ההודעה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश एन्क्रिप्ट नहीं किया जा सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa mengenkripsi pesan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile cifrare il messaggio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "メッセージを暗号化できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "메시지를 암호화할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat mengenkripsi pesan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सन्देश सङ्केत गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon het bericht niet versleutelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się zaszyfrować wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível encriptar a mensagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível criptografar a mensagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось зашифровать сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte kryptera meddelandet" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "செய்தியைக் குறியாக்கம் செய்ய முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถเข้ารหัสข้อความได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj şifrelenemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося зашифрувати повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام کو خفیہ نہیں کیا جا سکا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể mã hóa tin nhắn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法加密该消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法加密訊息" + } + } + } + }, + "system.group.unknown" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لم تعد في هذه المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি আর এই গ্রুপে নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist nicht mehr in dieser gruppe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are no longer in this group" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ya no estás en este grupo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wala ka na sa grupong ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu n'es plus dans ce groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה כבר לא בקבוצה הזו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप अब इस समूह में नहीं हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu tidak lagi ada di grup ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non sei più in questo gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このグループには参加していません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "더 이상 이 그룹에 속해 있지 않습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda tidak lagi dalam kumpulan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी अब यो समूहमा छैनौ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je zit niet meer in deze groep" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie należysz już do tej grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "já não estás neste grupo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você não está mais neste grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты больше не в этой группе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är inte längre med i den här gruppen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் இனி இந்தக் குழுவில் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณไม่ได้อยู่ในกลุ่มนี้แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "artık bu grupta değilsin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти більше не в цій групі" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ اب اس گروپ میں نہیں ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn không còn ở trong nhóm này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你已不在此群组中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你已不在此群組中" + } + } + } + }, + "system.group.usage_create" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاستخدام: /group create " + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যবহার: /group create " + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verwendung: /group create " + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group create " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group create " + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paggamit: /group create " + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilisation : /group create " + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שימוש: /group create " + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपयोग: /group create " + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group create " + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group create " + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使い方: /group create " + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용법: /group create " + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group create " + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रयोग: /group create " + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gebruik: /group create " + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "użycie: /group create " + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilização: /group create " + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group create " + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "использование: /group create " + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "användning: /group create " + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பயன்பாடு: /group create " + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วิธีใช้: /group create " + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanım: /group create " + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "використання: /group create " + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "استعمال: /group create " + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cách dùng: /group create " + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用法:/group create " + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用法:/group create " + } + } + } + }, + "system.group.usage_invite" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاستخدام: /group invite @name" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যবহার: /group invite @name" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verwendung: /group invite @name" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group invite @name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group invite @name" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paggamit: /group invite @name" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilisation : /group invite @name" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שימוש: /group invite @name" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपयोग: /group invite @name" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group invite @name" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group invite @name" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使い方: /group invite @name" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용법: /group invite @name" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group invite @name" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रयोग: /group invite @name" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gebruik: /group invite @name" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "użycie: /group invite @name" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilização: /group invite @name" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group invite @nome" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "использование: /group invite @name" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "användning: /group invite @name" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பயன்பாடு: /group invite @name" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วิธีใช้: /group invite @name" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanım: /group invite @name" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "використання: /group invite @name" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "استعمال: /group invite @name" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cách dùng: /group invite @name" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用法:/group invite @name" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用法:/group invite @name" + } + } + } + }, + "system.group.usage_remove" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاستخدام: /group remove @name" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যবহার: /group remove @name" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verwendung: /group remove @name" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group remove @name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group remove @name" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paggamit: /group remove @name" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilisation : /group remove @name" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שימוש: /group remove @name" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपयोग: /group remove @name" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group remove @name" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group remove @name" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使い方: /group remove @name" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용법: /group remove @name" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group remove @name" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रयोग: /group remove @name" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gebruik: /group remove @name" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "użycie: /group remove @name" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilização: /group remove @name" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group remove @nome" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "использование: /group remove @name" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "användning: /group remove @name" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பயன்பாடு: /group remove @name" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วิธีใช้: /group remove @name" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanım: /group remove @name" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "використання: /group remove @name" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "استعمال: /group remove @name" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cách dùng: /group remove @name" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用法:/group remove @name" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用法:/group remove @name" + } + } + } + }, "system.location.not_in_channel" : { "extractionState" : "manual", "localizations" : { @@ -33554,6 +65081,726 @@ } } }, + "system.mesh.block_failed" : { + "comment" : "System message shown when a mesh peer cannot be blocked", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر حظر %@: غير موجود أو تعذّر التحقق من الهوية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে ব্লক করা যায় না: পাওয়া যায়নি বা পরিচয় যাচাই করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ kann nicht blockiert werden: nicht gefunden oder identität nicht verifizierbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot block %@: not found or unable to verify identity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede bloquear a %@: no encontrado o no se pudo verificar la identidad" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi ma-block si %@: hindi nahanap o hindi ma-beripika ang pagkakakilanlan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de bloquer %@ : introuvable ou identité invérifiable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לחסום את %@: לא נמצא או שלא ניתן לאמת זהות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को ब्लॉक नहीं कर सकते: नहीं मिला या पहचान सत्यापित नहीं हो सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa memblokir %@: tidak ditemukan atau tidak bisa memverifikasi identitas" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile bloccare %@: non trovato o identità non verificabile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ をブロックできません: 見つからないか身元を確認できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 차단할 수 없습니다: 찾을 수 없거나 신원을 확인할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat memblokir %@: tidak dijumpai atau tidak dapat mengesahkan identiti" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई ब्लक गर्न सकिएन: भेटिएन वा पहिचान प्रमाणित गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan %@ niet blokkeren: niet gevonden of identiteit niet te verifiëren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można zablokować %@: nie znaleziono lub nie można zweryfikować tożsamości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não é possível bloquear %@: não encontrado ou identidade não verificável" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não é possível bloquear %@: não encontrado ou identidade não verificável" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось заблокировать %@: не найден или нельзя проверить личность" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan inte blockera %@: hittades inte eller kan inte verifiera identitet" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ஐத் தடுக்க முடியாது: கண்டறியப்படவில்லை அல்லது அடையாளத்தைச் சரிபார்க்க முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถบล็อก %@: ไม่พบหรือไม่สามารถยืนยันตัวตน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engellenemiyor: bulunamadı veya kimlik doğrulanamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не можна заблокувати %@: не знайдено або не вдалося підтвердити особу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو بلاک نہیں کیا جا سکتا: نہیں ملا یا شناخت کی تصدیق ممکن نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể chặn %@: không tìm thấy hoặc không thể xác minh danh tính" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法屏蔽 %@:未找到或无法验证身份" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法屏蔽 %@:未找到或無法驗證身份" + } + } + } + }, + "system.mesh.blocked" : { + "comment" : "System message shown when a mesh peer is blocked", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تم حظر %@. لن تتلقى رسائل منه بعد الآن" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে ব্লক করা হয়েছে। আপনি আর তাদের বার্তা পাবেন না" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ blockiert. du erhältst keine nachrichten mehr von ihnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked %@. you will no longer receive messages from them" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se bloqueó a %@. ya no recibirás mensajes suyos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "na-block si %@. hindi ka na makakatanggap ng mensahe mula sa kanila" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ bloqué. tu ne recevras plus ses messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ נחסם. לא תקבל ממנו יותר הודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को ब्लॉक किया। अब आपको उनसे संदेश नहीं मिलेंगे" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "memblokir %@. kamu tidak akan menerima pesan dari mereka lagi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ bloccato. non riceverai più messaggi da questa persona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ をブロックしました。今後この相手からのメッセージは受信しません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 차단했습니다. 이제 이 사용자의 메시지를 받지 않습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "memblokir %@. anda tidak akan menerima pesan daripada mereka lagi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई ब्लक गरियो। अब उनीबाट सन्देश पाउने छैनौ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ geblokkeerd. je ontvangt geen berichten meer van deze persoon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zablokowano %@. nie będziesz już otrzymywać od nich wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ bloqueado. deixarás de receber mensagens desta pessoa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ foi bloqueado. você não vai mais receber mensagens dessa pessoa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ заблокирован. ты больше не будешь получать от него сообщения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockerade %@. du får inte längre meddelanden från dem" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ தடுக்கப்பட்டது. அவர்களிடமிருந்து இனி செய்திகளைப் பெறமாட்டீர்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "บล็อก %@ แล้ว คุณจะไม่ได้รับข้อความจากพวกเขาอีก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engellendi. artık ondan mesaj almayacaksın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблоковано %@. ти більше не отримуватимеш від них повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو بلاک کر دیا۔ اب آپ کو ان سے پیغامات نہیں ملیں گے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã chặn %@. bạn sẽ không còn nhận tin nhắn từ họ nữa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽 %@。你将不再收到对方的消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽 %@。你將不再收到對方的訊息" + } + } + } + }, + "system.mesh.unblock_failed" : { + "comment" : "System message shown when a mesh peer cannot be unblocked", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر إلغاء حظر %@: غير موجود" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে আনব্লক করা যায় না: পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ kann nicht entsperrt werden: nicht gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot unblock %@: not found" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede desbloquear a %@: no encontrado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi ma-unblock si %@: hindi nahanap" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de débloquer %@ : introuvable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לבטל חסימה של %@: לא נמצא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को अनब्लॉक नहीं कर सकते: नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa membuka blokir %@: tidak ditemukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile sbloccare %@: non trovato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ のブロックを解除できません: 見つかりません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님의 차단을 해제할 수 없습니다: 찾을 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat membuka blokir %@: tidak dijumpai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई अनब्लक गर्न सकिएन: भेटिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan %@ niet deblokkeren: niet gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można odblokować %@: nie znaleziono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não é possível desbloquear %@: não encontrado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não é possível desbloquear %@: não encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось разблокировать %@: не найден" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan inte avblockera %@: hittades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ இன் தடையை நீக்க முடியாது: கண்டறியப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถเลิกบล็อก %@: ไม่พบ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engeli kaldırılamıyor: bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не можна розблокувати %@: не знайдено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو ان بلاک نہیں کیا جا سکتا: نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể bỏ chặn %@: không tìm thấy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法取消屏蔽 %@:未找到" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法取消屏蔽 %@:未找到" + } + } + } + }, + "system.mesh.unblocked" : { + "comment" : "System message shown when a mesh peer is unblocked", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أُلغي حظر %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে আনব্লক করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ entsperrt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unblocked %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se desbloqueó a %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in-unblock si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ débloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "החסימה של %@ בוטלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को अनब्लॉक किया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka blokir %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ sbloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ のブロックを解除しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님의 차단을 해제했습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka blokir %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई अनब्लक गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ gedeblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odblokowano %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ desbloqueado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ foi desbloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ разблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "avblockerade %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ இன் தடை நீக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เลิกบล็อก %@ แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engeli kaldırıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "розблоковано %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو ان بلاک کر دیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã bỏ chặn %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已取消屏蔽 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已取消屏蔽 %@" + } + } + } + }, "system.tor.dev_bypass" : { "extractionState" : "manual", "localizations" : { @@ -34449,6 +66696,906 @@ } } }, + "topology.caption" : { + "comment" : "Caption under the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مُقدَّرة من قوائم الجيران المتناقلة (حتى 10 لكل قرين) — جهازك مميَّز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গসিপ করা প্রতিবেশী তালিকা থেকে অনুমান করা (প্রতি পিয়ারে সর্বোচ্চ ১০) — আপনার ডিভাইস হাইলাইট করা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geschätzt aus verbreiteten nachbarlisten (bis zu 10 pro peer) — dein gerät ist hervorgehoben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimado a partir de listas de vecinos difundidas (hasta 10 por peer) — tu dispositivo está resaltado" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tinantiya mula sa mga ibinahaging listahan ng kapitbahay (hanggang 10 bawat peer) — naka-highlight ang iyong device" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimé à partir des listes de voisins diffusées (jusqu'à 10 par pair) — ton appareil est mis en évidence" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מוערך מרשימות שכנים שהופצו (עד 10 לכל עמית) — המכשיר שלך מודגש" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "गॉसिप की गई पड़ोसी सूचियों से अनुमानित (प्रति पीयर 10 तक) — आपका डिवाइस हाइलाइट किया गया है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diperkirakan dari daftar tetangga yang di-gossip (hingga 10 per peer) — perangkatmu disorot" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stimato dalle liste di vicini diffuse (fino a 10 per peer) — il tuo dispositivo è evidenziato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ゴシップされた近隣リスト(ピアあたり最大10件)から推定 — お使いのデバイスをハイライト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "가십으로 전파된 이웃 목록(피어당 최대 10개)에서 추정 — 사용 중인 기기가 강조 표시됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dianggarkan dari senarai jiran yang di-gossip (sehingga 10 setiap peer) — perantimu diserlahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "गसिप गरिएका छिमेकी सूचीबाट अनुमानित (प्रति सहकर्मी बढीमा १०) — तिम्रो यन्त्र हाइलाइट गरिएको छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geschat op basis van verspreide buurlijsten (tot 10 per peer) — je apparaat is gemarkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "oszacowane na podstawie rozgłaszanych list sąsiadów (do 10 na peera) — twoje urządzenie jest wyróżnione" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimado a partir de listas de vizinhos difundidas (até 10 por par) — o teu dispositivo está destacado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimado a partir das listas de vizinhos divulgadas (até 10 por par) — seu dispositivo está destacado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оценено по gossip-спискам соседей (до 10 на пира) — твоё устройство выделено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uppskattat från skvallrade grannlistor (upp till 10 per peer) — din enhet är markerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வதந்தியாகப் பகிரப்பட்ட அண்டை பட்டியல்களிலிருந்து மதிப்பிடப்பட்டது (ஒரு peer க்கு 10 வரை) — உங்கள் சாதனம் தனிப்படுத்தப்பட்டுள்ளது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ประมาณจากรายชื่อเพื่อนบ้านที่ส่งต่อแบบ gossip (สูงสุด 10 รายต่อเพียร์) — อุปกรณ์ของคุณถูกไฮไลต์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yayılan komşu listelerinden tahmin edildi (eş başına en fazla 10) — cihazın vurgulanmış" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оцінено на основі поширюваних списків сусідів (до 10 на піра) — твій пристрій виділено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گپ شپ کی گئی پڑوسیوں کی فہرستوں سے اندازہ (فی ہم منصب 10 تک) — آپ کی ڈیوائس نمایاں ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ước tính từ danh sách hàng xóm được gossip (tối đa 10 mỗi nút) — thiết bị của bạn được làm nổi bật" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "根据传播的邻居列表估算(每个同伴最多 10 个)— 你的设备已高亮显示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "根據同伴間流傳的鄰居列表估算(每位同伴最多 10 個)— 你的裝置已高亮顯示" + } + } + } + }, + "topology.empty" : { + "comment" : "Empty state of the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا توجد روابط mesh بعد — تمتلئ الخريطة مع وصول إعلانات الأقران" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এখনো কোনো মেশ লিঙ্ক নেই — পিয়ার অ্যানাউন্স এলে মানচিত্র পূরণ হয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "noch keine mesh-verbindungen — die karte füllt sich, sobald peer-announces eintreffen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no mesh links yet — the map fills in as peer announces arrive" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no hay enlaces del mesh — el mapa se completa a medida que llegan los anuncios de peers" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wala pang mesh link — napupuno ang mapa habang dumarating ang mga announce ng peer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aucun lien mesh pour l'instant — la carte se remplit à mesure que les annonces des pairs arrivent" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אין עדיין קישורי mesh — המפה מתמלאת כשהכרזות עמיתים מגיעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अभी तक कोई मेश लिंक नहीं — पीयर अनाउंस आने पर नक्शा भरता जाता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada tautan mesh — peta terisi saat announce peer berdatangan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ancora nessun collegamento mesh — la mappa si riempie man mano che arrivano gli announce dei peer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "まだmeshリンクがありません — ピアのannounceが届くとマップが埋まります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "아직 mesh 링크가 없습니다 — 피어 announce가 도착하면 지도가 채워집니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada pautan mesh — peta terisi apabila announce peer tiba" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अझै mesh लिंक छैन — सहकर्मी घोषणा आउँदै जाँदा नक्सा भरिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nog geen mesh-verbindingen — de kaart vult zich naarmate peer-announces binnenkomen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "brak połączeń mesh — mapa wypełnia się, gdy nadchodzą ogłoszenia peerów" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ainda sem ligações mesh — o mapa preenche-se à medida que chegam os announces dos pares" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nenhuma conexão do mesh ainda — o mapa se preenche conforme os anúncios dos pares chegam" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "пока нет mesh-связей — карта заполнится по мере поступления анонсов пиров" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inga mesh-länkar än — kartan fylls i när peer-announces kommer in" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இன்னும் mesh இணைப்புகள் இல்லை — peer அறிவிப்புகள் வரும்போது வரைபடம் நிரப்பப்படும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่มีการเชื่อมต่อ mesh — แผนที่จะเติมเต็มเมื่อ announce ของเพียร์มาถึง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "henüz mesh bağlantısı yok — eş duyuruları geldikçe harita dolar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поки немає зв'язків mesh — карта заповнюється, коли надходять оголошення пірів" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ابھی کوئی mesh روابط نہیں — ہم منصبوں کے اعلانات آتے ہی نقشہ بھر جاتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa có liên kết mesh nào — bản đồ sẽ được điền khi các announce của nút đến" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "尚无 mesh 链路 — 随着同伴广播到达,地图会逐渐填充" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "尚無 mesh 連線 — 地圖會隨著同伴宣告到達而逐漸填滿" + } + } + } + }, + "topology.refresh" : { + "comment" : "Accessibility label of the topology refresh button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تحديث الطوبولوجيا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টপোলজি রিফ্রেশ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie aktualisieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "refresh topology" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "actualizar topología" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-refresh ang topology" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "actualiser la topologie" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "רענון טופולוגיה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोपोलॉजी रीफ़्रेश करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "segarkan topologi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aggiorna topologia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "トポロジーを更新" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "토폴로지 새로고침" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "segarkan topologi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोपोलोजी ताजा गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie vernieuwen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odśwież topologię" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atualizar topologia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atualizar topologia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "обновить топологию" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uppdatera topologi" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டோபாலஜியைப் புதுப்பிக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รีเฟรชโทโพโลยี" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topolojiyi yenile" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оновити топологію" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹوپولوجی ریفریش کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "làm mới cấu trúc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "刷新拓扑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "重新整理拓撲" + } + } + } + }, + "topology.summary" : { + "comment" : "Topology map summary: number of peers and links", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld قرين · %2$ld رابط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld পিয়ার · %2$ld লিঙ্ক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peers · %2$ld verbindungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld links" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld enlaces" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld link" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld pairs · %2$ld liens" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld עמיתים · %2$ld קישורים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld पीयर · %2$ld लिंक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld tautan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld collegamenti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld ピア · %2$ld リンク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "피어 %1$ld개 · 링크 %2$ld개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld pautan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld सहकर्मी · %2$ld लिंक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peers · %2$ld verbindingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peerów · %2$ld połączeń" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld pares · %2$ld ligações" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld pares · %2$ld conexões" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld пиров · %2$ld связей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peers · %2$ld länkar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer-கள் · %2$ld இணைப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld เพียร์ · %2$ld การเชื่อมต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld eş · %2$ld bağlantı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld пірів · %2$ld зв'язків" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld ہم منصب · %2$ld روابط" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld nút · %2$ld liên kết" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld 个同伴 · %2$ld 条链路" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld 位同伴 · %2$ld 條連線" + } + } + } + }, + "topology.title" : { + "comment" : "Title of the mesh topology map sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "طوبولوجيا mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ টপোলজি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "topología del mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topology" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טופולוגיית mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश टोपोलॉजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh トポロジー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 토폴로지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh टोपोलोजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia do mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топология mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologi" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh டோபாலஜி" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "โทโพโลยี mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topolojisi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топологія mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ٹوپولوجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cấu trúc mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 拓扑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 拓撲" + } + } + } + }, "verification.my_qr.accessibility_label" : { "extractionState" : "manual", "localizations" : { @@ -36417,365 +69564,6 @@ } } } - }, - "Images are only available in mesh chats." : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الصور متاحة فقط في محادثات الميش." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder sind nur im Mesh-Chat verfügbar." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Images are only available in mesh chats." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Las imágenes solo están disponibles en los chats de mesh." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang mga larawan ay available lamang sa mga mesh chat." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Les images sont uniquement disponibles dans les discussions mesh." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תמונות זמינות רק בצ׳אט של mesh." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gambar hanya tersedia di obrolan mesh." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le immagini sono disponibili solo nelle chat mesh." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像はメッシュチャットでのみ利用できます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Imej hanya tersedia dalam sembang mesh." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obrazy są dostępne tylko na czatach mesh." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Изображения доступны только в mesh-чатах." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder är bara tillgängliga i mesh-chattar." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Зображення доступні лише в mesh-чатах." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "图片仅可在 mesh 聊天中使用。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "圖片僅能在 mesh 聊天中使用。" - } - } - } - }, - "Choose an image" : { - "comment" : "A label displayed above a button that allows the user to choose an image to send.", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اختر صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "একটি ছবি নির্বাচন করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bild auswählen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose an image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige una imagen" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pumili ng larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisir une image" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एक चित्र चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scegli un’immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지를 선택하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एउटा तस्वीर चयन गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kies een afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolher uma imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolha uma imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj en bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือกภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bir görüntü seç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виберіть зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایک تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chọn một hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择图像" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇圖像" - } - } - } } }, "version" : "1.1" diff --git a/bitchat/Models/BitchatMessage+Media.swift b/bitchat/Models/BitchatMessage+Media.swift index 5706654c..a718e484 100644 --- a/bitchat/Models/BitchatMessage+Media.swift +++ b/bitchat/Models/BitchatMessage+Media.swift @@ -13,13 +13,6 @@ extension BitchatMessage { enum Media { case voice(URL) case image(URL) - - var url: URL { - switch self { - case .voice(let url), .image(let url): - return url - } - } } // Cache the directory lookup to avoid repeated FileManager calls during view rendering diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index 6b69d019..fcccda49 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -7,7 +7,6 @@ struct BitchatPeer: Equatable { let peerID: PeerID // Hex-encoded peer ID let noisePublicKey: Data let nickname: String - let lastSeen: Date let isConnected: Bool let isReachable: Bool @@ -77,14 +76,13 @@ struct BitchatPeer: Equatable { peerID: PeerID, noisePublicKey: Data, nickname: String, - lastSeen: Date = Date(), + lastSeen _: Date = Date(), isConnected: Bool = false, isReachable: Bool = false ) { self.peerID = peerID self.noisePublicKey = noisePublicKey self.nickname = nickname - self.lastSeen = lastSeen self.isConnected = isConnected self.isReachable = isReachable diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 0bf25537..2b077759 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -11,48 +11,78 @@ import Foundation // MARK: - CommandInfo Enum enum CommandInfo: String, Identifiable { + // Raw values must match the aliases CommandProcessor actually accepts — + // the suggestion panel is the app's only command-discovery surface, and + // suggesting a spelling the processor rejects teaches users dead ends. case block case clear + case group + case help case hug - case message = "dm" + case message = "msg" case slap + case pay case unblock case who - case favorite - case unfavorite - + case favorite = "fav" + case unfavorite = "unfav" + case ping + case trace + case drop + var id: String { rawValue } - + var alias: String { "/" + rawValue } - + var placeholder: String? { switch self { - case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: + case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: return "<" + String(localized: "content.input.nickname_placeholder") + ">" - case .clear, .who: + case .group: + return "<" + String(localized: "content.input.group_placeholder") + ">" + case .pay: + return "<" + String(localized: "content.input.token_placeholder") + ">" + case .drop: + return "<" + String(localized: "content.input.note_placeholder") + ">" + case .clear, .help, .who: return nil } } - + var description: String { switch self { case .block: String(localized: "content.commands.block") case .clear: String(localized: "content.commands.clear") + case .group: String(localized: "content.commands.group") + case .help: String(localized: "content.commands.help") case .hug: String(localized: "content.commands.hug") case .message: String(localized: "content.commands.message") + case .pay: String(localized: "content.commands.pay") case .slap: String(localized: "content.commands.slap") case .unblock: String(localized: "content.commands.unblock") case .who: String(localized: "content.commands.who") case .favorite: String(localized: "content.commands.favorite") case .unfavorite: String(localized: "content.commands.unfavorite") + case .ping: String(localized: "content.commands.ping") + case .trace: String(localized: "content.commands.trace") + case .drop: String(localized: "content.commands.drop") } } - + static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { - let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who] - if isGeoPublic || isGeoDM { - return baseCommands + [.favorite, .unfavorite] + var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who] + // Cashu tokens are bearer instruments: in a public geohash any nearby + // stranger can redeem one, so don't *suggest* /pay there (the + // processor still allows it behind an explicit "public" confirm). + // Payments make sense in every DM and in mesh public. + if !isGeoPublic { + commands.append(.pay) } - return baseCommands + // The processor rejects favorites, groups, and mesh diagnostics in + // geohash contexts, so only suggest them where they work: mesh. + if isGeoPublic || isGeoDM { + return commands + } + return commands + [.favorite, .unfavorite, .ping, .trace, .group] } } diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index 0db8e7aa..0c6cb3cd 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -1,10 +1,21 @@ +import BitFoundation import Foundation // REQUEST_SYNC payload TLV (type, length16, value) // - 0x01: P (uint8) — Golomb-Rice parameter // - 0x02: M (uint32, big-endian) — hash range (N * 2^P) // - 0x03: data (opaque) — GR bitstream bytes (MSB-first) +// - 0x04: types (SyncTypeFlags) — packet types the filter covers +// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor +// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte) +// fragment stream IDs; restricts the fragment diff to exactly those +// streams (targeted resync for stalled reassemblies) struct RequestSyncPacket { + /// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as + /// 16 hex chars plus a comma separator, so the largest encoded value is + /// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap. + static let maxFragmentIdFilterCount = 60 + let p: Int let m: UInt32 let data: Data @@ -12,6 +23,29 @@ struct RequestSyncPacket { let sinceTimestamp: UInt64? let fragmentIdFilter: String? + /// Encodes 8-byte fragment stream IDs as the 0x06 filter string, + /// dropping malformed IDs and capping at `maxFragmentIdFilterCount`. + static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? { + let tokens = fragmentIDs + .filter { $0.count == 8 } + .prefix(maxFragmentIdFilterCount) + .map { $0.hexEncodedString() } + guard !tokens.isEmpty else { return nil } + return tokens.joined(separator: ",") + } + + /// Decodes a 0x06 filter string back into 8-byte fragment stream IDs, + /// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`. + static func decodeFragmentIdFilter(_ filter: String?) -> Set? { + guard let filter else { return nil } + var ids: Set = [] + for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) { + guard token.count == 16, let id = Data(hexString: String(token)) else { continue } + ids.insert(id) + } + return ids.isEmpty ? nil : ids + } + init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) { self.p = p self.m = m @@ -88,7 +122,9 @@ struct RequestSyncPacket { sinceTimestamp = ts } case 0x06: - if let fid = String(data: v, encoding: .utf8) { + // Same acceptance cap as the GCS payload; an oversized filter + // is ignored rather than failing the whole request. + if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) { fragmentIdFilter = fid } default: diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 6c960742..a42b86a0 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -93,6 +93,7 @@ enum NoisePattern { case XX // Most versatile, mutual authentication case IK // Initiator knows responder's static key case NK // Anonymous initiator + case X // One-way: single message to a known static key (no response) } enum NoiseRole { @@ -601,7 +602,7 @@ final class NoiseHandshakeState { switch pattern { case .XX: break // No pre-message keys - case .IK, .NK: + case .IK, .NK, .X: if role == .initiator, let remoteStatic = remoteStaticPublic { symmetricState.mixHash(remoteStatic.rawRepresentation) } else if role == .responder, let localStatic = localStaticPublic { @@ -722,7 +723,7 @@ final class NoiseHandshakeState { return messageBuffer } - func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data { + func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete @@ -904,6 +905,7 @@ extension NoisePattern { case .XX: return "XX" case .IK: return "IK" case .NK: return "NK" + case .X: return "X" } } @@ -925,6 +927,10 @@ extension NoisePattern { [.e, .es], // -> e, es [.e, .ee] // <- e, ee ] + case .X: + return [ + [.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message) + ] } } } diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index 17781352..67a722b1 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -21,12 +21,6 @@ enum NoiseSecurityConstants { // Maximum number of messages before rekey (2^64 - 1 is the nonce limit) static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages - // Handshake timeout - abandon incomplete handshakes - static let handshakeTimeout: TimeInterval = 60 // 1 minute - - // Maximum concurrent sessions per peer - static let maxSessionsPerPeer = 3 - // Rate limiting static let maxHandshakesPerMinute = 10 static let maxMessagesPerSecond = 100 diff --git a/bitchat/Noise/NoiseSecurityError.swift b/bitchat/Noise/NoiseSecurityError.swift index aff73c8b..8ffba1eb 100644 --- a/bitchat/Noise/NoiseSecurityError.swift +++ b/bitchat/Noise/NoiseSecurityError.swift @@ -14,5 +14,4 @@ enum NoiseSecurityError: Error { case messageTooLarge case invalidPeerID case rateLimitExceeded - case handshakeTimeout } diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 9029b228..619f84ac 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -13,8 +13,6 @@ import BitFoundation final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] - private let localStaticKey: Curve25519.KeyAgreement.PrivateKey - private let keychain: KeychainManagerProtocol private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) @@ -23,8 +21,6 @@ final class NoiseSessionManager { var onSessionFailed: ((PeerID, Error) -> Void)? init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { - self.localStaticKey = localStaticKey - self.keychain = keychain self.sessionFactory = { peerID, role in SecureNoiseSession( peerID: peerID, @@ -37,12 +33,10 @@ final class NoiseSessionManager { #if DEBUG init( - localStaticKey: Curve25519.KeyAgreement.PrivateKey, - keychain: KeychainManagerProtocol, + localStaticKey _: Curve25519.KeyAgreement.PrivateKey, + keychain _: KeychainManagerProtocol, sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession ) { - self.localStaticKey = localStaticKey - self.keychain = keychain self.sessionFactory = sessionFactory } #endif diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index 72fb7bbf..7dedfd08 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -6,14 +6,12 @@ struct NostrIdentity: Codable { let privateKey: Data let publicKey: Data let npub: String // Bech32-encoded public key - let createdAt: Date - + /// Memberwise initializer - init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { + init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) { self.privateKey = privateKey self.publicKey = publicKey self.npub = npub - self.createdAt = createdAt } /// Generate a new Nostr identity @@ -39,12 +37,6 @@ struct NostrIdentity: Codable { self.privateKey = privateKeyData self.publicKey = xOnlyPubkey self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) - self.createdAt = Date() - } - - /// Get signing key for event signatures - func signingKey() throws -> P256K.Signing.PrivateKey { - try P256K.Signing.PrivateKey(dataRepresentation: privateKey) } /// Get Schnorr signing key for Nostr event signatures diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index a2e091ba..2e18a235 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -15,7 +15,7 @@ final class NostrIdentityBridge { private let keychain: KeychainManagerProtocol - init(keychain: KeychainManagerProtocol = KeychainManager()) { + init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) { self.keychain = keychain } @@ -37,14 +37,6 @@ final class NostrIdentityBridge { return nostrIdentity } - /// Associate a Nostr identity with a Noise public key (for favorites) - func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { - let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" - if let data = nostrPubkey.data(using: .utf8) { - keychain.save(key: key, data: data, service: keychainService, accessible: nil) - } - } - /// Get Nostr public key associated with a Noise public key func getNostrPublicKey(for noisePublicKey: Data) -> String? { let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" @@ -57,29 +49,10 @@ final class NostrIdentityBridge { /// Clear all Nostr identity associations and current identity func clearAllAssociations() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecMatchLimit as String: kSecMatchLimitAll, - kSecReturnAttributes as String: true - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecSuccess, let items = result as? [[String: Any]] { - for item in items { - var deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService - ] - if let account = item[kSecAttrAccount as String] as? String { - deleteQuery[kSecAttrAccount as String] = account - } - SecItemDelete(deleteQuery as CFDictionary) - } - } else if status == errSecItemNotFound { - // nothing persisted; no action needed - } + // Must go through the injected keychain, not raw SecItem calls: + // under test that keychain is in-memory, and a direct delete here + // would wipe the developer's real Nostr identity on every test run. + keychain.deleteAll(service: keychainService) deviceSeedCache = nil // Also drop the in-memory derived per-geohash identities. These hold the @@ -113,6 +86,13 @@ final class NostrIdentityBridge { return seed } + /// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge + /// rendezvous cell. Distinct HMAC label keeps it unlinkable from the + /// geohash-chat identity for the same cell string. + func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity { + try deriveIdentity(forGeohash: "bridge|" + cell) + } + /// Derive a deterministic, unlinkable Nostr identity for a given geohash. /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing /// if the candidate is not a valid secp256k1 private key. diff --git a/bitchat/Nostr/NostrPoW.swift b/bitchat/Nostr/NostrPoW.swift new file mode 100644 index 00000000..006bba6d --- /dev/null +++ b/bitchat/Nostr/NostrPoW.swift @@ -0,0 +1,215 @@ +import BitFoundation +import CryptoKit +import Foundation + +/// NIP-13 proof-of-work for Nostr events. +/// +/// Outgoing kind-20000 geohash messages mine a `["nonce", "", ""]` +/// tag so the event ID carries at least `target` leading zero bits. Inbound +/// events are scored (never hard-rejected — the network has clients that do +/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the +/// per-sender public rate limit, everything else keeps the strict limits. +enum NostrPoW { + + // MARK: - Tuning + + /// Difficulty (leading zero bits of the event ID) mined onto outgoing + /// geohash messages. 8 bits is ~256 hash attempts — typically well under + /// 100 ms on any supported device. + static let targetBits = 8 + + /// Inbound events whose validated NIP-13 difficulty is at least this many + /// bits skip the per-sender rate-limit bucket (the content-flood bucket + /// still applies). See `MessageRateLimiter.allow`. + static let rateLimitBypassBits = 8 + + /// Hard cap on mining wall-clock time. When it hits, the committed target + /// steps down until a difficulty reachable in a small extra budget is + /// found and the message is sent anyway — mining never blocks sending. + static let miningTimeCap: TimeInterval = 2.0 + + /// Budget for each stepped-down attempt after the main cap (or a task + /// cancellation) hits. + private static let fallbackTimeCap: TimeInterval = 0.15 + + /// The hot loop checks the deadline and task cancellation every this many + /// hash attempts. + private static let checkInterval: UInt64 = 1024 + + /// The nonce value is a fixed-width hex counter so the serialized event + /// template can be mutated in place without reallocation. + private static let nonceLength = 16 + + // MARK: - Scoring + + /// Number of leading zero bits in a byte sequence (NIP-13 difficulty of + /// an event-ID hash). + static func leadingZeroBits>(_ bytes: Bytes) -> Int { + var total = 0 + for byte in bytes { + if byte == 0 { + total += 8 + } else { + total += byte.leadingZeroBitCount + break + } + } + return total + } + + /// Validated NIP-13 difficulty of an inbound event. + /// + /// The committed target in the nonce tag is what counts: the actual + /// leading zero bits of the ID must meet it (otherwise the claim is void + /// and the event scores 0), and work beyond the commitment earns no extra + /// credit — this stops spammers who mine a low target from getting lucky + /// high scores. Events without a well-formed commitment score 0. + static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int { + guard let nonceTag = tags.last(where: { $0.first == "nonce" }), + nonceTag.count >= 3, + let committed = Int(nonceTag[2]), + committed > 0, committed <= 256, + let idData = Data(hexString: idHex) + else { + return 0 + } + return leadingZeroBits(idData) >= committed ? committed : 0 + } + + // MARK: - Mining + + /// Mine a `["nonce", value, target]` tag for the given unsigned-event + /// fields. Nonisolated async: runs off the calling actor. + /// + /// Bounded by `miningTimeCap`: when the cap hits — or the surrounding + /// task is cancelled — the committed target steps down (halving to 0, + /// which any hash satisfies) so the event still ships promptly with an + /// honest commitment at the difficulty actually reached. Returns nil only + /// if canonical serialization fails; the caller then sends unmined. + static func mineNonceTag( + pubkey: String, + createdAt: Int, + kind: Int, + tags: [[String]], + content: String, + targetBits: Int = NostrPoW.targetBits + ) async -> [String]? { + var target = min(max(targetBits, 0), 256) + var budget = miningTimeCap + while true { + if let tag = mineAttempt( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + baseTags: tags, + content: content, + targetBits: target, + budget: budget + ) { + return tag + } + // Target 0 succeeds on the first hash, so reaching it with nil + // means serialization itself failed — give up on mining. + if target == 0 { return nil } + target /= 2 + budget = fallbackTimeCap + } + } + + /// One bounded mining pass at a fixed committed target. Allocation-light: + /// the canonical serialization is built once and only the fixed-width + /// nonce bytes are rewritten per attempt (the event ID is recomputed for + /// every attempt, per NIP-13). Returns nil on timeout/cancellation or if + /// the template could not be built. + private static func mineAttempt( + pubkey: String, + createdAt: Int, + kind: Int, + baseTags: [[String]], + content: String, + targetBits: Int, + budget: TimeInterval + ) -> [String]? { + let targetString = String(targetBits) + guard let template = serializedTemplate( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + baseTags: baseTags, + content: content, + targetString: targetString + ) else { + return nil + } + var buffer = template.buffer + let nonceRange = template.nonceRange + + let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000) + let hexDigits = [UInt8]("0123456789abcdef".utf8) + var nonce = UInt64.random(in: .min ... .max) + var attempts: UInt64 = 0 + + while true { + // Write the nonce as 16 lowercase hex chars, in place. + var value = nonce + var index = nonceRange.upperBound + while index > nonceRange.lowerBound { + index -= 1 + buffer[index] = hexDigits[Int(value & 0xF)] + value >>= 4 + } + + if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits { + // Identical to the bytes just written into the buffer. + return ["nonce", String(format: "%016llx", nonce), targetString] + } + + nonce &+= 1 + attempts &+= 1 + if attempts % checkInterval == 0, + Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline { + return nil + } + } + } + + /// Canonical NIP-01 serialization of the event with a placeholder nonce, + /// plus the byte range of the nonce value inside it. + /// + /// The range is located by serializing twice with two same-length + /// placeholders and diffing the buffers — the only differing bytes are + /// the nonce value, so this stays correct however `JSONSerialization` + /// escapes the surrounding fields (and even if the content contains the + /// placeholder text itself). + private static func serializedTemplate( + pubkey: String, + createdAt: Int, + kind: Int, + baseTags: [[String]], + content: String, + targetString: String + ) -> (buffer: Data, nonceRange: Range)? { + func serialize(noncePlaceholder: String) -> Data? { + var tags = baseTags + tags.append(["nonce", noncePlaceholder, targetString]) + let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] + return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + } + + guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)), + let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)), + zeros.count == effs.count + else { + return nil + } + + var firstDiff = -1 + var lastDiff = -1 + for index in 0..= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil } + return (zeros, firstDiff..<(firstDiff + nonceLength)) + } +} diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index f68caa8d..72144e9e 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -19,6 +19,11 @@ struct NostrProtocol { case giftWrap = 1059 // NIP-59 gift wrap case ephemeralEvent = 20000 case geohashPresence = 20001 + case deletion = 5 // NIP-09 event deletion request + /// Sealed courier envelope parked on relays under its rotating + /// recipient tag (`#x`). Regular (stored) kind so it survives until + /// its NIP-40 expiration — the whole point is store-and-forward. + case courierDrop = 1401 } /// Create a NIP-17 private message @@ -170,6 +175,63 @@ struct NostrProtocol { nickname: String? = nil, teleported: Bool = false ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported), + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work + /// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is + /// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the + /// surrounding task is cancelled) the event ships at the highest + /// committed difficulty still met, and if mining is impossible it ships + /// unmined — sending is never blocked. + static func createMinedEphemeralGeohashEvent( + content: String, + geohash: String, + senderIdentity: NostrIdentity, + nickname: String? = nil, + teleported: Bool = false, + powTargetBits: Int = NostrPoW.targetBits + ) async throws -> NostrEvent { + var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported) + // Fix created_at up front: the mined nonce commits to the full + // serialized event, so the signed event must reuse the exact value. + let createdAt = Int(Date().timeIntervalSince1970) + if let nonceTag = await NostrPoW.mineNonceTag( + pubkey: senderIdentity.publicKeyHex, + createdAt: createdAt, + kind: EventKind.ephemeralEvent.rawValue, + tags: tags, + content: content, + targetBits: powTargetBits + ) { + tags.append(nonceTag) + } + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)), + kind: .ephemeralEvent, + tags: tags, + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Tags for a kind-20000 geohash message (shared by the plain and mined + /// variants). + private static func ephemeralGeohashTags( + geohash: String, + nickname: String?, + teleported: Bool + ) -> [[String]] { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) @@ -177,15 +239,7 @@ struct NostrProtocol { if teleported { tags.append(["t", "teleport"]) } - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: tags, - content: content - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) + return tags } /// Create a geohash presence heartbeat (kind 20001) @@ -206,17 +260,115 @@ struct NostrProtocol { return try event.sign(with: schnorrKey) } + // MARK: - Mesh bridge (rendezvous) events + + /// Create a mesh-bridge public message (kind 20000) for a geohash-cell + /// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash + /// channel subscriptions (which filter on `#g`); `m` is + /// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the + /// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0 + /// parsers, which key their dedup on `m[1]` unconditionally and need it + /// per-message-unique. Current parsers key bridge rows by the authenticated + /// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh + /// coordinates are public and cannot authenticate the Nostr signer. + static func createBridgeMeshEvent( + content: String, + cell: String, + senderIdentity: NostrIdentity, + nickname: String? = nil, + meshSenderID: String? = nil, + meshTimestampMs: UInt64? = nil + ) throws -> NostrEvent { + var tags = [["r", cell]] + if let nickname = nickname?.trimmedOrNilIfEmpty { + tags.append(["n", nickname]) + } + if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs { + let stableID = MeshMessageIdentity.stableID( + senderIDHex: meshSenderID, + timestampMs: meshTimestampMs, + content: content + ) + tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)]) + } + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: tags, + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous + /// cell: empty content, `r` tag only — the bridge analogue of geohash + /// presence, counted into "people across the bridge". + static func createBridgePresenceEvent( + cell: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .geohashPresence, + tags: [["r", cell]], + content: "" + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Create a courier drop (kind 1401): an opaque sealed courier envelope + /// parked on relays. `x` is the hex recipient tag the recipient (or a + /// gateway acting for them) subscribes for; the NIP-40 expiration tracks + /// the envelope expiry so honoring relays garbage-collect the drop. The + /// signing identity should be a throwaway — the envelope authenticates + /// its sender internally via Noise-X, and linking drops to a stable + /// publisher key would leak courier traffic patterns. + static func createCourierDropEvent( + envelope: Data, + recipientTagHex: String, + expiresAt: Date, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let tags = [ + ["x", recipientTagHex], + ["expiration", String(Int(expiresAt.timeIntervalSince1970))] + ] + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .courierDrop, + tags: tags, + content: envelope.base64EncodedString() + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. + /// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays + /// drop the note in step with a bridged board post's expiry. static func createGeohashTextNote( content: String, geohash: String, senderIdentity: NostrIdentity, - nickname: String? = nil + nickname: String? = nil, + expiresAt: Date? = nil, + urgent: Bool = false ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } + if let expiresAt { + tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))]) + } + if urgent { + tags.append(["t", "urgent"]) + } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), @@ -227,7 +379,25 @@ struct NostrProtocol { let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } - + + /// Create a NIP-09 deletion request for one of our own events. Relays that + /// honor NIP-09 drop the referenced event; it must be signed by the same + /// key that signed the original. + static func createDeleteEvent( + ofEventID eventID: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .deletion, + tags: [["e", eventID]], + content: "" + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + // MARK: - Private Methods private static func createSeal( @@ -474,37 +644,6 @@ struct NostrProtocol { return sharedSecretData } - // Direct version that doesn't try to add prefixes - private static func deriveSharedSecretDirect( - privateKey: P256K.Schnorr.PrivateKey, - publicKey: Data - ) throws -> Data { - // Direct shared secret calculation - - // Convert Schnorr private key to KeyAgreement private key - let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( - dataRepresentation: privateKey.dataRepresentation - ) - - // Use the public key as-is (should already have prefix) - let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( - dataRepresentation: publicKey, - format: .compressed - ) - - // Perform ECDH - let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( - with: keyAgreementPublicKey, - format: .compressed - ) - - // Convert SharedSecret to Data - let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } - - // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key - return sharedSecretData - } - private static func randomizedTimestamp() -> Date { // Add random offset to current time for privacy // This prevents timing correlation attacks while the actual message timestamp @@ -634,11 +773,8 @@ struct NostrEvent: Codable { enum NostrError: Error { case invalidPublicKey - case invalidPrivateKey case invalidEvent case invalidCiphertext - case signingFailed - case encryptionFailed } // MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) @@ -648,7 +784,7 @@ private extension NostrProtocol { let derivedKey = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: sharedSecretData), salt: Data(), - info: "nip44-v2".data(using: .utf8)!, + info: Data("nip44-v2".utf8), outputByteCount: 32 ) return derivedKey.withUnsafeBytes { Data($0) } diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 53d794fa..9f295a69 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -117,7 +117,6 @@ final class NostrRelayManager: ObservableObject { let url: String var isConnected: Bool = false var lastError: Error? - var lastConnectedAt: Date? var messagesSent: Int = 0 var messagesReceived: Int = 0 var reconnectAttempts: Int = 0 @@ -137,6 +136,10 @@ final class NostrRelayManager: ObservableObject { @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false + /// Whether a relay that carries private messages is connected. DMs + /// target the default (gift-wrap-capable) relay set, so a connected + /// geohash/custom relay alone must not count — sends would still queue. + @Published private(set) var isDMRelayConnected = false private let dependencies: NostrRelayManagerDependencies private var allowDefaultRelays: Bool = false @@ -180,11 +183,26 @@ final class NostrRelayManager: ObservableObject { } private var subscriptionRequestState: [String: SubscriptionRequestState] = [:] - // Track EOSE per subscription to signal when initial stored events are done + // Track EOSE per subscription to signal when initial stored events are + // done. Completion is scoped to relays the REQ actually reached: targets + // still mid-connect must not hold the callback hostage until the fallback + // timer (a dead relay of five used to pin "loading" for the full 10s). private struct EOSETracker { - var pendingRelays: Set + /// Targets the REQ has not been delivered to yet (still connecting). + var awaitingSend: Set + /// Relays that received the REQ and have not sent EOSE yet. + var awaitingEOSE: Set + /// True once any relay received the REQ (or answered with EOSE) — + /// completion with zero sends would mean "done" without ever asking. + var didSend = false var callback: () -> Void let epoch: Int + + /// Done when every relay that got the REQ has resolved, provided at + /// least one did — or when every target dropped out entirely. + var isComplete: Bool { + (didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty) + } } private var eoseTrackers: [String: EOSETracker] = [:] private var eoseTrackerEpoch = 0 @@ -198,6 +216,15 @@ final class NostrRelayManager: ObservableObject { } private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() + /// Non-queued sends whose callers require relay durability. A WebSocket + /// write only proves bytes left this process; NIP-20 OK is the relay's + /// accept/reject acknowledgment. + private struct ConfirmedSendState { + let token: UUID + var awaitingRelays: Set + let completion: (Bool) -> Void + } + private var confirmedSends: [String: ConfirmedSendState] = [:] // Total pending sends dropped at the queue cap; drives the sampled // overflow warning (first + every Nth drop). private var pendingSendDropCount = 0 @@ -294,6 +321,9 @@ final class NostrRelayManager: ObservableObject { for (_, tracker) in trackers { tracker.callback() } + let confirmed = confirmedSends.values.map(\.completion) + confirmedSends.removeAll() + confirmed.forEach { $0(false) } pendingTorConnectionURLs.removeAll() awaitingTorForConnections = false torReadyWaitAttempts = 0 @@ -327,6 +357,7 @@ final class NostrRelayManager: ObservableObject { duplicateInboundEventDropCountBySubscription.removeAll() inboundEventLogCount = 0 Self.pendingGiftWrapIDs.removeAll() + confirmedSends.removeAll() messageQueueLock.lock() messageQueue.removeAll() @@ -343,7 +374,6 @@ final class NostrRelayManager: ObservableObject { relays[index].nextReconnectTime = nil if resetState { relays[index].lastError = nil - relays[index].lastConnectedAt = nil relays[index].lastDisconnectedAt = nil relays[index].messagesSent = 0 relays[index].messagesReceived = 0 @@ -402,6 +432,97 @@ final class NostrRelayManager: ObservableObject { } } + /// Attempts an event only on currently connected target relays and + /// reports whether at least one relay explicitly accepted it via NIP-20 + /// OK. A successful WebSocket write alone is not durable acceptance. + /// Unlike `sendEvent`, this never enters the process-local pending queue; + /// callers use it when success unlocks durable state or user-visible + /// delivery progress. + func sendEventImmediately( + _ event: NostrEvent, + to relayUrls: [String]? = nil, + completion: @escaping (Bool) -> Void + ) { + guard dependencies.activationAllowed() else { + completion(false) + return + } + guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else { + completion(false) + return + } + + let requestedRelays = relayUrls ?? Self.defaultRelays + let targetRelays = allowedRelayList(from: requestedRelays) + let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in + guard let connection = connectedConnection(for: relayUrl) else { return nil } + return (relayUrl, connection) + } + guard !connectedTargets.isEmpty else { + completion(false) + return + } + + let token = UUID() + let eventID = event.id + if let replaced = confirmedSends.removeValue(forKey: eventID) { + replaced.completion(false) + } + confirmedSends[eventID] = ConfirmedSendState( + token: token, + awaitingRelays: Set(connectedTargets.map(\.0)), + completion: completion + ) + dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in + Task { @MainActor [weak self] in + self?.timeoutConfirmedSend(eventID: eventID, token: token) + } + } + + for (relayUrl, connection) in connectedTargets { + sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in + guard let self else { return } + // Success only means the bytes reached the socket; wait for + // the matching relay OK. A failed write settles this target + // as rejected because no OK can arrive for it. + if !succeeded { + self.resolveConfirmedSend( + eventID: eventID, + relayURL: relayUrl, + accepted: false, + token: token + ) + } + } + } + } + + private func resolveConfirmedSend( + eventID: String, + relayURL: String, + accepted: Bool, + token: UUID? = nil + ) { + guard var state = confirmedSends[eventID], + token == nil || state.token == token, + state.awaitingRelays.remove(relayURL) != nil else { return } + if accepted { + confirmedSends.removeValue(forKey: eventID) + state.completion(true) + } else if state.awaitingRelays.isEmpty { + confirmedSends.removeValue(forKey: eventID) + state.completion(false) + } else { + confirmedSends[eventID] = state + } + } + + private func timeoutConfirmedSend(eventID: String, token: UUID) { + guard let state = confirmedSends[eventID], state.token == token else { return } + confirmedSends.removeValue(forKey: eventID) + state.completion(false) + } + private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set) { messageQueueLock.lock() messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays)) @@ -791,7 +912,7 @@ final class NostrRelayManager: ObservableObject { private func startEOSETracking(id: String, relayURLs: Set, callback: @escaping () -> Void) { eoseTrackerEpoch += 1 let epoch = eoseTrackerEpoch - eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch) + eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch) // Fallback timeout to avoid hanging if a relay never sends EOSE. dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in Task { @MainActor [weak self] in @@ -906,6 +1027,7 @@ final class NostrRelayManager: ObservableObject { // Send initial ping to verify connection task.sendPing { [weak self] error in DispatchQueue.main.async { + guard self?.connections[urlString] === task else { return } if error == nil { SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) self?.updateRelayStatus(urlString, isConnected: true) @@ -915,7 +1037,11 @@ final class NostrRelayManager: ObservableObject { SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) self?.updateRelayStatus(urlString, isConnected: false, error: error) // Trigger disconnection handler for proper backoff - self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) + self?.handleDisconnection( + relayUrl: urlString, + error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil), + connection: task + ) } } } @@ -931,8 +1057,19 @@ final class NostrRelayManager: ObservableObject { toSend[id] = state.messageString } for (id, messageString) in toSend { - if self.subscriptions[relayUrl]?.contains(id) == true { continue } + if self.subscriptions[relayUrl]?.contains(id) == true { + // Already subscribed on this relay (e.g. a tracker promoted + // after an earlier flush): its EOSE is coming, count it. + markEOSESubscribed(id: id, relayUrl: relayUrl) + continue + } startPendingEOSETrackingIfNeeded(id: id) + // Mark at send *initiation*, not in the async completion: a fast + // relay's EOSE could otherwise complete the tracker while this + // relay — REQ already on the wire — still sat in awaitingSend. + // If the send fails the socket is going down with it, and the + // disconnect settle (or the fallback timer) releases the wait. + markEOSESubscribed(id: id, relayUrl: relayUrl) connection.send(.string(messageString)) { [weak self, weak connection] error in Task { @MainActor [weak self] in guard let self else { return } @@ -962,18 +1099,20 @@ final class NostrRelayManager: ObservableObject { Task.detached(priority: .utility) { guard let parsed = ParsedInbound(message) else { return } await MainActor.run { + guard self.connections[relayUrl] === task else { return } self.handleParsedMessage(parsed, from: relayUrl) } } // Continue receiving Task { @MainActor in + guard self.connections[relayUrl] === task else { return } self.receiveMessage(from: task, relayUrl: relayUrl) } case .failure(let error): DispatchQueue.main.async { - self.handleDisconnection(relayUrl: relayUrl, error: error) + self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task) } } } @@ -1014,8 +1153,12 @@ final class NostrRelayManager: ObservableObject { } case .eose(let subId): if var tracker = eoseTrackers[subId] { - tracker.pendingRelays.remove(relayUrl) - if tracker.pendingRelays.isEmpty { + // An EOSE proves the relay received the REQ even if the local + // send completion hasn't run yet. + tracker.awaitingSend.remove(relayUrl) + tracker.awaitingEOSE.remove(relayUrl) + tracker.didSend = true + if tracker.isComplete { eoseTrackers.removeValue(forKey: subId) tracker.callback() } else { @@ -1023,6 +1166,7 @@ final class NostrRelayManager: ObservableObject { } } case .ok(let eventId, let success, let reason): + resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success) if success { _ = Self.pendingGiftWrapIDs.remove(eventId) SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session) @@ -1039,7 +1183,12 @@ final class NostrRelayManager: ObservableObject { } } - private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) { + private func sendToRelay( + event: NostrEvent, + connection: NostrRelayConnectionProtocol, + relayUrl: String, + completion: ((Bool) -> Void)? = nil + ) { let req = NostrRequest.event(event) do { @@ -1052,17 +1201,20 @@ final class NostrRelayManager: ObservableObject { DispatchQueue.main.async { if let error = error { SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) + completion?(false) } else { // SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session) // Update relay stats if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { self?.relays[index].messagesSent += 1 } + completion?(true) } } } } catch { SecureLogger.error("Failed to encode event: \(error)", category: .session) + completion?(false) } } @@ -1071,7 +1223,6 @@ final class NostrRelayManager: ObservableObject { relays[index].isConnected = isConnected relays[index].lastError = error if isConnected { - relays[index].lastConnectedAt = dependencies.now() relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].nextReconnectTime = nil } else { @@ -1087,15 +1238,20 @@ final class NostrRelayManager: ObservableObject { private func updateConnectionStatus() { isConnected = relays.contains { $0.isConnected } + // Relay URLs are normalized before entries are created, so direct + // set membership is sound. + isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) } } /// A relay that drops before sending EOSE must not stall initial-load /// callbacks; treat it as done and let the remaining relays (or the /// fallback timeout) drive completion. private func settleEOSETrackers(droppingRelay relayUrl: String) { - for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) { - tracker.pendingRelays.remove(relayUrl) - if tracker.pendingRelays.isEmpty { + for (id, var tracker) in eoseTrackers + where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) { + tracker.awaitingSend.remove(relayUrl) + tracker.awaitingEOSE.remove(relayUrl) + if tracker.isComplete { eoseTrackers.removeValue(forKey: id) tracker.callback() } else { @@ -1104,9 +1260,38 @@ final class NostrRelayManager: ObservableObject { } } - private func handleDisconnection(relayUrl: String, error: Error) { + /// Whether any of `relayUrls` currently holds a live connection. Lets + /// subscribers distinguish "loaded, empty" from "never reached a relay" + /// when an EOSE fallback fires. + func isAnyRelayConnected(among relayUrls: [String]) -> Bool { + let targets = Set(relayUrls) + return relays.contains { targets.contains($0.url) && $0.isConnected } + } + + /// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on + /// this relay instead of the never-connected remainder. + private func markEOSESubscribed(id: String, relayUrl: String) { + guard var tracker = eoseTrackers[id], + tracker.awaitingSend.remove(relayUrl) != nil else { return } + tracker.awaitingEOSE.insert(relayUrl) + tracker.didSend = true + eoseTrackers[id] = tracker + } + + private func handleDisconnection( + relayUrl: String, + error: Error, + connection: NostrRelayConnectionProtocol? = nil + ) { + if let connection, connections[relayUrl] !== connection { return } connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) + let awaitingConfirmation = confirmedSends.compactMap { eventID, state in + state.awaitingRelays.contains(relayUrl) ? eventID : nil + } + for eventID in awaitingConfirmation { + resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false) + } updateRelayStatus(relayUrl, isConnected: false, error: error) settleEOSETrackers(droppingRelay: relayUrl) // If networking is disallowed, do not schedule reconnection @@ -1456,6 +1641,29 @@ struct NostrFilter: Encodable { filter.limit = limit return filter } + + // For the mesh bridge: rendezvous messages (kind 20000) and presence + // (kind 20001) tagged `#r` with one or more cells (own + neighbors). + static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [20000, 20001] + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["r": cells] + filter.limit = limit + return filter + } + + // For courier drops: sealed envelopes (kind 1401) parked under rotating + // recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent + // UTC days x recipients) in one filter. + static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue] + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["x": recipientTagsHex] + filter.limit = limit + return filter + } } // Dynamic coding key for tag filters diff --git a/bitchat/Nostr/XChaCha20Poly1305Compat.swift b/bitchat/Nostr/XChaCha20Poly1305Compat.swift index 13794508..006cf899 100644 --- a/bitchat/Nostr/XChaCha20Poly1305Compat.swift +++ b/bitchat/Nostr/XChaCha20Poly1305Compat.swift @@ -132,4 +132,3 @@ private extension Data { replaceSubrange(offset..<(offset+4), with: bytes) } } - diff --git a/bitchat/PrivacyInfo.xcprivacy b/bitchat/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..00a28dfb --- /dev/null +++ b/bitchat/PrivacyInfo.xcprivacy @@ -0,0 +1,41 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + 1C8F.1 + + + + + diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 4ebe6509..6c259f5f 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -18,7 +18,7 @@ /// - Efficient binary message encoding /// - Message fragmentation for large payloads /// - TTL-based routing for mesh networks -/// - Privacy features like padding and timing obfuscation +/// - Privacy features: message padding and randomized relay jitter /// - Integration points for end-to-end encryption /// /// ## Protocol Design @@ -38,18 +38,20 @@ /// 7. **Decoding**: Binary data parsed back to message objects /// /// ## Security Considerations -/// - Message padding obscures actual content length -/// - Timing obfuscation prevents traffic analysis +/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length +/// - Randomized relay jitter reduces the traffic-analysis signal; there is no +/// cover traffic or per-message timing obfuscation /// - Integration with Noise Protocol for E2E encryption /// - No persistent identifiers in protocol headers /// /// ## Message Types /// - **Announce/Leave**: Peer presence notifications -/// - **Message**: User chat messages (broadcast or directed) +/// - **Message**: Public chat messages /// - **Fragment**: Multi-part message handling -/// - **Delivery/Read**: Message acknowledgments -/// - **Noise**: Encrypted channel establishment -/// - **Version**: Protocol version negotiation +/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and +/// all private payloads (messages, delivery acks, read receipts) +/// - **CourierEnvelope**: Sealed store-and-forward mail +/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer /// /// ## Future Extensions /// The protocol is designed to be extensible: @@ -72,17 +74,28 @@ enum NoisePayloadType: UInt8 { case privateMessage = 0x01 // Private chat message case readReceipt = 0x02 // Message was read case delivered = 0x03 // Message was delivered + // Private groups (0x04/0x05 reserved by other features) + case groupInvite = 0x06 // Creator-signed group state (invite) + case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) + // Live voice (push-to-talk) + case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket) // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response - + // Transitive verification (web of trust) + case vouch = 0x12 // Batch of vouch attestations + var description: String { switch self { case .privateMessage: return "privateMessage" case .readReceipt: return "readReceipt" case .delivered: return "delivered" + case .groupInvite: return "groupInvite" + case .groupKeyUpdate: return "groupKeyUpdate" + case .voiceFrame: return "voiceFrame" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" + case .vouch: return "vouch" } } } @@ -114,6 +127,12 @@ protocol BitchatDelegate: AnyObject { // Low-level events for better separation of concerns func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) + // Encrypted group broadcast (opaque envelope; decrypted by the group coordinator) + func didReceiveGroupMessage(payload: Data, timestamp: Date) + + // Public live-voice burst packet (signature-verified by the transport) + func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) + // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -133,6 +152,14 @@ extension BitchatDelegate { // Default empty implementation } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + // Default empty implementation + } + + func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) { + // Default empty implementation + } + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } diff --git a/bitchat/Protocols/BoardPackets.swift b/bitchat/Protocols/BoardPackets.swift new file mode 100644 index 00000000..d265a1c1 --- /dev/null +++ b/bitchat/Protocols/BoardPackets.swift @@ -0,0 +1,348 @@ +// +// BoardPackets.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation + +// MARK: - Board wire format (MessageType.boardPost payloads) +// +// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC: +// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone +// - 0x02: postID (16B random) +// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars) +// - 0x04: content (UTF-8, 1...512 bytes) [post] +// - 0x05: authorSigningKey (32B Ed25519 public key) +// - 0x06: authorNickname (UTF-8, max 64 bytes) +// - 0x07: createdAt (u64 big-endian, ms) [post] +// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post] +// - 0x09: flags (u8, bit0 = urgent) [post] +// - 0x0A: signature (64B Ed25519) +// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone] +// Unknown TLVs are skipped for forward compatibility. + +enum BoardWireConstants { + static let postIDLength = 16 + static let signingKeyLength = 32 + static let signatureLength = 64 + static let contentMaxBytes = 512 + static let nicknameMaxBytes = 64 + static let geohashMaxLength = 12 + /// Posts may live at most 7 days past their creation timestamp. + static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000 + static let postSigningContext = "bitchat-board-v1" + static let tombstoneSigningContext = "bitchat-board-del-v1" + static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz") +} + +private enum BoardTLVType: UInt8 { + case kind = 0x01 + case postID = 0x02 + case geohash = 0x03 + case content = 0x04 + case authorSigningKey = 0x05 + case authorNickname = 0x06 + case createdAt = 0x07 + case expiresAt = 0x08 + case flags = 0x09 + case signature = 0x0A + case deletedAt = 0x0B +} + +private enum BoardWireKind: UInt8 { + case post = 0x01 + case tombstone = 0x02 +} + +/// A signed, persistent bulletin-board notice. +struct BoardPostPacket: Equatable { + let postID: Data + /// Empty string scopes the post to the mesh-local board. + let geohash: String + let content: String + let authorSigningKey: Data + let authorNickname: String + let createdAt: UInt64 + let expiresAt: UInt64 + let flags: UInt8 + let signature: Data + + static let urgentFlag: UInt8 = 0x01 + + var isUrgent: Bool { flags & Self.urgentFlag != 0 } + + /// Canonical bytes covered by the Ed25519 signature. Variable-length + /// fields are length-prefixed so no two field combinations can collide. + static func signingBytes( + postID: Data, + geohash: String, + content: String, + authorSigningKey: Data, + authorNickname: String, + createdAt: UInt64, + expiresAt: UInt64, + flags: UInt8 + ) -> Data { + var out = Data() + BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out) + out.append(postID) + BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out) + BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out) + out.append(authorSigningKey) + BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out) + BoardWireEncoding.appendUInt64(createdAt, to: &out) + BoardWireEncoding.appendUInt64(expiresAt, to: &out) + out.append(flags) + return out + } + + var signingBytes: Data { + Self.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: authorSigningKey, + authorNickname: authorNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags + ) + } + + func verifySignature() -> Bool { + BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey) + } +} + +/// A signed deletion marker. Only the author's key can produce one; receivers +/// keep it until the post's original expiry so the delete outruns the post. +struct BoardTombstonePacket: Equatable { + let postID: Data + let authorSigningKey: Data + let deletedAt: UInt64 + let signature: Data + + static func signingBytes(postID: Data, deletedAt: UInt64) -> Data { + var out = Data() + BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out) + out.append(postID) + BoardWireEncoding.appendUInt64(deletedAt, to: &out) + return out + } + + var signingBytes: Data { + Self.signingBytes(postID: postID, deletedAt: deletedAt) + } + + func verifySignature() -> Bool { + BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey) + } +} + +/// Decoded board payload: either a live post or a tombstone. +enum BoardWire: Equatable { + case post(BoardPostPacket) + case tombstone(BoardTombstonePacket) + + func encode() -> Data { + var out = Data() + func putTLV(_ t: BoardTLVType, _ v: Data) { + out.append(t.rawValue) + let len = UInt16(v.count) + out.append(UInt8((len >> 8) & 0xFF)) + out.append(UInt8(len & 0xFF)) + out.append(v) + } + switch self { + case .post(let post): + putTLV(.kind, Data([BoardWireKind.post.rawValue])) + putTLV(.postID, post.postID) + putTLV(.geohash, Data(post.geohash.utf8)) + putTLV(.content, Data(post.content.utf8)) + putTLV(.authorSigningKey, post.authorSigningKey) + putTLV(.authorNickname, Data(post.authorNickname.utf8)) + putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt)) + putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt)) + putTLV(.flags, Data([post.flags])) + putTLV(.signature, post.signature) + case .tombstone(let tombstone): + putTLV(.kind, Data([BoardWireKind.tombstone.rawValue])) + putTLV(.postID, tombstone.postID) + putTLV(.authorSigningKey, tombstone.authorSigningKey) + putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt)) + putTLV(.signature, tombstone.signature) + } + return out + } + + /// Structural decode; the caller must still verify the signature before + /// ingesting (`verifySignature()`). + static func decode(from data: Data) -> BoardWire? { + var off = data.startIndex + var kind: BoardWireKind? + var postID: Data? + var geohash: String? + var content: String? + var contentBytes = 0 + var authorSigningKey: Data? + var authorNickname: String? + var nicknameBytes = 0 + var createdAt: UInt64? + var expiresAt: UInt64? + var flags: UInt8? + var signature: Data? + var deletedAt: UInt64? + + while off + 3 <= data.endIndex { + let t = data[off]; off += 1 + let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2 + guard off + len <= data.endIndex else { return nil } + let v = data.subdata(in: off..<(off + len)); off += len + switch BoardTLVType(rawValue: t) { + case .kind: + guard v.count == 1 else { return nil } + kind = BoardWireKind(rawValue: v[v.startIndex]) + case .postID: + guard v.count == BoardWireConstants.postIDLength else { return nil } + postID = v + case .geohash: + guard v.count <= BoardWireConstants.geohashMaxLength else { return nil } + geohash = String(data: v, encoding: .utf8) + case .content: + guard v.count <= BoardWireConstants.contentMaxBytes else { return nil } + contentBytes = v.count + content = String(data: v, encoding: .utf8) + case .authorSigningKey: + guard v.count == BoardWireConstants.signingKeyLength else { return nil } + authorSigningKey = v + case .authorNickname: + guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil } + nicknameBytes = v.count + authorNickname = String(data: v, encoding: .utf8) + case .createdAt: + createdAt = BoardWireEncoding.uint64(from: v) + case .expiresAt: + expiresAt = BoardWireEncoding.uint64(from: v) + case .flags: + guard v.count == 1 else { return nil } + flags = v[v.startIndex] + case .signature: + guard v.count == BoardWireConstants.signatureLength else { return nil } + signature = v + case .deletedAt: + deletedAt = BoardWireEncoding.uint64(from: v) + case nil: + continue // forward compatible; ignore unknown TLVs + } + } + + guard let postID, let authorSigningKey, let signature else { return nil } + + switch kind { + case .post: + guard let geohash, let content, let authorNickname, + let createdAt, let expiresAt, let flags, + contentBytes >= 1, + nicknameBytes <= BoardWireConstants.nicknameMaxBytes, + isValidGeohashField(geohash), + expiresAt > createdAt, + expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else { + return nil + } + return .post(BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: authorSigningKey, + authorNickname: authorNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags, + signature: signature + )) + case .tombstone: + guard let deletedAt else { return nil } + return .tombstone(BoardTombstonePacket( + postID: postID, + authorSigningKey: authorSigningKey, + deletedAt: deletedAt, + signature: signature + )) + case nil: + return nil + } + } + + func verifySignature() -> Bool { + switch self { + case .post(let post): return post.verifySignature() + case .tombstone(let tombstone): return tombstone.verifySignature() + } + } + + /// Cheap TLV peek for relay policy: is this payload an urgent post? + /// Avoids a full decode on the hot relay path. + static func urgentFlag(in data: Data) -> Bool { + var off = data.startIndex + while off + 3 <= data.endIndex { + let t = data[off]; off += 1 + let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2 + guard off + len <= data.endIndex else { return false } + if t == BoardTLVType.flags.rawValue, len == 1 { + return data[off] & BoardPostPacket.urgentFlag != 0 + } + off += len + } + return false + } + + /// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash + /// base32 alphabet. + private static func isValidGeohashField(_ geohash: String) -> Bool { + geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) } + } +} + +enum BoardWireEncoding { + static func appendContext(_ context: String, to out: inout Data) { + let bytes = Data(context.utf8) + out.append(UInt8(min(bytes.count, 255))) + out.append(bytes.prefix(255)) + } + + static func appendLengthPrefixed(_ value: Data, to out: inout Data) { + let len = UInt16(min(value.count, Int(UInt16.max))) + out.append(UInt8((len >> 8) & 0xFF)) + out.append(UInt8(len & 0xFF)) + out.append(value.prefix(Int(UInt16.max))) + } + + static func appendUInt64(_ value: UInt64, to out: inout Data) { + var be = value.bigEndian + withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } + } + + static func uint64Data(_ value: UInt64) -> Data { + var out = Data() + appendUInt64(value, to: &out) + return out + } + + static func uint64(from data: Data) -> UInt64? { + guard data.count == 8 else { return nil } + var value: UInt64 = 0 + for byte in data { value = (value << 8) | UInt64(byte) } + return value + } + + static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { + return false + } + return key.isValidSignature(signature, for: message) + } +} diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift index f436ea27..d0061c5c 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -10,11 +10,11 @@ enum Geohash { return map }() - /// Validates a geohash string for building-level precision (8 characters). + /// Validates a geohash string at any channel precision (1-12 characters). /// - Parameter geohash: The geohash string to validate - /// - Returns: true if valid 8-character base32 geohash, false otherwise - static func isValidBuildingGeohash(_ geohash: String) -> Bool { - guard geohash.count == 8 else { return false } + /// - Returns: true if a non-empty base32 geohash of at most 12 characters + static func isValidGeohash(_ geohash: String) -> Bool { + guard (1...12).contains(geohash.count) else { return false } return geohash.lowercased().allSatisfy { base32Map[$0] != nil } } diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index b7c0d5b6..cbe1c55f 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { case .city: return 5 case .province: return 4 case .region: return 2 - } + } } var displayName: String { diff --git a/bitchat/Protocols/MeshMessageIdentity.swift b/bitchat/Protocols/MeshMessageIdentity.swift new file mode 100644 index 00000000..1256697f --- /dev/null +++ b/bitchat/Protocols/MeshMessageIdentity.swift @@ -0,0 +1,32 @@ +// +// MeshMessageIdentity.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Content-derived identity for public mesh messages. +/// +/// The BLE wire carries no message ID for public broadcasts, so every device +/// recomputes the same stable ID from the signed wire fields (sender ID, +/// millisecond timestamp, content). That gives the mesh bridge a +/// cross-device-consistent radio identity with zero wire change. Bridge events +/// carry this value only as a hint for detecting a radio copy that is already +/// present: sender/timestamp/content are public, so a different Nostr signer +/// can copy them and must never be allowed to reserve the genuine event's +/// authenticated dedup slot. +enum MeshMessageIdentity { + /// Matches the wire truncation in `BLEService.sendMessage`. + static func millisecondTimestamp(_ date: Date) -> UInt64 { + UInt64(date.timeIntervalSince1970 * 1000) + } + + static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String { + let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed + return String(Data(input.utf8).sha256Hex().prefix(32)) + } +} diff --git a/bitchat/Protocols/NostrCarrierPacket.swift b/bitchat/Protocols/NostrCarrierPacket.swift new file mode 100644 index 00000000..8e7b7c06 --- /dev/null +++ b/bitchat/Protocols/NostrCarrierPacket.swift @@ -0,0 +1,144 @@ +// +// NostrCarrierPacket.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed +/// Nostr event ferried over the mesh between a mesh-only peer and an +/// internet gateway peer. +/// +/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer): +/// a mesh-only sender asks the gateway to publish its locally signed +/// geohash event to Nostr relays. +/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway +/// rebroadcasts inbound relay events so mesh-only peers see the channel. +/// +/// The carried event is public geohash chat — already plaintext on Nostr — +/// so the carrier adds no encryption. It IS signed by the originator's +/// per-geohash identity, so neither the gateway nor any mesh relay can forge +/// or alter it undetected: gateways and receivers verify the Schnorr +/// signature before acting on it. +/// +/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the +/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped +/// for forward compatibility. +struct NostrCarrierPacket: Equatable { + enum Direction: UInt8 { + case toGateway = 0x01 + case fromGateway = 0x02 + /// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to + /// publish its signed rendezvous event. Directed, like `toGateway`. + case toBridge = 0x03 + /// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous + /// event from a remote island. Broadcast, like `fromGateway`. + /// Old clients fail the Direction decode on 0x03/0x04 and drop the + /// carrier quietly — bridge traffic degrades to invisible, not junk. + case fromBridge = 0x04 + } + + let direction: Direction + let geohash: String + /// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags, + /// content, sig). + let eventJSON: Data + + /// BLE airtime cap for a carried event. + static let maxEventJSONBytes = 16 * 1024 + static let maxGeohashLength = 12 + + private enum TLVType: UInt8 { + case direction = 0x01 + case geohash = 0x02 + case eventJSON = 0x03 + } + + init?(direction: Direction, geohash: String, eventJSON: Data) { + let geohashBytes = Data(geohash.utf8) + guard !geohashBytes.isEmpty, + geohashBytes.count <= Self.maxGeohashLength, + !eventJSON.isEmpty, + eventJSON.count <= Self.maxEventJSONBytes else { + return nil + } + self.direction = direction + self.geohash = geohash + self.eventJSON = eventJSON + } + + init?(direction: Direction, geohash: String, event: NostrEvent) { + guard let json = try? event.jsonString(), !json.isEmpty else { return nil } + self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8)) + } + + /// Decodes the carried event. Callers MUST still verify + /// `event.isValidSignature()` before publishing or displaying it. + func event() -> NostrEvent? { + guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else { + return nil + } + return try? NostrEvent(from: dict) + } + + func encode() -> Data? { + var data = Data() + data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12) + + func appendTLV(_ type: TLVType, _ value: Data) { + data.append(type.rawValue) + data.append(UInt8((value.count >> 8) & 0xFF)) + data.append(UInt8(value.count & 0xFF)) + data.append(value) + } + + appendTLV(.direction, Data([direction.rawValue])) + appendTLV(.geohash, Data(geohash.utf8)) + appendTLV(.eventJSON, eventJSON) + return data + } + + static func decode(_ data: Data) -> NostrCarrierPacket? { + // Defensive slice re-base (Data slices keep parent indices). + let data = Data(data) + var offset = 0 + var direction: Direction? + var geohash: String? + var eventJSON: Data? + + while offset + 3 <= data.count { + let typeRaw = data[offset] + let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2]) + offset += 3 + guard offset + length <= data.count else { return nil } + let value = data.subdata(in: offset.. Data? { @@ -48,6 +72,24 @@ struct AnnouncementPacket { } } + // TLV for capabilities (optional) + if let capabilities = capabilities { + let capabilityBytes = capabilities.encoded() + guard capabilityBytes.count <= 255 else { return nil } + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(capabilityBytes.count)) + data.append(capabilityBytes) + } + + // TLV for bridge rendezvous cell (optional; old clients skip it) + if let bridgeGeohash = bridgeGeohash, + let cellData = bridgeGeohash.data(using: .utf8), + !cellData.isEmpty, cellData.count <= 12 { + data.append(TLVType.bridgeGeohash.rawValue) + data.append(UInt8(cellData.count)) + data.append(cellData) + } + return data } @@ -57,6 +99,8 @@ struct AnnouncementPacket { var noisePublicKey: Data? var signingPublicKey: Data? var directNeighbors: [Data]? + var capabilities: PeerCapabilities? + var bridgeGeohash: String? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -87,6 +131,12 @@ struct AnnouncementPacket { } directNeighbors = neighbors } + case .capabilities: + capabilities = PeerCapabilities(encoded: Data(value)) + case .bridgeGeohash: + if length <= 12 { + bridgeGeohash = String(data: value, encoding: .utf8) + } } } else { // Unknown TLV; skip (tolerant decoder for forward compatibility) @@ -99,7 +149,9 @@ struct AnnouncementPacket { nickname: nickname, noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, - directNeighbors: directNeighbors + directNeighbors: directNeighbors, + capabilities: capabilities, + bridgeGeohash: bridgeGeohash ) } } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift new file mode 100644 index 00000000..819464b9 --- /dev/null +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -0,0 +1,7 @@ +import BitFoundation + +extension PeerCapabilities { + /// Capabilities this build advertises in its announce packets. + /// Each feature adds its bit here when it ships. + static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups] +} diff --git a/bitchat/Protocols/VoiceBurstPacket.swift b/bitchat/Protocols/VoiceBurstPacket.swift new file mode 100644 index 00000000..3d534175 --- /dev/null +++ b/bitchat/Protocols/VoiceBurstPacket.swift @@ -0,0 +1,220 @@ +// +// VoiceBurstPacket.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Security + +/// Audio codec of a live voice burst. START packets carry it so receivers can +/// reject bursts they can't decode instead of feeding garbage to the decoder. +enum VoiceBurstCodec: UInt8 { + /// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so + /// the finalized `.m4a` and the live frames come from the same encoder + /// settings. + case aacLC16kMono = 0x01 +} + +/// One packet of a live push-to-talk voice burst (the inner payload of +/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload +/// of `MessageType.voiceFrame`). +/// +/// Wire format: +/// ``` +/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…] +/// ``` +/// - flags 0x01 (START): payload = [codec: UInt8] +/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE] +/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst +/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame] +struct VoiceBurstPacket: Equatable { + enum Kind: Equatable { + case start(codec: VoiceBurstCodec) + case frames([Data]) + case end(totalDataPackets: UInt16, durationMs: UInt32) + case canceled + } + + static let burstIDSize = 8 + private static let headerSize = burstIDSize + 2 + 1 + /// Sanity cap on frames per packet; real packets carry 1-2 frames. + static let maxFramesPerPacket = 8 + + private enum Flags { + static let start: UInt8 = 0x01 + static let end: UInt8 = 0x02 + static let canceled: UInt8 = 0x04 + } + + let burstID: Data + let seq: UInt16 + let kind: Kind + + init?(burstID: Data, seq: UInt16, kind: Kind) { + guard burstID.count == Self.burstIDSize else { return nil } + if case .frames(let frames) = kind { + guard !frames.isEmpty, + frames.count <= Self.maxFramesPerPacket, + frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) }) + else { return nil } + } + self.burstID = burstID + self.seq = seq + self.kind = kind + } + + func encode() -> Data { + var data = Data(capacity: Self.headerSize + payloadSize) + data.append(burstID) + data.append(UInt8((seq >> 8) & 0xFF)) + data.append(UInt8(seq & 0xFF)) + switch kind { + case .start(let codec): + data.append(Flags.start) + data.append(codec.rawValue) + case .frames(let frames): + data.append(0) + for frame in frames { + let length = UInt16(frame.count) + data.append(UInt8((length >> 8) & 0xFF)) + data.append(UInt8(length & 0xFF)) + data.append(frame) + } + case .end(let totalDataPackets, let durationMs): + data.append(Flags.end) + data.append(UInt8((totalDataPackets >> 8) & 0xFF)) + data.append(UInt8(totalDataPackets & 0xFF)) + for shift in stride(from: 24, through: 0, by: -8) { + data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF)) + } + case .canceled: + data.append(Flags.canceled) + } + return data + } + + static func decode(_ data: Data) -> VoiceBurstPacket? { + // Work on a re-based copy so subscripting is offset-safe. + let data = Data(data) + guard data.count >= headerSize else { return nil } + + let burstID = data.prefix(burstIDSize) + let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1]) + let flags = data[burstIDSize + 2] + let payload = data.dropFirst(headerSize) + + let kind: Kind + switch flags { + case Flags.start: + guard let codecByte = payload.first, + let codec = VoiceBurstCodec(rawValue: codecByte) + else { return nil } + kind = .start(codec: codec) + case Flags.end: + guard payload.count >= 6 else { return nil } + let bytes = Array(payload) + let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1]) + let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + kind = .end(totalDataPackets: total, durationMs: duration) + case Flags.canceled: + kind = .canceled + case 0: + var frames: [Data] = [] + var offset = payload.startIndex + while offset < payload.endIndex { + guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil } + let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)]) + offset = payload.index(offset, offsetBy: 2) + guard length > 0, + payload.distance(from: offset, to: payload.endIndex) >= length, + frames.count < maxFramesPerPacket + else { return nil } + let end = payload.index(offset, offsetBy: length) + frames.append(Data(payload[offset.. Data { + var bytes = Data(count: burstIDSize) + let result = bytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!) + } + guard result == errSecSuccess else { + return Data((0.. [Data] { + let frameCost = 2 + frame.count + guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] } + + var packets: [Data] = [] + if !pendingFrames.isEmpty, + VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget + || pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket { + packets.append(contentsOf: flush()) + } + pendingFrames.append(frame) + pendingSize += frameCost + return packets + } + + /// Emits any buffered frames as a final data packet. + mutating func flush() -> [Data] { + guard !pendingFrames.isEmpty, + let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames)) + else { + pendingFrames = [] + pendingSize = 0 + return [] + } + pendingFrames = [] + pendingSize = 0 + nextSeq &+= 1 + dataPacketCount &+= 1 + return [packet.encode()] + } +} diff --git a/bitchat/Protocols/VouchAttestation.swift b/bitchat/Protocols/VouchAttestation.swift new file mode 100644 index 00000000..a765a11b --- /dev/null +++ b/bitchat/Protocols/VouchAttestation.swift @@ -0,0 +1,225 @@ +// +// VouchAttestation.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation + +/// A signed statement that the *sender of the enclosing Noise payload* has +/// verified the identity described here ("transitive verification"). +/// +/// The voucher's identity is deliberately implicit: attestations only travel +/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the +/// receiver verifies the Ed25519 signature against the session peer's +/// announce-bound signing key and stores the vouch keyed by that peer's +/// fingerprint. Nothing in the attestation names the voucher, so a captured +/// attestation cannot be replayed by a third party whose signing key doesn't +/// match. +/// +/// Wire format — single attestation (TLV, 1-byte type + 1-byte length): +/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key +/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity +/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970 +/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over +/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp` +/// +/// Unknown TLV types are skipped for forward compatibility. +/// +/// Batch format (the `vouch` Noise payload body): +/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`. +struct VouchAttestation: Equatable { + static let signingContext = "bitchat-vouch-v1" + /// Receiver-side expiry for attestations. + static let maxAge: TimeInterval = 30 * 24 * 60 * 60 + /// Tolerated clock skew for attestations timestamped in the future. + static let maxClockSkew: TimeInterval = 60 * 60 + /// Upper bound of attestations carried/accepted in one batch payload. + static let maxBatchCount = 16 + + static let fingerprintSize = 32 + static let signingKeySize = 32 + static let signatureSize = 64 + + let voucheeFingerprint: Data // 32 bytes + let voucheeSigningKey: Data // 32 bytes + let timestampMs: UInt64 + let signature: Data // 64 bytes + + private enum TLVType: UInt8 { + case voucheeFingerprint = 0x01 + case voucheeSigningKey = 0x02 + case timestamp = 0x03 + case signature = 0x04 + } + + var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() } + + var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) } + + /// The exact bytes the voucher signs. + static func signableBytes( + voucheeFingerprint: Data, + voucheeSigningKey: Data, + timestampMs: UInt64 + ) -> Data { + var message = Data(signingContext.utf8) + message.append(voucheeFingerprint) + message.append(voucheeSigningKey) + var timestampBE = timestampMs.bigEndian + withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) } + return message + } + + var signableBytes: Data { + Self.signableBytes( + voucheeFingerprint: voucheeFingerprint, + voucheeSigningKey: voucheeSigningKey, + timestampMs: timestampMs + ) + } + + /// Builds and signs an attestation. `sign` is the voucher's Ed25519 + /// signing primitive (e.g. `Transport.noiseSignData`). + static func build( + voucheeFingerprint: Data, + voucheeSigningKey: Data, + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), + sign: (Data) -> Data? + ) -> VouchAttestation? { + guard voucheeFingerprint.count == fingerprintSize, + voucheeSigningKey.count == signingKeySize else { return nil } + let message = signableBytes( + voucheeFingerprint: voucheeFingerprint, + voucheeSigningKey: voucheeSigningKey, + timestampMs: timestampMs + ) + guard let signature = sign(message), signature.count == signatureSize else { return nil } + return VouchAttestation( + voucheeFingerprint: voucheeFingerprint, + voucheeSigningKey: voucheeSigningKey, + timestampMs: timestampMs, + signature: signature + ) + } + + /// Verifies the Ed25519 signature against the voucher's announce-bound + /// signing key. + func verifySignature(voucherSigningKey: Data) -> Bool { + guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else { + return false + } + return publicKey.isValidSignature(signature, for: signableBytes) + } + + /// Whether the attestation is outside its validity window (older than + /// `maxAge`, or timestamped implausibly far in the future). + func isExpired(now: Date = Date()) -> Bool { + let age = now.timeIntervalSince(timestamp) + return age > Self.maxAge || age < -Self.maxClockSkew + } + + // MARK: - Encoding + + func encode() -> Data? { + guard voucheeFingerprint.count == Self.fingerprintSize, + voucheeSigningKey.count == Self.signingKeySize, + signature.count == Self.signatureSize else { return nil } + var data = Data() + func appendTLV(_ type: TLVType, _ value: Data) { + data.append(type.rawValue) + data.append(UInt8(value.count)) + data.append(value) + } + appendTLV(.voucheeFingerprint, voucheeFingerprint) + appendTLV(.voucheeSigningKey, voucheeSigningKey) + var timestampBE = timestampMs.bigEndian + appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) }) + appendTLV(.signature, signature) + return data + } + + static func decode(from data: Data) -> VouchAttestation? { + var fingerprint: Data? + var signingKey: Data? + var timestampMs: UInt64? + var signature: Data? + + var offset = data.startIndex + while offset < data.endIndex { + guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil, + offset + 1 < data.endIndex else { return nil } + let type = data[offset] + let length = Int(data[offset + 1]) + let valueStart = offset + 2 + guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else { + return nil + } + let value = Data(data[valueStart.. Data? { + guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil } + var data = Data() + data.append(UInt8(attestations.count)) + for attestation in attestations { + guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil } + var lengthBE = UInt16(encoded.count).bigEndian + withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) } + data.append(encoded) + } + return data + } + + /// Decodes a batch payload, dropping malformed entries and ignoring + /// anything beyond `maxBatchCount` (sender-declared count is not trusted). + static func decodeList(from data: Data) -> [VouchAttestation] { + guard data.count > 1 else { return [] } + let declaredCount = Int(data[data.startIndex]) + let limit = min(declaredCount, maxBatchCount) + var attestations: [VouchAttestation] = [] + var offset = data.startIndex + 1 + while attestations.count < limit, offset < data.endIndex { + guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break } + let length = Int(data[offset]) << 8 | Int(data[offset + 1]) + guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break } + if let attestation = decode(from: Data(data[lengthEnd.. (suggestions: [String], range: NSRange?) { let textToPosition = String(text.prefix(cursorPosition)) @@ -73,26 +66,6 @@ final class AutocompleteService { return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) } - private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? { - guard let regex = commandRegex else { return nil } - - let nsText = text as NSString - let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length)) - - guard let match = matches.last else { return nil } - - let fullRange = match.range(at: 0) - let captureRange = match.range(at: 1) - let prefix = nsText.substring(with: captureRange).lowercased() - - let suggestions = commands - .filter { $0.hasPrefix("/\(prefix)") } - .sorted() - .prefix(5) - - return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) - } - private func needsArgument(command: String) -> Bool { switch command { case "/who", "/clear": diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index fbd594a2..306831ce 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -20,6 +20,12 @@ struct BLEAnnounceHandlerEnvironment { let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Direct link state for the peer (BLE-queue read). let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) + /// Whether the link this packet arrived on is already bound to a + /// different peer ID (ingress-registry + BLE-queue read). Directness + /// rides on the unsigned TTL, so a replayed announce can look "direct" + /// on the replayer's link; that link must not shortcut an absent peer + /// into "connected". + let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool /// Runs the registry mutation phase under the collections barrier. let withRegistryBarrier: (() -> Void) -> Void /// Upserts the verified announce into the peer registry. @@ -59,6 +65,15 @@ struct BLEAnnounceHandlerEnvironment { let scheduleAfterglow: (TimeInterval) -> Void } +/// Outcome of an accepted announce, surfaced so the service can run +/// follow-up work (e.g. courier handover) that keys off the announce. +struct BLEAnnounceHandlingResult { + let peerID: PeerID + let announcement: AnnouncementPacket + let isDirectAnnounce: Bool + let isVerified: Bool +} + /// Orchestrates inbound announce packets: preflight validation, signature /// trust, registry/topology updates, identity persistence, UI notification, /// gossip tracking, and the reciprocal announce response. @@ -69,7 +84,8 @@ final class BLEAnnounceHandler { self.environment = environment } - func handle(_ packet: BitchatPacket, from peerID: PeerID) { + @discardableResult + func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? { let env = environment let now = env.now() let preflight = BLEAnnouncePreflightPolicy.evaluate( @@ -85,15 +101,15 @@ final class BLEAnnounceHandler { announcement = acceptance.announcement case .reject(.malformed): SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) - return + return nil case .reject(.senderMismatch(let derivedFromKey)): SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) - return + return nil case .reject(.selfAnnounce): - return + return nil case .reject(.stale(let ageSeconds)): SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) - return + return nil } // Suppress announce logs to reduce noise @@ -125,6 +141,23 @@ final class BLEAnnounceHandler { var isReconnectedPeer = false let directLinkState = env.linkState(peerID) let isDirectAnnounce = packet.ttl == env.messageTTL + // A "direct" announce arriving on a link that another peer already + // owns is either a rotation heal or a replay with its TTL restored; + // both are ambiguous, so only the rebind (which containment-checks + // the claimed identity) may promote it — never this shortcut. + // + // Known limitation: denying the shortcut cannot prevent forged + // presence outright. A rebind that passes the containment checks + // promotes the claimed peer to connected — it must, or a legitimate + // rotation on an open link would read as disconnected — so a replay + // that wins the rebind (absent victim, cooldown clear) still forges + // presence. That residue is presence display only: DMs stay gated on + // canDeliverSecurely (no Noise session means retain + courier, see + // MessageRouter.sendPrivate). What this check buys: the ambiguous + // announce alone never flips presence — forging requires winning the + // containment-checked rebind (never steals an identity that owns a + // live link; at most one rebind per link per cooldown window). + let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID) env.withRegistryBarrier { let hasPeripheralConnection = directLinkState.hasPeripheral @@ -142,7 +175,7 @@ final class BLEAnnounceHandler { let update = env.upsertVerifiedAnnounce( peerID, announcement, - isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), now ) isNewPeer = update.isNewPeer @@ -210,5 +243,12 @@ final class BLEAnnounceHandler { let delay = Double.random(in: 0.3...0.6) env.scheduleAfterglow(delay) } + + return BLEAnnounceHandlingResult( + peerID: peerID, + announcement: announcement, + isDirectAnnounce: isDirectAnnounce, + isVerified: verifiedAnnounce + ) } } diff --git a/bitchat/Services/BLE/BLEFanoutSelector.swift b/bitchat/Services/BLE/BLEFanoutSelector.swift index aa99a8f1..8cc8aefe 100644 --- a/bitchat/Services/BLE/BLEFanoutSelector.swift +++ b/bitchat/Services/BLE/BLEFanoutSelector.swift @@ -15,21 +15,59 @@ enum BLEFanoutSelector { excludedLinks: Set = [], peripheralPeerBindings: [String: PeerID] = [:], centralPeerBindings: [String: PeerID] = [:], + preferredPeripheralPerPeer: [PeerID: String] = [:], + collapseDuplicatePeerLinks: Bool = true, directedPeerHint: PeerID?, + requireDirectPeerLink: Bool = false, packetType: UInt8, messageID: String ) -> BLEFanoutSelection { - let allowed = collapseDuplicateLinksPerPeer( - allowedLinks( - peripheralIDs: peripheralIDs, - centralIDs: centralIDs, - ingressLink: ingressLink, - excludedLinks: excludedLinks - ), - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings + let rawAllowed = allowedLinks( + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + ingressLink: ingressLink, + excludedLinks: excludedLinks ) + if let directedPeerHint, + let directedSelection = directLinks( + to: directedPeerHint, + links: rawAllowed, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings, + preferredPeripheralPerPeer: preferredPeripheralPerPeer + ) { + return directedSelection + } + if directedPeerHint != nil, requireDirectPeerLink { + return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) + } + if let directedPeerHint, + hasBoundLink( + to: directedPeerHint, + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings + ) { + return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) + } + + // Direct announces are the packet that binds a link to its peer + // (BLEService's raw bind and verified rebind). Collapsing them per + // peer starves duplicate same-peer links of the announce they need to + // become bound — the duplicates then look "pre-announce" forever and + // every broadcast sprays down all of them. Announces are small and + // throttled, so they go on every live link. + let allowed = collapseDuplicatePeerLinks + ? collapseDuplicateLinksPerPeer( + rawAllowed, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings, + preferredPeripheralPerPeer: preferredPeripheralPerPeer + ) + : rawAllowed + guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { return BLEFanoutSelection( peripheralIDs: Set(allowed.peripheralIDs), @@ -71,6 +109,44 @@ enum BLEFanoutSelector { return (allowedPeripheralIDs, allowedCentralIDs) } + private static func directLinks( + to peerID: PeerID, + links: (peripheralIDs: [String], centralIDs: [String]), + peripheralPeerBindings: [String: PeerID], + centralPeerBindings: [String: PeerID], + preferredPeripheralPerPeer: [PeerID: String] + ) -> BLEFanoutSelection? { + let directLinks = collapseDuplicateLinksPerPeer( + ( + peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID }, + centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID } + ), + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings, + preferredPeripheralPerPeer: preferredPeripheralPerPeer + ) + + guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else { + return nil + } + + return BLEFanoutSelection( + peripheralIDs: Set(directLinks.peripheralIDs), + centralIDs: Set(directLinks.centralIDs) + ) + } + + private static func hasBoundLink( + to peerID: PeerID, + peripheralIDs: [String], + centralIDs: [String], + peripheralPeerBindings: [String: PeerID], + centralPeerBindings: [String: PeerID] + ) -> Bool { + peripheralIDs.contains { peripheralPeerBindings[$0] == peerID } + || centralIDs.contains { centralPeerBindings[$0] == peerID } + } + // Dual-role pairs hold two live links (we-as-central writing to their // peripheral, and they-as-central subscribed to ours). Sending the same // packet down both doubles airtime for nothing — the receiver's assembler @@ -82,7 +158,8 @@ enum BLEFanoutSelector { private static func collapseDuplicateLinksPerPeer( _ links: (peripheralIDs: [String], centralIDs: [String]), peripheralPeerBindings: [String: PeerID], - centralPeerBindings: [String: PeerID] + centralPeerBindings: [String: PeerID], + preferredPeripheralPerPeer: [PeerID: String] ) -> (peripheralIDs: [String], centralIDs: [String]) { guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else { return links @@ -90,13 +167,30 @@ enum BLEFanoutSelector { var seenPeers = Set() var keptPeripheralIDs: [String] = [] + // When a peer has several bound peripheral links (duplicate + // connections after a restore), collapse onto its preferred one (the + // most recently bound) instead of dictionary order — an arbitrary + // pick could route a peer's single collapsed copy down a stale link. for id in links.peripheralIDs { - if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted { - continue + guard let peer = peripheralPeerBindings[id], + preferredPeripheralPerPeer[peer] == id, + seenPeers.insert(peer).inserted else { continue } + keptPeripheralIDs.append(id) + } + for id in links.peripheralIDs { + if let peer = peripheralPeerBindings[id] { + if preferredPeripheralPerPeer[peer] == id { continue } + if !seenPeers.insert(peer).inserted { continue } } keptPeripheralIDs.append(id) } + // Known limitation: centrals collapse in subscription order (oldest + // first) — there is no recency signal like the peripheral reverse + // map. A central-only peer with duplicate subscriptions rides the + // oldest one until the remote side (which owns those connections) + // consolidates on its next verified announce (bounded by its + // retirement cooldown). var keptCentralIDs: [String] = [] for id in links.centralIDs { if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted { diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 43d13aa9..015a1967 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -14,6 +14,8 @@ struct BLEFileTransferHandlerEnvironment { let localNickname: () -> String /// Snapshot of known peers keyed by ID (registry read). let peersSnapshot: () -> [PeerID: BLEPeerInfo] + /// Verifies a packet's signature against a candidate signing key (registry path). + let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Resolves a display name from a verified packet signature for peers missing from the registry. let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? /// Tracks the broadcast file packet for gossip sync. @@ -44,25 +46,32 @@ final class BLEFileTransferHandler { self.environment = environment } - func handle(_ packet: BitchatPacket, from peerID: PeerID) { + /// Returns `false` when the packet fails sender authentication and must + /// not be relayed onward. Every other outcome returns `true`: files + /// directed to another peer are forwarded untouched, and local-only drops + /// (malformed payload, quota, save failure) don't affect multi-hop + /// delivery to nodes that may handle them fine. + @discardableResult + func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { let env = environment - if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return } - - let peersSnapshot = env.peersSnapshot() - guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: env.localPeerID(), - localNickname: env.localNickname(), - peers: peersSnapshot, - allowConnectedUnverified: true - ) ?? env.signedSenderDisplayName(packet, peerID) else { - SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) - return - } + if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true } guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else { - return + return true } + + let peersSnapshot = env.peersSnapshot() + guard let senderNickname = resolveSenderNickname( + packet: packet, + from: peerID, + isBroadcast: !deliveryPlan.isPrivateMessage, + peers: peersSnapshot, + env: env + ) else { + SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + return false + } + if deliveryPlan.shouldTrackForSync { env.trackPacketSeen(packet) } @@ -75,16 +84,16 @@ final class BLEFileTransferHandler { mime = acceptance.mime case .failure(.malformedPayload): SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return + return true case .failure(.payloadTooLarge(let bytes)): SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return + return true case .failure(.unsupportedMime(let mimeType, let bytes)): SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) - return + return true case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) - return + return true } // BCH-01-002: Enforce storage quota before saving @@ -97,7 +106,7 @@ final class BLEFileTransferHandler { mime.defaultExtension, mime.category.rawValue ) else { - return + return true } if deliveryPlan.isPrivateMessage { @@ -113,11 +122,66 @@ final class BLEFileTransferHandler { originalSender: nil, isPrivate: deliveryPlan.isPrivateMessage, recipientNickname: nil, - senderPeerID: peerID + senderPeerID: peerID, + // Received messages need an explicit status: BitchatMessage + // defaults private messages to .sending, which the media views + // render as an in-flight send (empty reveal mask, disabled tap). + deliveryStatus: deliveryPlan.isPrivateMessage + ? .delivered(to: env.localNickname(), at: ts) + : nil ) SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) env.deliverMessage(message) + return true + } + + /// Resolves the authenticated display name for a file transfer's sender. + /// + /// Directed (private) transfers are addressed to us specifically and keep + /// the lenient connected-peer path. Broadcast transfers carry an + /// attacker-controllable `senderID` exactly like public messages and public + /// voice frames — registry membership alone is NOT proof of identity, so a + /// valid packet signature from the claimed sender is required before we + /// trust it. Without this, a peer that observed a public voice burst could + /// spoof a broadcast `voice_.m4a` note under the talker's ID and + /// overwrite the signature-verified live bubble with attacker audio. + private func resolveSenderNickname( + packet: BitchatPacket, + from peerID: PeerID, + isBroadcast: Bool, + peers: [PeerID: BLEPeerInfo], + env: BLEFileTransferHandlerEnvironment + ) -> String? { + guard isBroadcast else { + return BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peers, + allowConnectedUnverified: true + ) ?? env.signedSenderDisplayName(packet, peerID) + } + + // Our own broadcasts replayed back via gossip sync (ttl==0) are + // trivially authentic and cannot be verified against the peer registry + // or identity cache, so exempt self exactly as `BLEPublicMessageHandler` + // does. Verify against the signing key already in the + // (synchronously-updated) registry first, then fall back to the + // persisted-identity signature lookup for peers not yet cached there. + let isSelf = peerID == env.localPeerID() + let registrySigningKey = peers[peerID]?.signingPublicKey + let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false) + let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID) + guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil } + + return BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peers, + allowConnectedUnverified: false + ) ?? signedDisplayName } } diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift index 4d41b2d3..9550e196 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -61,9 +61,11 @@ struct BLEFragmentAssemblyBuffer { } private struct Metadata { - let type: UInt8 let total: Int let timestamp: Date + let isBroadcast: Bool + var lastFragmentAt: Date + var lastResyncRequestAt: Date? } private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:] @@ -105,7 +107,15 @@ struct BLEFragmentAssemblyBuffer { return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started) } + // Only actual progress resets the stall clock: fragment packets + // bypass the packet deduplicator, so relayed duplicates of an + // already-held index must not keep suppressing the targeted + // REQUEST_SYNC for a stalled stream. + let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil fragmentsByKey[header.key]?[header.index] = header.fragmentData + if isNewIndex { + metadataByKey[header.key]?.lastFragmentAt = now + } guard let fragments = fragmentsByKey[header.key], fragments.count == header.total else { @@ -138,10 +148,58 @@ struct BLEFragmentAssemblyBuffer { } fragmentsByKey[header.key] = [:] - metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now) + metadataByKey[header.key] = Metadata( + total: header.total, + timestamp: now, + isBroadcast: header.isBroadcastFragment, + lastFragmentAt: now + ) return true } + /// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast + /// reassemblies that have not seen a new fragment for `stalledAfter` + /// seconds — candidates for a targeted REQUEST_SYNC. Each returned + /// stream is marked so it is not re-requested within `retryAfter`. + /// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are + /// returned per pass — the wire filter cannot carry more — selected + /// oldest-stall first; overflow streams stay unmarked and eligible for + /// the next pass. Directed reassemblies are excluded: peers only archive + /// broadcast fragments for gossip sync, so a targeted request cannot + /// recover them. + mutating func stalledBroadcastFragmentIDs( + stalledAfter: TimeInterval, + retryAfter: TimeInterval, + now: Date = Date() + ) -> [Data] { + var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = [] + for (key, metadata) in metadataByKey { + guard metadata.isBroadcast, + let fragments = fragmentsByKey[key], + fragments.count < metadata.total, + now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue } + if let lastRequest = metadata.lastResyncRequestAt, + now.timeIntervalSince(lastRequest) < retryAfter { continue } + candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt)) + } + + // Mark only the streams that will actually go on the wire, so the + // overflow is not silently suppressed for `retryAfter`. + let selected = candidates + .sorted { + if $0.lastFragmentAt != $1.lastFragmentAt { + return $0.lastFragmentAt < $1.lastFragmentAt + } + return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id) + } + .prefix(RequestSyncPacket.maxFragmentIdFilterCount) + + return selected.map { candidate in + metadataByKey[candidate.key]?.lastResyncRequestAt = now + return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) } + } + } + private static func assemblyLimit(for originalType: UInt8) -> Int { if originalType == MessageType.fileTransfer.rawValue { // Allow headroom for TLV metadata and binary framing overhead. diff --git a/bitchat/Services/BLE/BLEFragmentHandler.swift b/bitchat/Services/BLE/BLEFragmentHandler.swift index da65fd36..9d913151 100644 --- a/bitchat/Services/BLE/BLEFragmentHandler.swift +++ b/bitchat/Services/BLE/BLEFragmentHandler.swift @@ -33,13 +33,21 @@ final class BLEFragmentHandler { func handle(_ packet: BitchatPacket, from peerID: PeerID) { let env = environment - // Don't process our own fragments + guard let header = BLEFragmentHeader(packet: packet) else { return } + + // Sync replay legitimately hands us our own fragments back (the RSR + // ttl=0 restore path): after a relaunch the fragment store starts + // empty, so our sync filter doesn't cover them and peers re-offer + // them. Record them as seen — the next round's filter then covers + // them and the redelivery stops — but skip assembly: we authored + // the original, there is nothing to reassemble. if peerID == env.localPeerID() { + if header.isBroadcastFragment { + env.trackPacketSeen(packet) + } return } - guard let header = BLEFragmentHeader(packet: packet) else { return } - if header.isBroadcastFragment { env.trackPacketSeen(packet) } diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index 3379cfe5..214c3ba2 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -5,7 +5,16 @@ import Foundation struct BLEIncomingFileStore { private static let quotaBytes: Int64 = 100 * 1024 * 1024 - private let fileManager: FileManager + /// Name prefix of in-flight live voice captures (progressively written by + /// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern — + /// deleting one mid-stream unlinks the inode under an open `FileHandle` + /// and kills playback — and the coordinator's startup sweep deletes any + /// orphans a previous session left behind. + static let liveCapturePrefix = "voice_live_" + + /// Exposed so callers that write progressively into the store's + /// directories (live voice captures) share the same file manager. + let fileManager: FileManager private let baseDirectory: URL? private let dateProvider: () -> Date @@ -15,6 +24,14 @@ struct BLEIncomingFileStore { self.dateProvider = dateProvider } + /// Resolves (and creates) an incoming-media directory for callers that + /// write progressively instead of via `save` (live voice captures). + func incomingDirectory(subdirectory: String) throws -> URL { + let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + return directory + } + func save( data: Data, preferredName: String?, @@ -39,6 +56,11 @@ struct BLEIncomingFileStore { } } + /// Frees least-recently-modified incoming files until `reservingBytes` + /// fits under the quota. Files named `voice_live_*` (in-flight live + /// captures) are never evicted regardless of who triggers enforcement — + /// a finalized transfer can arrive at quota while a burst is still + /// streaming — but they still count toward usage. func enforceQuota(reservingBytes: Int) { do { let base = try filesDirectory() @@ -72,6 +94,7 @@ struct BLEIncomingFileStore { var freedSpace: Int64 = 0 for file in allFiles.sorted(by: { $0.modified < $1.modified }) { guard freedSpace < needToFree else { break } + guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } do { try fileManager.removeItem(at: file.url) freedSpace += file.size diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift index ba41648c..970921b1 100644 --- a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift +++ b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift @@ -78,10 +78,21 @@ struct BLEIngressLinkRegistry { return .failure(.selfLoopback(packetType: packet.type)) } - if let boundPeerID, - boundPeerID != claimedSenderID, - requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) { - return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) + if let boundPeerID, boundPeerID != claimedSenderID { + if requiresDirectSenderBinding(packet) { + return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) + } + // A direct announce claiming a new sender on a bound link is either + // a spoof or a legitimate peer-ID rotation on a connection that + // outlived the old ID. Attribute it to the claimed sender and let + // it through: announces are self-authenticating, and only a + // signature-verified announce may rebind the link (BLEService). + if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) { + return .success(BLEIngressPacketContext( + receivedFromPeerID: claimedSenderID, + validationPeerID: claimedSenderID + )) + } } let receivedFromPeerID = boundPeerID ?? claimedSenderID @@ -98,7 +109,14 @@ struct BLEIngressLinkRegistry { return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" } - private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { + private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool { + // REQUEST_SYNC is never relayed, so on a bound link the claimed sender + // must be the link peer — it elicits a full store replay, and the + // response is addressed to whoever the sender claims to be. + packet.type == MessageType.requestSync.rawValue + } + + static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL } diff --git a/bitchat/Services/BLE/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index 54fad9b9..31914092 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -164,7 +164,11 @@ final class BLELinkStateStore { guard let peerID else { return [] } var links: Set = [] - if let peripheralUUID = peerToPeripheralUUID[peerID] { + // Scan all states rather than the 1:1 reverse map: after a state + // restoration the same device can hold several live peripheral links + // bound to one peer (it reappears under a fresh UUID while the + // restored connection lives on). + for (peripheralUUID, state) in peripherals where state.peerID == peerID { links.insert(.peripheral(peripheralUUID)) } for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID { @@ -173,6 +177,13 @@ final class BLELinkStateStore { return links } + /// The peer's most recently bound peripheral link, per peer. Used to keep + /// duplicate-link fanout collapse deterministic (see BLEFanoutSelector). + var preferredPeripheralBindings: [PeerID: String] { + assertOwned() + return peerToPeripheralUUID + } + func peerID(forPeripheralID peripheralID: String) -> PeerID? { assertOwned() return peripherals[peripheralID]?.peerID @@ -203,16 +214,37 @@ final class BLELinkStateStore { func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { assertOwned() - if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil { - peerToPeripheralUUID[peerID] = peripheralUUID + var previousPeerID: PeerID? + let updated = updatePeripheral(peripheralUUID) { + previousPeerID = $0.peerID + $0.peerID = peerID } + guard updated != nil else { return } + // Rebinding (peer-ID rotation): drop the retired ID's reverse mapping + // so the old peer no longer claims this link. + if let previousPeerID, previousPeerID != peerID, + peerToPeripheralUUID[previousPeerID] == peripheralUUID { + peerToPeripheralUUID.removeValue(forKey: previousPeerID) + } + peerToPeripheralUUID[peerID] = peripheralUUID } func removePeripheral(_ peripheralID: String) -> PeerID? { assertOwned() let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID - if let peerID { - peerToPeripheralUUID.removeValue(forKey: peerID) + // Only clear (or repair) the reverse map when it points at the removed + // link: with duplicate links to one peer, removing a stale duplicate + // must not strand the peer's surviving bound link. + if let peerID, peerToPeripheralUUID[peerID] == peripheralID { + // Prefer a writable survivor: repairing onto a link that is + // mid-service-rediscovery would strand directed sends until the + // characteristic comes back. + let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected } + if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key { + peerToPeripheralUUID[peerID] = survivorUUID + } else { + peerToPeripheralUUID.removeValue(forKey: peerID) + } } return peerID } diff --git a/bitchat/Services/BLE/BLELogRateLimiter.swift b/bitchat/Services/BLE/BLELogRateLimiter.swift index 65892970..e1e246d8 100644 --- a/bitchat/Services/BLE/BLELogRateLimiter.swift +++ b/bitchat/Services/BLE/BLELogRateLimiter.swift @@ -25,9 +25,4 @@ final class BLELogRateLimiter { } } - func removeAll() { - queue.sync { - lastLogTimeByKey.removeAll() - } - } } diff --git a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift index 3e9d7fe7..882e4978 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift @@ -7,11 +7,54 @@ struct BLEOutboundFragmentTransferRequest { let maxChunk: Int? let directedPeer: PeerID? let transferId: String? + let requireDirectPeerLink: Bool + let requireNoiseAuthenticatedPeerLink: Bool + + init( + packet: BitchatPacket, + pad: Bool, + maxChunk: Int?, + directedPeer: PeerID?, + transferId: String?, + requireDirectPeerLink: Bool = false, + requireNoiseAuthenticatedPeerLink: Bool = false + ) { + self.packet = packet + self.pad = pad + self.maxChunk = maxChunk + self.directedPeer = directedPeer + self.transferId = transferId + self.requireDirectPeerLink = requireDirectPeerLink + self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink + } var resolvedTransferId: String? { guard packet.type == MessageType.fileTransfer.rawValue else { return nil } return transferId ?? packet.payload.sha256Hex() } + + /// Content identity independent of the caller-chosen transfer ID: the + /// same file resent through another path (gossip-sync replay, retry) + /// arrives with a different explicit transferId but identical payload. + var contentKey: String? { + guard packet.type == MessageType.fileTransfer.rawValue else { return nil } + return packet.payload.sha256Hex() + } +} + +/// Transactional admission for strict fragment trains. Durable callers may +/// commit only when every fragment was accepted; the first rejection stops +/// the train and reports failure so the original remains retryable. +enum BLEStrictFragmentAdmission { + static func admitAll( + _ fragments: [Fragment], + accepting: (Fragment) -> Bool + ) -> Bool { + for fragment in fragments where !accepting(fragment) { + return false + } + return true + } } struct BLEOutboundFragmentTransferScheduler { @@ -23,6 +66,16 @@ struct BLEOutboundFragmentTransferScheduler { enum SubmitResult { case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition) + /// Strict direct-link requests are transactional: returning false to + /// their durable owner must mean no process-local copy remains that + /// can transmit later. They are therefore start-or-reject, never + /// admitted to `pendingTransfers`. + case rejectedStrict(request: BLEOutboundFragmentTransferRequest, transferId: String?) + /// The same file is already being (or waiting to be) fragmented out + /// to an audience covering this request; sending it again would just + /// double the airtime (field-verified: one 41KB voice file went out + /// as two complete fragment streams). + case droppedDuplicate(request: BLEOutboundFragmentTransferRequest, activeTransferId: String?) } enum CancelResult { @@ -38,14 +91,34 @@ struct BLEOutboundFragmentTransferScheduler { } private struct ActiveTransferState { - let totalFragments: Int + var totalFragments: Int var sentFragments: Int var workItems: [DispatchWorkItem] + var contentKey: String? + var directedPeer: PeerID? } private var activeTransfers: [String: ActiveTransferState] = [:] private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = [] + /// A transfer of the same content whose audience covers `directedPeer`: + /// a broadcast covers every peer; a directed transfer covers only its + /// recipient. A directed resend to a peer NOT covered by what's in + /// flight (different recipient of a private file) is never a duplicate. + private func coveringDuplicate(contentKey: String, directedPeer: PeerID?) -> String? { + for (transferId, state) in activeTransfers where state.contentKey == contentKey { + if state.directedPeer == nil || state.directedPeer == directedPeer { + return transferId + } + } + for request in pendingTransfers where request.contentKey == contentKey { + if request.directedPeer == nil || request.directedPeer == directedPeer { + return request.resolvedTransferId + } + } + return nil + } + var activeCount: Int { activeTransfers.count } @@ -69,17 +142,39 @@ struct BLEOutboundFragmentTransferScheduler { return .start(request: request, reservedTransferId: nil) } + // Only requests without an explicit transferId are dropped as + // duplicates: those are resend paths (gossip-sync replay, directed + // spool) with no UI waiting on them. An app-initiated send carries a + // transferId whose progress events the UI tracks, so it always runs. + if request.transferId == nil, + let contentKey = request.contentKey, + let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) { + return .droppedDuplicate(request: request, activeTransferId: coveringId) + } + guard activeTransfers.count < maxConcurrentTransfers else { + if request.requireDirectPeerLink { + return .rejectedStrict(request: request, transferId: transferId) + } pendingTransfers.append(request) return .queued(request: request, transferId: transferId, position: .back) } guard activeTransfers[transferId] == nil else { + if request.requireDirectPeerLink { + return .rejectedStrict(request: request, transferId: transferId) + } pendingTransfers.insert(request, at: 0) return .queued(request: request, transferId: transferId, position: .front) } - activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: []) + activeTransfers[transferId] = ActiveTransferState( + totalFragments: 0, + sentFragments: 0, + workItems: [], + contentKey: request.contentKey, + directedPeer: request.directedPeer + ) return .start(request: request, reservedTransferId: transferId) } @@ -88,12 +183,11 @@ struct BLEOutboundFragmentTransferScheduler { totalFragments: Int, workItems: [DispatchWorkItem] ) -> Bool { - guard activeTransfers[transferId] != nil else { return false } - activeTransfers[transferId] = ActiveTransferState( - totalFragments: totalFragments, - sentFragments: 0, - workItems: workItems - ) + guard var state = activeTransfers[transferId] else { return false } + state.totalFragments = totalFragments + state.sentFragments = 0 + state.workItems = workItems + activeTransfers[transferId] = state return true } @@ -149,13 +243,25 @@ struct BLEOutboundFragmentTransferScheduler { while availableSlots > 0, !pendingTransfers.isEmpty { let request = pendingTransfers.removeFirst() - availableSlots -= 1 guard let transferId = request.resolvedTransferId else { + availableSlots -= 1 results.append(.start(request: request, reservedTransferId: nil)) continue } + // A queued duplicate of content that started while it waited + // must not resend the whole file once the slot frees up (same + // explicit-transferId exemption as submit). + if request.transferId == nil, + let contentKey = request.contentKey, + let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) { + results.append(.droppedDuplicate(request: request, activeTransferId: coveringId)) + continue + } + + availableSlots -= 1 + guard activeTransfers.count < maxConcurrentTransfers else { pendingTransfers.insert(request, at: 0) results.append(.queued(request: request, transferId: transferId, position: .front)) @@ -168,7 +274,13 @@ struct BLEOutboundFragmentTransferScheduler { continue } - activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: []) + activeTransfers[transferId] = ActiveTransferState( + totalFragments: 0, + sentFragments: 0, + workItems: [], + contentKey: request.contentKey, + directedPeer: request.directedPeer + ) results.append(.start(request: request, reservedTransferId: transferId)) } diff --git a/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift b/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift index 4462ecc8..9f729834 100644 --- a/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift +++ b/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift @@ -20,22 +20,15 @@ enum BLEOutboundLinkPlanner { excludedLinks: Set, peripheralPeerBindings: [String: PeerID] = [:], centralPeerBindings: [String: PeerID] = [:], - directedOnlyPeer: PeerID? + preferredPeripheralPerPeer: [PeerID: String] = [:], + directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault, + directedOnlyPeer: PeerID?, + requireDirectPeerLink: Bool = false ) -> BLEOutboundLinkPlan { - if let minLimit = minimumLinkLimit( - peripheralWriteLimits: peripheralWriteLimits, - centralNotifyLimits: centralNotifyLimits - ), packet.type != MessageType.fragment.rawValue, - dataCount > minLimit { - return BLEOutboundLinkPlan( - directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer), - fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit), - selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []), - shouldSpoolDirectedPacket: false - ) - } - let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer) + // Direct announces bypass the per-peer duplicate-link collapse so + // every live link gets bound (see BLEFanoutSelector.selectLinks). + let isDirectAnnounce = packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL let selectedLinks = BLEFanoutSelector.selectLinks( peripheralIDs: peripheralIDs, centralIDs: centralIDs, @@ -43,11 +36,37 @@ enum BLEOutboundLinkPlanner { excludedLinks: excludedLinks, peripheralPeerBindings: peripheralPeerBindings, centralPeerBindings: centralPeerBindings, + preferredPeripheralPerPeer: preferredPeripheralPerPeer, + collapseDuplicatePeerLinks: !isDirectAnnounce, directedPeerHint: directedPeerHint, + requireDirectPeerLink: requireDirectPeerLink, packetType: packet.type, messageID: BLEOutboundPacketPolicy.messageID(for: packet) ) + // Fragment only for links that this packet can actually use. Looking + // at every connected link before directed-peer selection lets an + // unrelated peer's MTU make an oversized directed send look routable, + // even though every resulting fragment will select zero target links. + let selectedPeripheralLimits = zip(peripheralIDs, peripheralWriteLimits).compactMap { id, limit in + selectedLinks.peripheralIDs.contains(id) ? limit : nil + } + let selectedCentralLimits = zip(centralIDs, centralNotifyLimits).compactMap { id, limit in + selectedLinks.centralIDs.contains(id) ? limit : nil + } + if let minLimit = minimumLinkLimit( + peripheralWriteLimits: selectedPeripheralLimits, + centralNotifyLimits: selectedCentralLimits + ), packet.type != MessageType.fragment.rawValue, + dataCount > minLimit { + return BLEOutboundLinkPlan( + directedPeerHint: directedPeerHint, + fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit), + selectedLinks: selectedLinks, + shouldSpoolDirectedPacket: false + ) + } + return BLEOutboundLinkPlan( directedPeerHint: directedPeerHint, fragmentChunkSize: nil, diff --git a/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift b/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift index 6ccb4346..43c94a9a 100644 --- a/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift +++ b/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift @@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer { guard !pending.isEmpty else { return } notifications.insert(contentsOf: pending, at: 0) } + + /// Removes a disconnected target from target-specific retries. Broadcast + /// entries (`targets == nil`) remain valid for the surviving subscriber + /// set; an entry with no targets left is discarded entirely. + mutating func removeTarget(where matches: (Target) -> Bool) { + notifications = notifications.compactMap { notification in + guard let targets = notification.targets else { return notification } + let remaining = targets.filter { !matches($0) } + guard !remaining.isEmpty else { return nil } + return BLEPendingNotification(data: notification.data, targets: remaining) + } + } } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index f7117353..ddcc4abc 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,12 +12,15 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: + // voiceFrame is deliberately unpadded: padding to the 512 block would + // push every ~490-byte signed voice packet over the MTU into the + // fragment path. + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: return false } } - static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority { + static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority { guard let messageType = MessageType(rawValue: packet.type) else { return .low } switch messageType { case .fragment: diff --git a/bitchat/Services/BLE/BLEOutboundWriteBuffer.swift b/bitchat/Services/BLE/BLEOutboundWriteBuffer.swift index f1bbbee7..44dfcbf1 100644 --- a/bitchat/Services/BLE/BLEOutboundWriteBuffer.swift +++ b/bitchat/Services/BLE/BLEOutboundWriteBuffer.swift @@ -46,8 +46,25 @@ struct BLEOutboundWriteBuffer { priority: BLEOutboundWritePriority, capBytes: Int ) -> EnqueueResult { + enqueueReportingAcceptance( + data: data, + for: peripheralID, + priority: priority, + capBytes: capBytes + ).result + } + + /// Enqueues while also reporting whether the newly offered write survived + /// priority trimming. `EnqueueResult.enqueued` alone cannot express that: + /// a full queue may immediately trim the new lowest-priority item. + mutating func enqueueReportingAcceptance( + data: Data, + for peripheralID: String, + priority: BLEOutboundWritePriority, + capBytes: Int + ) -> (result: EnqueueResult, accepted: Bool) { guard data.count <= capBytes else { - return .oversized(bytes: data.count) + return (.oversized(bytes: data.count), false) } var queue = writesByPeripheralID[peripheralID] ?? [] @@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer { } writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue - return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total) + let accepted = insertIndex < queue.count + return (.enqueued(trimmedBytes: trimmedBytes, remainingBytes: total), accepted) } mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] { @@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer { return items } + /// Drops link-specific ciphertext when that physical link is gone. Keeping + /// the dictionary entry would let rotating peripheral UUIDs accumulate a + /// fresh per-link byte cap indefinitely. + mutating func discardAll(for peripheralID: String) { + writesByPeripheralID[peripheralID] = nil + } + mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) { guard !items.isEmpty else { return } var existing = writesByPeripheralID[peripheralID] ?? [] diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 78e9591d..792107d8 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -9,6 +9,9 @@ struct BLEPeerInfo: Equatable { var signingPublicKey: Data? var isVerifiedNickname: Bool var lastSeen: Date + var capabilities: PeerCapabilities = [] + /// Rendezvous cell from the peer's announce when it advertises `.bridge`. + var bridgeGeohash: String? } struct BLEPeerAnnounceUpdate: Equatable { @@ -107,6 +110,24 @@ struct BLEPeerRegistry { peers[peerID]?.noisePublicKey?.sha256Fingerprint() } + func capabilities(for peerID: PeerID) -> PeerCapabilities { + peers[peerID.toShort()]?.capabilities ?? [] + } + + /// Peers whose last verified announce advertised the given capability. + func peers(advertising capability: PeerCapabilities) -> [PeerID] { + peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) + } + + /// A rendezvous cell advertised by any bridge-capable peer, if one is + /// known — lets location-less devices join the island's rendezvous. + func advertisedBridgeGeohash() -> String? { + peers.values + .filter { $0.capabilities.contains(.bridge) } + .compactMap(\.bridgeGeohash) + .first + } + func displayNicknames(selfNickname: String) -> [PeerID: String] { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } @@ -125,25 +146,28 @@ struct BLEPeerRegistry { nickname: resolvedNames[info.peerID] ?? info.nickname, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, - lastSeen: info.lastSeen + lastSeen: info.lastSeen, + isVerified: info.isVerifiedNickname ) } } - func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? { - guard let info = peers[peerID], info.isVerifiedNickname else { return nil } - let hasCollision = peers.values.contains { - $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID - } || selfNickname == info.nickname - return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname - } - mutating func markDisconnected(_ peerID: PeerID) { guard var info = peers[peerID] else { return } info.isConnected = false peers[peerID] = info } + /// Flips an already-known peer to connected. Returns false when the peer + /// is unknown or already connected (nothing changed). + @discardableResult + mutating func markConnected(_ peerID: PeerID) -> Bool { + guard var info = peers[peerID], !info.isConnected else { return false } + info.isConnected = true + peers[peerID] = info + return true + } + mutating func updateLastSeen(_ peerID: PeerID, at date: Date) { guard var peer = peers[peerID] else { return } peer.lastSeen = date @@ -156,7 +180,9 @@ struct BLEPeerRegistry { noisePublicKey: Data, signingPublicKey: Data?, isConnected: Bool, - now: Date + now: Date, + capabilities: PeerCapabilities = [], + bridgeGeohash: String? = nil ) -> BLEPeerAnnounceUpdate { let existing = peers[peerID] let update = BLEPeerAnnounceUpdate( @@ -172,7 +198,9 @@ struct BLEPeerRegistry { noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, isVerifiedNickname: true, - lastSeen: now + lastSeen: now, + capabilities: capabilities, + bridgeGeohash: bridgeGeohash ) return update diff --git a/bitchat/Services/BLE/BLEPublicMessageHandler.swift b/bitchat/Services/BLE/BLEPublicMessageHandler.swift index 49699c81..7ff786be 100644 --- a/bitchat/Services/BLE/BLEPublicMessageHandler.swift +++ b/bitchat/Services/BLE/BLEPublicMessageHandler.swift @@ -122,10 +122,18 @@ final class BLEPublicMessageHandler { SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - var resolvedSelfMessageID: String? = nil + let messageID: String? if peerID == env.localPeerID() { - resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet) + messageID = env.takeSelfBroadcastMessageID(packet) + } else { + // The wire carries no message ID; derive the stable one every + // device agrees on so bridged copies dedup against the radio copy. + messageID = MeshMessageIdentity.stableID( + senderIDHex: peerID.id, + timestampMs: packet.timestamp, + content: content + ) } - env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID) + env.deliverPublicMessage(peerID, senderNickname, content, ts, messageID) } } diff --git a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift index e81ddec4..9a0bc965 100644 --- a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift +++ b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift @@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy { } let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) + // Acceptance window matches the gossip-sync serving window: a peer + // walking between partitions carries hours of public history, so the + // receive side must not drop what sync legitimately serves. if isBroadcast, - BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) { + BLEPacketFreshnessPolicy.isStale( + timestampMilliseconds: packet.timestamp, + now: now, + maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds + ) { return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds( timestampMilliseconds: packet.timestamp, now: now diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 05bf81f4..e81fabd5 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -48,11 +48,29 @@ struct BLEReceivePipeline { senderIsSelf: senderID == localPeerID, recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID, isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, - isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil, + // Courier envelopes are directed opaque ciphertext like DMs; a + // remote handover toward a relayed announce rides this same + // deterministic relay treatment instead of the broadcast clamp. + // Ping/pong diagnostics ride it too: probes need the same + // deterministic multi-hop relay as DMs (always relay, jitter, + // no TTL cap) so RTT and hop counts reflect the real path. + // Directed nostrCarrier uplinks (mesh-only peer -> gateway) need + // the same multi-hop treatment to reach a non-adjacent gateway. + isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue + || packet.type == MessageType.courierEnvelope.rawValue + || packet.type == MessageType.ping.rawValue + || packet.type == MessageType.pong.rawValue + || packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil, isFragment: packet.type == MessageType.fragment.rawValue, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isAnnounce: packet.type == MessageType.announce.rawValue, + isRequestSync: packet.type == MessageType.requestSync.rawValue, + // Board posts relay like broadcast messages; urgent ones get the + // announce-class TTL headroom so alerts travel the extra hop. + isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue + && BoardWire.urgentFlag(in: packet.payload), + isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue, degree: degree, highDegreeThreshold: highDegreeThreshold ) diff --git a/bitchat/Services/BLE/BLERecentPeripheralCache.swift b/bitchat/Services/BLE/BLERecentPeripheralCache.swift new file mode 100644 index 00000000..1f7819fd --- /dev/null +++ b/bitchat/Services/BLE/BLERecentPeripheralCache.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped +/// links) so the service can arm pending background connections against them +/// when the app leaves the foreground. Generic over the peripheral type so +/// the eviction/expiry logic is testable without CoreBluetooth. +final class BLERecentPeripheralCache { + private struct Entry { + let peripheral: Peripheral + var lastSeen: Date + } + + private var entries: [String: Entry] = [:] + private let capacity: Int + private let maxAge: TimeInterval + + init( + capacity: Int = TransportConfig.bleRecentPeripheralCacheCap, + maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds + ) { + self.capacity = capacity + self.maxAge = maxAge + } + + var count: Int { entries.count } + + func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) { + entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now) + guard entries.count > capacity else { return } + // Inserts overshoot capacity by at most one; evict the stalest entry + if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) { + entries.removeValue(forKey: stalest.key) + } + } + + /// Most-recently-seen peripherals eligible for a pending background + /// connect, freshest first, capped at `limit`. Expired entries are + /// pruned as a side effect. + func reconnectTargets( + now: Date, + limit: Int, + excluding: (String) -> Bool + ) -> [(peripheralID: String, peripheral: Peripheral)] { + let cutoff = now.addingTimeInterval(-maxAge) + entries = entries.filter { $0.value.lastSeen >= cutoff } + guard limit > 0 else { return [] } + return entries + .filter { !excluding($0.key) } + .sorted { $0.value.lastSeen > $1.value.lastSeen } + .prefix(limit) + .map { (peripheralID: $0.key, peripheral: $0.value.peripheral) } + } +} diff --git a/bitchat/Services/BLE/BLERedundantLinkPolicy.swift b/bitchat/Services/BLE/BLERedundantLinkPolicy.swift new file mode 100644 index 00000000..6a053b5a --- /dev/null +++ b/bitchat/Services/BLE/BLERedundantLinkPolicy.swift @@ -0,0 +1,73 @@ +import BitFoundation +import Foundation + +/// Decides which central-role connections (peripheral links we own) are +/// redundant duplicates of a peer's live link. +/// +/// One connection per role per peer is the normal dual-role topology (each +/// device is both central and peripheral). After a BLE state-restoration +/// relaunch, though, the same phone can reappear under a fresh peripheral +/// UUID while the restored connection lives on — leaving several live +/// central-role connections to one peer, each carrying every packet +/// (field-verified: 2-3x airtime on all traffic). Only same-role duplicates +/// are retired; the peer's central-role subscription on our peripheral +/// manager is its own connection to manage, and it runs the same policy. +enum BLERedundantLinkPolicy { + struct PeripheralLink: Equatable { + let uuid: String + let peerID: PeerID? + let isConnected: Bool + /// Whether the link has a discovered characteristic (is writable). + /// A link mid-service-rediscovery (didModifyServices cleared it) + /// must never be kept over a writable duplicate. + let hasCharacteristic: Bool + + init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) { + self.uuid = uuid + self.peerID = peerID + self.isConnected = isConnected + self.hasCharacteristic = hasCharacteristic + } + } + + /// The link to keep when a peer has several connected bound peripheral + /// links, or nil when there is nothing to consolidate. Prefers the + /// ingress link of the verified direct announce that triggered the check + /// (the strongest liveness proof available), falling back to the peer's + /// most recently bound link — but only among writable links while any + /// exist: keeping a characteristic-less link and cancelling the writable + /// duplicate would strand outbound traffic on the central link until + /// rediscovery finishes. When neither anchor is a viable candidate, + /// consolidation waits for a later announce rather than guessing. + static func keptPeripheralUUID( + ingressPeripheralUUID: String?, + mostRecentlyBoundUUID: String?, + links: [PeripheralLink], + peerID: PeerID + ) -> String? { + let bound = links.filter { $0.peerID == peerID && $0.isConnected } + guard bound.count > 1 else { return nil } + + let writable = bound.filter(\.hasCharacteristic) + let candidates = writable.isEmpty ? bound : writable + + if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) { + return ingressPeripheralUUID + } + if let mostRecentlyBoundUUID, candidates.contains(where: { $0.uuid == mostRecentlyBoundUUID }) { + return mostRecentlyBoundUUID + } + return nil + } + + /// Connected peripheral links bound to the peer other than the kept one. + static func peripheralUUIDsToRetire( + links: [PeripheralLink], + peerID: PeerID, + keeping keptUUID: String + ) -> [String] { + links + .filter { $0.peerID == peerID && $0.isConnected && $0.uuid != keptUUID } + .map(\.uuid) + } +} diff --git a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift index 8c43e0c3..aa12f7e0 100644 --- a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift +++ b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift @@ -35,6 +35,14 @@ struct BLERouteForwardingPolicy { routingPeer: (Data) -> PeerID?, isPeerConnected: (PeerID) -> Bool ) -> BLERouteForwardingPlan { + // REQUEST_SYNC is link-local: never forward it, on the flood path or + // the source-routed path. A crafted request with a route and TTL + // headroom must not be able to fan a full-store replay out to the next + // hop. Suppressing here also short-circuits the flood relay. + if packet.type == MessageType.requestSync.rawValue { + return .suppressFloodRelay + } + if PeerID(hexData: packet.recipientID) == localPeerID { return .suppressFloodRelay } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2ee9cd13..1f919a4a 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -10,7 +10,6 @@ import UIKit /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). -/// - A lightweight `peerSnapshotPublisher` is provided for non-UI services. final class BLEService: NSObject { // MARK: - Constants @@ -38,6 +37,20 @@ final class BLEService: NSObject { // 1. Consolidated BLE link tracking for both central and peripheral roles. private var linkStateStore = BLELinkStateStore() + // A peer ID can retain an established Noise session after its physical + // link disappears. Courier handover therefore needs the stronger fact + // that the session was established *on this current ingress link*, not + // merely that some session exists for the claimed ID. bleQueue-owned. + private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:] + + // Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link + // store): entries older than the cooldown are pruned on insert. + private var lastLinkRebindAt: [String: Date] = [:] + + // Redundant-link retirement cooldown per peer (bleQueue-owned): bounds + // how often a replayed announce could flip which duplicate link survives. + private var lastRedundantLinkRetirementAt: [PeerID: Date] = [:] + // BCH-01-004: Rate-limiting for subscription-triggered announces. private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() @@ -46,9 +59,75 @@ final class BLEService: NSObject { // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() + + // Courier store-and-forward: envelopes this device carries for offline + // third parties, and the trust gate for accepting deposits. The policy + // maps (depositor key, announce-verified?) to a quota tier, or nil to + // reject. Injectable for tests; main-actor policy because favorites live + // on the main actor. + var courierStore: CourierStore = .shared + // Bulletin-board posts this device carries; injectable for tests. + var boardStore: BoardStore = .shared + var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in + if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite } + return isVerifiedPeer ? .verified : nil + } + // Local-only store-and-forward counters; nil in unit tests. + var sfMetrics: StoreAndForwardMetrics? + + // Verified one-time prekey bundles gossiped by other peers, used to seal + // courier mail forward-secretly. Injectable for tests. + var prekeyBundleStore: PrekeyBundleStore = .shared + // Throttle for re-broadcasting our own (unchanged) bundle; guarded by + // collectionsQueue barriers. + private var lastPrekeyBundleSentAt: Date? + // Prekey bundles that arrived before their owner's verified announce bound + // a signing key. The receive queue is concurrent, so a bundle can race + // ahead of the announce it depends on; we retain the latest such bundle per + // owner (bounded) and re-attempt attribution when the announce lands. + // Guarded by collectionsQueue barriers. + private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:] + private static let pendingPrekeyBundleCap = 64 + // Gateway mode: sink for received nostrCarrier packets (set by app + // wiring, called on the main actor after transport-level checks) and the + // runtime-toggled capability bits ORed into `PeerCapabilities.localSupported` + // for every announce. `directedToUs` distinguishes an uplink deposit + // addressed to this device from a downlink broadcast. + var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? + /// Fired (off-main) when a signature-verified announce is processed — + /// the bridge courier watch refreshes its tag set on new arrivals. + var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? + private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue + private var localBridgeGeohash: String? // collectionsQueue + + #if DEBUG + // Test-only tap on the outbound pipeline so multi-node tests can ferry + // packets between in-process service instances. + var _test_onOutboundPacket: ((BitchatPacket) -> Void)? + #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() - + // Route health for originated source routes; guarded by collectionsQueue. + private var sourceRouteFailures = BLESourceRouteFailureCache() + + // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the + // inbound ping budget — keyed by the ingress link (the directly connected + // peer that delivered the packet), since the unsigned claimed sender is + // spoofable — so a directed unencrypted probe cannot be turned into an + // amplification primitive. Both are owned by collectionsQueue barriers + // like the other mutable collections. + private struct PendingMeshPing { + let peerID: PeerID + let sentAt: Date + let completion: @MainActor (MeshPingResult?) -> Void + let timeout: DispatchWorkItem + } + private var pendingMeshPings: [Data: PendingMeshPing] = [:] + private var meshPingResponseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() @@ -60,6 +139,15 @@ final class BLEService: NSObject { // Application state tracking (thread-safe) #if os(iOS) private var isAppActive: Bool = true // Assume active initially + /// Last `UIApplication.shared.backgroundTimeRemaining` sampled on the + /// main thread, cached so bleQueue status logs can read it without ever + /// dispatching to main (see `captureBluetoothStatus` for the invariant). + private let backgroundTimeLock = NSLock() + private var _cachedBackgroundTimeRemaining: TimeInterval = .greatestFiniteMagnitude + private var cachedBackgroundTimeRemaining: TimeInterval { + backgroundTimeLock.lock(); defer { backgroundTimeLock.unlock() } + return _cachedBackgroundTimeRemaining + } #endif // MARK: - Core BLE Objects @@ -107,6 +195,13 @@ final class BLEService: NSObject { // Ingress link tracking for duplicate and last-hop suppression private var ingressLinks = BLEIngressLinkRegistry() + // Inner message IDs of recently opened courier envelopes. Redundant + // copies of one message ride different envelopes (each seal uses a fresh + // ephemeral key, and bridge drops multiply across relays/couriers), so + // envelope-level dedup can't catch them; dedup on the inner ID before + // delivery so a duplicate costs one decrypt instead of a delivery + ack + // + handshake each. Owned by collectionsQueue barriers. + private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap) private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) private var pendingPeripheralWrites = BLEOutboundWriteBuffer() @@ -135,6 +230,7 @@ final class BLEService: NSObject { private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceCounter = 0 // Track maintenance cycles + private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes /// Whether real CoreBluetooth managers were initialized. When false (unit /// tests), periodic mesh background work is not started — the maintenance /// timer and the gossip-sync timers only drain BLE writes/notifications, @@ -145,6 +241,9 @@ final class BLEService: NSObject { // MARK: - Connection budget & scheduling (central role) private var connectionScheduler = BLEConnectionScheduler() + // Recently seen peripherals retained for background wake-on-proximity + // connects (bleQueue-confined, like the link state store) + private let recentPeripheralCache = BLERecentPeripheralCache() // MARK: - Adaptive scanning duty-cycle private var scanDutyTimer: DispatchSourceTimer? @@ -192,12 +291,18 @@ final class BLEService: NSObject { // Set up application state tracking (iOS only) #if os(iOS) - // Check initial state on main thread + // Check initial state on main thread. The background-budget cache is + // seeded here too: a background-restore launch captures Bluetooth + // status before any lifecycle notification fires, and the init-time + // sentinel would log a meaningless bgRemaining=∞ for exactly the + // wake window that matters. if Thread.isMainThread { isAppActive = UIApplication.shared.applicationState == .active + refreshCachedBackgroundTimeRemaining() } else { DispatchQueue.main.sync { isAppActive = UIApplication.shared.applicationState == .active + refreshCachedBackgroundTimeRemaining() } } @@ -261,6 +366,7 @@ final class BLEService: NSObject { gcsMaxBytes: TransportConfig.syncGCSMaxBytes, gcsTargetFpr: TransportConfig.syncGCSTargetFpr, maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds, + publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds, maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds, stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds, stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds, @@ -268,11 +374,26 @@ final class BLEService: NSObject { fileTransferCapacity: TransportConfig.syncFileTransferCapacity, fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds, fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds, - messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds + messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds, + responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses, + responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds, + prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity, + prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds, + prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds ) - - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + + // Only real Bluetooth sessions archive to disk; unit tests stay hermetic. + let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive) manager.delegate = self + // Board posts sync from the board store (their retention owner) so + // deleted/expired posts drop out of rounds immediately. Real sessions + // only, matching the archive: unit tests stay hermetic. + if meshBackgroundEnabled { + manager.boardPacketsProvider = { [weak self] in + self?.boardStore.syncCandidates() ?? [] + } + } // Only start the periodic sync timers when real Bluetooth exists. In unit // tests there is no mesh to sync with, and the periodic sign/broadcast // churn just keeps the process busy and aggravates flaky exit hangs. @@ -309,6 +430,8 @@ final class BLEService: NSObject { ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() + // Let the post-panic identity publish its fresh bundle promptly. + lastPrekeyBundleSentAt = nil return transfers } @@ -405,13 +528,6 @@ final class BLEService: NSObject { weak var delegate: BitchatDelegate? weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? - - // MARK: Peer snapshots publisher (non-UI convenience) - - private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>() - var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { - peerSnapshotSubject.eraseToAnyPublisher() - } func currentPeerSnapshots() -> [TransportPeerSnapshot] { collectionsQueue.sync { @@ -552,6 +668,7 @@ final class BLEService: NSObject { let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) } peerRegistry.removeAll() fragmentAssemblyBuffer.removeAll() + sourceRouteFailures = BLESourceRouteFailureCache() // Also clear pending message queues to avoid stale state across sessions pendingNoiseSessionQueues.removeAll() pendingDirectedRelays.removeAll() @@ -569,6 +686,7 @@ final class BLEService: NSObject { // Clear peripheral references (synchronized access to avoid races with BLE callbacks) bleQueue.sync { linkStateStore.clearAll() + noiseAuthenticatedLinkOwners.removeAll() connectionScheduler.reset() subscriptionAnnounceLimiter.removeAll() } @@ -589,12 +707,84 @@ final class BLEService: NSObject { } } + func canDeliverSecurely(to peerID: PeerID) -> Bool { + // A live link binding alone is forgeable: the rotation heal rebinds a + // link on a signature-verified "direct" announce, but directness rides + // on the unsigned TTL, so a replayed announce can bind an absent + // peer's ID to the replayer's link. An established Noise session + // proves the other end of the link holds the peer's private key. + // + // Sessions are keyed by the short wire ID, so normalize like + // isPeerConnected does — a send keyed by the full 64-hex Noise key + // must not misread an established session as insecure. + noiseService.hasEstablishedSession(with: peerID.toShort()) + } + func peerNickname(peerID: PeerID) -> String? { collectionsQueue.sync { peerRegistry.nickname(for: peerID, connectedOnly: true) } } + /// Capabilities the peer advertised in its last verified announce. + /// Empty for peers that predate the capabilities TLV. + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { + collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } + } + + /// Enables or disables a runtime-advertised capability bit (e.g. the + /// internet-gateway toggle) and re-announces so peers learn promptly. + /// Build-time bits stay in `PeerCapabilities.localSupported`. + func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) { + let changed: Bool = collectionsQueue.sync(flags: .barrier) { + let before = runtimeCapabilities + if enabled { + runtimeCapabilities.insert(capability) + } else { + runtimeCapabilities.remove(capability) + } + return runtimeCapabilities != before + } + guard changed else { return } + sendAnnounce(forceSend: true) + } + + /// Reachable peers currently advertising the `.gateway` capability. + func reachableGatewayPeers() -> [PeerID] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.peers(advertising: .gateway) + .filter { peerRegistry.isReachable($0, now: now) } + } + } + + /// Reachable peers currently advertising the `.bridge` capability. + func reachableBridgePeers() -> [PeerID] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.peers(advertising: .bridge) + .filter { peerRegistry.isReachable($0, now: now) } + } + } + + /// A rendezvous cell advertised by a bridge-capable peer's announce. + func advertisedBridgeGeohash() -> String? { + collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() } + } + + /// The rendezvous cell this device advertises in its own announces while + /// bridging with the gateway toggle on. Set from the main actor; the + /// value rides the next (forced) announce. + func setLocalBridgeGeohash(_ cell: String?) { + let changed: Bool = collectionsQueue.sync(flags: .barrier) { + guard localBridgeGeohash != cell else { return false } + localBridgeGeohash = cell + return true + } + guard changed else { return } + sendAnnounce(forceSend: true) + } + func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { peerRegistry.displayNicknames(selfNickname: myNickname) @@ -620,7 +810,11 @@ final class BLEService: NSObject { } func triggerHandshake(with peerID: PeerID) { - initiateNoiseHandshake(with: peerID) + // Callers are on the main actor; the handshake broadcast sync-waits + // on bleQueue for link state, so hop off main first. + messageQueue.async { [weak self] in + self?.initiateNoiseHandshake(with: peerID) + } } // MARK: Noise identity/session access (narrow Transport wrappers) @@ -775,6 +969,15 @@ final class BLEService: NSObject { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + // Hop like sendMessage: callers are often on the main actor, and the + // send path sync-waits on bleQueue for link state — the main thread + // must never block on bleQueue (see captureBluetoothStatus). + if DispatchQueue.getSpecific(key: messageQueueKey) == nil { + messageQueue.async { [weak self] in + self?.sendReadReceipt(receipt, to: peerID) + } + return + } let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID) if noiseService.hasEstablishedSession(with: peerID) { @@ -785,11 +988,15 @@ final class BLEService: NSObject { SecureLogger.error("Failed to send read receipt: \(error)") } } else { - // Queue for after handshake and initiate if needed + // Queue for after handshake; initiate only while the peer is + // around to answer (see sendDeliveryAck — absent senders must + // not turn queued acks into handshake floods). collectionsQueue.sync(flags: .barrier) { pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) } - if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { + initiateNoiseHandshake(with: peerID) + } SecureLogger.debug("🕒 Queued READ receipt for \(peerID.id.prefix(8))… until handshake completes", category: .session) } } @@ -888,6 +1095,10 @@ final class BLEService: NSObject { } else { packetToSend = packet } + + #if DEBUG + _test_onOutboundPacket?(packetToSend) + #endif // Encode once using a small per-type padding policy, then delegate by type let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) @@ -984,14 +1195,101 @@ final class BLEService: NSObject { } } - private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) { + /// Synchronously admits a notification to the link-specific retry queue. + /// Destructive courier handoff uses this result as its commit point, so a + /// full process-local queue must be reported as rejection, not success. + private func enqueuePendingNotificationIfAccepted( + data: Data, + centrals: [CBCentral], + context: String + ) -> Bool { + let result = collectionsQueue.sync(flags: .barrier) { + pendingNotifications.enqueue( + data: data, + targets: centrals, + capCount: TransportConfig.blePendingNotificationsCapCount + ) + } + switch result { + case let .enqueued(count): + SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session) + return true + case let .full(count): + SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session) + return false + } + } + + /// Serializes the final authenticated-link check with CoreBluetooth's + /// notification admission on `bleQueue`, closing the rebind/disconnect + /// race between fanout planning and the actual handoff. + private func notifyOrEnqueueIfAccepted( + data: Data, + centrals: [CBCentral], + characteristic: CBMutableCharacteristic, + context: String, + requiredAuthenticatedPeer: PeerID? + ) -> Bool { + let accept = { [self] in + let eligible: [CBCentral] + if let peerID = requiredAuthenticatedPeer { + eligible = centrals.filter { central in + let link = BLEIngressLinkID.central(central.identifier.uuidString) + return noiseAuthenticatedLinkOwners[link] == peerID + && linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID + } + } else { + eligible = centrals + } + guard !eligible.isEmpty else { return false } + if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true { + return true + } + return enqueuePendingNotificationIfAccepted( + data: data, + centrals: eligible, + context: context + ) + } + + if DispatchQueue.getSpecific(key: bleQueueKey) != nil { + return accept() + } + return bleQueue.sync(execute: accept) + } + + /// Returns true only when the packet was accepted by at least one current + /// physical link (including its link-specific backpressure queue). A + /// process-local directed spool is deliberately not success: callers + /// that own a durable upstream copy must keep it retryable. + @discardableResult + private func sendOnAllLinks( + packet: BitchatPacket, + data: Data, + pad: Bool, + directedOnlyPeer: PeerID?, + requireDirectPeerLink: Bool = false, + requireNoiseAuthenticatedPeerLink: Bool = false + ) -> Bool { let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } - let excludedPeerLinks = links(to: ingressRecord?.peerID) + var excludedPeerLinks = links(to: ingressRecord?.peerID) + if requireNoiseAuthenticatedPeerLink { + guard let directedOnlyPeer else { return false } + let boundLinks = links(to: directedOnlyPeer) + let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer) + guard !authenticatedLinks.isEmpty else { return false } + excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks)) + } let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data) let states = snapshotPeripheralStates() - let connectedStates = states.filter { $0.isConnected } - let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().centrals + // A link without a discovered characteristic cannot be written to + // (the write loop below skips it); offering it to the planner only + // wastes fanout slots — and a peer's single collapsed copy would be + // silently dropped if its bound link is still mid-rediscovery. + let connectedStates = states.filter { $0.isConnected && $0.characteristic != nil } + let centralSnapshot = snapshotSubscribedCentrals() + let subscribedCentrals = characteristic == nil ? [] : centralSnapshot.centrals let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString } let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state in @@ -1007,13 +1305,28 @@ final class BLEService: NSObject { ingressRecord: ingressRecord, excludedLinks: excludedPeerLinks, peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: snapshotSubscribedCentrals().peerIDsByCentralUUID, - directedOnlyPeer: directedOnlyPeer + centralPeerBindings: centralSnapshot.peerIDsByCentralUUID, + // Perf note: this is a third bleQueue hop per send; if send-path + // profiling ever flags it, fold it into snapshotPeripheralStates + // as a combined snapshot. + preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings }, + directAnnounceTTL: messageTTL, + directedOnlyPeer: directedOnlyPeer, + requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink ) if let chunk = plan.fragmentChunkSize { - sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer) - return + guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else { + return false + } + return sendFragmentedPacket( + packet, + pad: pad, + maxChunk: chunk, + directedOnlyPeer: directedOnlyPeer, + requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink, + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink + ) } // If directed and we currently have no links to forward on, spool for a short window @@ -1022,33 +1335,73 @@ final class BLEService: NSObject { spoolDirectedPacket(packet, recipientPeerID: only) } + var acceptedByPhysicalLink = false + // Writes to selected connected peripherals for s in connectedStates { let pid = s.peripheral.identifier.uuidString guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue } if let ch = s.characteristic { - writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) + if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { + acceptedByPhysicalLink = writeOrEnqueueIfAccepted( + data, + to: s.peripheral, + characteristic: ch, + priority: outboundPriority, + requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil + ) || acceptedByPhysicalLink + } else { + writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) + } } } // Notify selected subscribed centrals if let ch = characteristic { let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) } if !targets.isEmpty { - let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false - if !success { - // Notification queue full - queue for retry to prevent silent packet loss - // This is critical for fragment delivery reliability - let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast" - enqueuePendingNotification(data: data, centrals: targets, context: context) + if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { + acceptedByPhysicalLink = notifyOrEnqueueIfAccepted( + data: data, + centrals: targets, + characteristic: ch, + context: "directed", + requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil + ) || acceptedByPhysicalLink + } else { + let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false + if !success { + // Notification queue full - queue for retry to prevent silent packet loss + // This is critical for fragment delivery reliability + let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast" + enqueuePendingNotification(data: data, centrals: targets, context: context) + } } } } + if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink } + return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty } // Directed send helper (unicast to a specific peerID) without altering packet contents - private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { - guard let data = packet.toBinaryData(padding: false) else { return } - sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) + @discardableResult + private func sendPacketDirected( + _ packet: BitchatPacket, + to peerID: PeerID, + requireDirectPeerLink: Bool = false, + requireNoiseAuthenticatedPeerLink: Bool = false + ) -> Bool { + #if DEBUG + _test_onOutboundPacket?(packet) + #endif + guard let data = packet.toBinaryData(padding: false) else { return false } + return sendOnAllLinks( + packet: packet, + data: data, + pad: false, + directedOnlyPeer: peerID, + requireDirectPeerLink: requireDirectPeerLink, + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink + ) } // MARK: - Directed store-and-forward @@ -1104,7 +1457,61 @@ final class BLEService: NSObject { return nil } - private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { + // MARK: - Archived public messages ("heard here earlier") + + func purgeArchivedPublicMessages(from peerID: PeerID) { + gossipSyncManager?.removePublicMessages(from: peerID) + } + + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { + guard let sync = gossipSyncManager else { + Task { @MainActor in completion([]) } + return + } + sync.collectPublicMessagePackets { [weak self] packets in + guard let self = self else { + Task { @MainActor in completion([]) } + return + } + // Signature verification and registry lookups run on messageQueue + // like the live receive path. + self.messageQueue.async { + let decoded = packets + .compactMap { self.decodeArchivedPublicMessage($0) } + .sorted { $0.timestamp < $1.timestamp } + Task { @MainActor in completion(decoded) } + } + } + } + + private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? { + guard packet.type == MessageType.message.rawValue, + let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty + else { return nil } + let senderPeerID = PeerID(hexData: packet.senderID) + let peers = collectionsQueue.sync { peerRegistry.snapshotByID } + // Archived senders are usually long gone, so the signature-derived + // identity is the best shot at a name; a live registry entry is + // next; anonymous fallback matches the live path. + let nickname = signedSenderDisplayName(for: packet, from: senderPeerID) + ?? BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: senderPeerID, + localPeerID: myPeerID, + localNickname: myNickname, + peers: peers, + allowConnectedUnverified: false + ) + ?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID) + return ArchivedPublicMessage( + packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(), + senderPeerID: senderPeerID, + senderNickname: nickname, + content: content, + timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + ) + } + + private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { fileTransferHandler.handle(packet, from: peerID) } @@ -1123,6 +1530,9 @@ final class BLEService: NSObject { guard let self = self else { return [:] } return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } }, + verifyPacketSignature: { [weak self] packet, signingPublicKey in + self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false + }, signedSenderDisplayName: { [weak self] packet, peerID in self?.signedSenderDisplayName(for: packet, from: peerID) }, @@ -1174,6 +1584,15 @@ final class BLEService: NSObject { } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { + // Hop like sendMessage: callers are often on the main actor, and the + // send path sync-waits on bleQueue for link state — the main thread + // must never block on bleQueue (see captureBluetoothStatus). + if DispatchQueue.getSpecific(key: messageQueueKey) == nil { + messageQueue.async { [weak self] in + self?.sendDeliveryAck(for: messageID, to: peerID) + } + return + } let payload = BLENoisePayloadFactory.delivered(messageID: messageID) if noiseService.hasEstablishedSession(with: peerID) { @@ -1183,16 +1602,23 @@ final class BLEService: NSObject { SecureLogger.error("Failed to send delivery ACK: \(error)") } } else { - // Queue for after handshake and initiate if needed + // Queue for after handshake; initiate only while the peer is + // around to answer — couriered/bridged mail routinely arrives + // from absent (or rotated) identities, and every duplicate copy + // initiating a handshake broadcast turns one undeliverable ack + // into a mesh-wide flood. The queued ack flushes whenever a + // session eventually establishes. collectionsQueue.sync(flags: .barrier) { pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) } - if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { + initiateNoiseHandshake(with: peerID) + } SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID.id.prefix(8))… until handshake completes", category: .session) } } - private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) { + private func handleLeave(_: BitchatPacket, from peerID: PeerID) { _ = collectionsQueue.sync(flags: .barrier) { // Remove the peer when they leave peerRegistry.remove(peerID) @@ -1222,15 +1648,21 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let connectedPeerIDs: [Data] = collectionsQueue.sync { - peerRegistry.connectedRoutingData + let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync { + ( + peerRegistry.connectedRoutingData, + PeerCapabilities.localSupported.union(runtimeCapabilities), + runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil + ) } - + let announcement = AnnouncementPacket( nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, - directNeighbors: connectedPeerIDs + directNeighbors: connectedPeerIDs, + capabilities: advertisedCapabilities, + bridgeGeohash: advertisedBridgeCell ) guard let payload = announcement.encode() else { @@ -1265,10 +1697,56 @@ final class BLEService: NSObject { } // Ensure our own announce is included in sync state gossipSyncManager?.onPublicPacketSeen(signedPacket) + + // Keep our prekey bundle riding alongside presence (throttled; the + // send is a no-op when the bundle was refreshed recently). + sendPrekeyBundle() } // MARK: QR Verification over Noise + // MARK: Private Groups + + /// Sends creator-signed group state (invite) 1:1 over the Noise session, + /// queueing behind a handshake when none is established yet. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID) + } + + /// Sends creator-signed group state (key rotation / roster update) 1:1 + /// over the Noise session. + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID) + } + + /// Broadcasts a sealed group message (MessageType 0x25) like a public + /// message: fire-and-flood with gossip-sync backfill. The outer packet is + /// intentionally unsigned — receivers authenticate the sender's Ed25519 + /// signature inside the ciphertext, which still verifies for backfilled + /// copies long after the sender's announce has expired. + func broadcastGroupMessage(_ envelope: Data) { + guard !envelope.isEmpty else { return } + messageQueue.async { [weak self] in + guard let self else { return } + let packet = BitchatPacket( + type: MessageType.groupMessage.rawValue, + senderID: Data(hexString: self.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: envelope, + signature: nil, + ttl: self.messageTTL + ) + // Pre-mark our own broadcast as processed to avoid handling a + // relayed self copy. + let dedupID = BLESelfBroadcastTracker.dedupID(for: packet) + self.messageDeduplicator.markProcessed(dedupID) + self.broadcastPacket(packet) + // Track our own broadcast for gossip sync + self.gossipSyncManager?.onPublicPacketSeen(packet) + } + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA) sendNoisePayload(payload, to: peerID) @@ -1278,6 +1756,67 @@ final class BLEService: NSObject { guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return } sendNoisePayload(payload, to: peerID) } + + // MARK: Vouching over Noise + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID) + } + + // MARK: Live Voice (PTT) + + /// Sends one live voice-burst packet inside the Noise session. Unlike + /// `sendNoisePayload` this never queues behind a handshake: live audio is + /// only useful now, so without an established session frames are dropped. + func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) { + messageQueue.async { [weak self] in + guard let self else { return } + guard self.noiseService.hasEstablishedSession(with: peerID) else { + SecureLogger.debug("PTT: dropping voice frame — no established session with \(peerID.id.prefix(8))…", category: .session) + return + } + do { + let typedPayload = NoisePayload(type: .voiceFrame, data: burstContent).encode() + self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID)) + } catch { + SecureLogger.error("Failed to send voice frame: \(error)", category: .session) + } + } + } + + /// Broadcasts one live voice-burst packet to the public mesh, signed like + /// a public message so receivers can authenticate the talker. Ephemeral: + /// never tracked for gossip sync (stale audio is worthless to replay). + func sendVoiceFrameBroadcast(_ burstContent: Data) { + guard !burstContent.isEmpty else { return } + messageQueue.async { [weak self] in + guard let self else { return } + let packet = BitchatPacket( + type: MessageType.voiceFrame.rawValue, + senderID: self.myPeerIDData, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: burstContent, + signature: nil, + ttl: self.messageTTL + ) + guard let signedPacket = self.noiseService.signPacket(packet) else { + SecureLogger.error("❌ Failed to sign voice frame", category: .security) + return + } + // Pre-mark our own broadcast as processed to avoid handling a + // relayed self copy. + let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) + self.messageDeduplicator.markProcessed(dedupID) + self.broadcastPacket(signedPacket) + } + } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + // Appends to the encryption service's handler array, so this never + // displaces the callbacks installed by installNoiseSessionCallbacks. + noiseService.addOnPeerAuthenticatedHandler(handler) + } } // MARK: - GossipSyncManager Delegate @@ -1305,7 +1844,7 @@ extension BLEService: GossipSyncManager.Delegate { extension BLEService: CBCentralManagerDelegate { #if os(iOS) - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] @@ -1336,9 +1875,20 @@ extension BLEService: CBCentralManagerDelegate { assembler: assembler ) linkStateStore.setPeripheralState(restoredState, for: identifier) + + // Restored peripherals are the freshest wake-on-proximity + // candidates we have after a relaunch — without this the cache + // starts empty and backgrounding right after a restore arms + // nothing. Service rediscovery for restored-connected links waits + // for poweredOn: CoreBluetooth drops commands issued during + // restoration (API MISUSE warnings). + recentPeripheralCache.record(peripheral, peripheralID: identifier, at: Date()) } - captureBluetoothStatus(context: "central-restore") + // Via the sampler (not a direct capture): it refreshes the cached + // background budget on main first, so the restore log shows the real + // wake window instead of the init sentinel. + logBluetoothStatus("central-restore") if central.state == .poweredOn { startScanning() @@ -1351,6 +1901,17 @@ extension BLEService: CBCentralManagerDelegate { switch central.state { case .poweredOn: + // Links restored as connected have no characteristic in the new + // process; without rediscovery they sit connected-but-unusable + // until the peer disconnects. Runs here (not willRestoreState) + // because commands issued before poweredOn are dropped. + for state in linkStateStore.peripheralStates where state.isConnected + && state.characteristic == nil + && state.peripheral.state == .connected { + SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session) + state.peripheral.discoverServices([BLEService.serviceUUID]) + } + // Start scanning - use allow duplicates for faster discovery when active startScanning() @@ -1430,6 +1991,9 @@ extension BLEService: CBCentralManagerDelegate { isConnectable: isConnectable, discoveredAt: Date() ) + if isConnectable { + recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt) + } let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) switch connectionScheduler.handleDiscovery( @@ -1456,7 +2020,15 @@ extension BLEService: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { let peripheralID = peripheral.identifier.uuidString - + + #if os(iOS) + // A connect completing while backgrounded is the wake-on-proximity + // path doing its job — worth an info line for field verification. + if !isAppActive { + SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session) + } + #endif + // Update state to connected linkStateStore.markConnected(peripheral) @@ -1481,10 +2053,43 @@ extension BLEService: CBCentralManagerDelegate { if error != nil { connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date()) } - + + // Retain the handle: a dropped link is the best wake-on-proximity + // candidate if the app backgrounds before the peer returns. + recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date()) + + #if os(iOS) + // Link lost while backgrounded (peer walked away): re-arm a pending + // connect during this wake window so the peer's return wakes us again. + // Delayed past the disconnect-settle window to avoid reconnect thrash + // at range edge. + if !isAppActive { + bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in + guard let self, !self.isAppActive else { return } + // Reserve 0: use the slot this disconnect freed even in a + // dense mesh, so the lost peer can wake us when it returns. + self.armPendingBackgroundConnects(slotReserve: 0) + } + } + #endif + // Clean up references and peer mappings + collectionsQueue.sync(flags: .barrier) { + pendingPeripheralWrites.discardAll(for: peripheralID) + } + noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) - if let peerID { + // A duplicate link can drop while the peer stays live on another + // (the dual-role central link, or a second bound link after a + // restore): peer-disconnect bookkeeping only runs once the peer's + // last live link is gone. removePeripheral just repaired the reverse + // map onto a connected survivor, so directLinkState is accurate + // here. The scan restart and connect-slot refill below stay + // unguarded — they respond to the physical drop regardless of + // remaining logical links. + let remainingLinks = peerID.map { linkStateStore.directLinkState(for: $0) } + let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false) + if let peerID, !peerStillLinked { // Do not remove peer; mark as not connected but retain for reachability collectionsQueue.sync(flags: .barrier) { peerRegistry.markDisconnected(peerID) @@ -1492,7 +2097,7 @@ extension BLEService: CBCentralManagerDelegate { refreshLocalTopology() } - + // Restart scanning with allow duplicates for faster rediscovery if centralManager?.state == .poweredOn { // Stop and restart scanning to ensure we get fresh discovery events @@ -1503,15 +2108,15 @@ extension BLEService: CBCentralManagerDelegate { } // Attempt to fill freed slot from queue bleQueue.async { [weak self] in self?.tryConnectFromQueue() } - + // Notify delegate about disconnection on main thread (direct link dropped) notifyUI { [weak self] in guard let self = self else { return } - + // Get current peer list (after removal) let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } - - if let peerID { + + if let peerID, !peerStillLinked { self.notifyPeerDisconnectedDebounced(peerID) } self.requestPeerDataPublish() @@ -1523,6 +2128,10 @@ extension BLEService: CBCentralManagerDelegate { let peripheralID = peripheral.identifier.uuidString // Clean up the references + collectionsQueue.sync(flags: .barrier) { + pendingPeripheralWrites.discardAll(for: peripheralID) + } + noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) @@ -1609,8 +2218,24 @@ extension BLEService { return } + #if os(iOS) + if !self.isAppActive { + // Backgrounded: leave the connect pending. iOS never expires + // it — the controller completes it whenever the peer comes + // back into range, waking the app (state restoration relaunches + // us if we were terminated). Foreground return cancels stale + // pendings via cancelStalePendingConnects(). + SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) + return + } + #endif + SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) central.cancelPeripheralConnection(peripheral) + self.collectionsQueue.sync(flags: .barrier) { + self.pendingPeripheralWrites.discardAll(for: peripheralID) + } + self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) self.tryConnectFromQueue() @@ -1657,6 +2282,27 @@ extension BLEService { handleReceivedPacket(packet, from: fromPeerID) } + /// Waits until fragment ingress already submitted by a test has finished + /// reassembly/reinjection and any resulting transport event has crossed + /// the MainActor delivery hop. This is a deterministic pipeline fence, + /// avoiding wall-clock sleeps that become flaky under a parallel suite. + func _test_drainFragmentPipeline() async { + await withCheckedContinuation { continuation in + messageQueue.async(flags: .barrier) { + // Reassembled packets are reinjected synchronously on + // `messageQueue`; their UI delivery task is therefore already + // enqueued before this later MainActor marker. + Task { @MainActor in + continuation.resume() + } + } + } + } + + func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool { + gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false + } + func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool { let claimedSenderID = PeerID(hexData: packet.senderID) guard case .success = BLEIngressLinkRegistry.packetContext( @@ -1675,6 +2321,45 @@ extension BLEService { recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID)) } + func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) { + bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) } + } + + func _test_centralBinding(_ centralUUID: String) -> PeerID? { + bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) } + } + + func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) { + bleQueue.sync { + guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return } + noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID + } + } + + func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) { + collectionsQueue.sync(flags: .barrier) { + peerRegistry.upsert(BLEPeerInfo( + peerID: peerID, + nickname: nickname, + isConnected: true, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: Date() + )) + } + } + + /// Handshake plumbing for tests that need a real established Noise + /// session (e.g. canDeliverSecurely) without Bluetooth in the loop. + func _test_noiseInitiateHandshake(with peerID: PeerID) throws -> Data { + try noiseService.initiateHandshake(with: peerID) + } + + func _test_noiseProcessHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { + try noiseService.processHandshakeMessage(from: peerID, message: message) + } + static func _test_shouldRediscoverBitChatService( invalidatedServiceUUIDs: [CBUUID], cachedServiceUUIDs: [CBUUID]? @@ -1848,23 +2533,27 @@ extension BLEService: CBPeripheralDelegate { } } - private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) { + private func processNotificationPacket(_ packet: BitchatPacket, from _: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) { let senderID = PeerID(hexData: packet.senderID) if packet.type != MessageType.announce.rawValue { SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session) } - if packet.type == MessageType.announce.rawValue { - if packet.ttl == messageTTL { + if packet.type == MessageType.announce.rawValue, + packet.ttl == messageTTL { + // Only bind an unbound link here: this runs before signature + // verification, so a bound link must not be re-bound by a raw + // announce (spoofable). Rotation rebinds happen after the announce + // verifies (rebindLinkAfterVerifiedDirectAnnounce). + let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID) + if boundPeerID == nil || boundPeerID == senderID { linkStateStore.bindPeripheral(peripheralUUID, to: senderID) refreshLocalTopology() } - - handleReceivedPacket(packet, from: peerID) - } else { - handleReceivedPacket(packet, from: peerID) } + + handleReceivedPacket(packet, from: peerID) } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { @@ -1987,7 +2676,7 @@ extension BLEService: CBPeripheralManagerDelegate { } #if os(iOS) - func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { + func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) { let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] @@ -2004,7 +2693,8 @@ extension BLEService: CBPeripheralManagerDelegate { } } - captureBluetoothStatus(context: "peripheral-restore") + // Via the sampler for a fresh background budget (see central-restore). + logBluetoothStatus("peripheral-restore") if peripheral.state == .poweredOn && !peripheral.isAdvertising { peripheral.startAdvertising(buildAdvertisementData()) @@ -2060,7 +2750,12 @@ extension BLEService: CBPeripheralManagerDelegate { } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { - SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString.prefix(8))…", category: .session) + let centralID = central.identifier.uuidString + SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session) + collectionsQueue.sync(flags: .barrier) { + pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } + } + noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) let removedPeerID = linkStateStore.removeSubscribedCentral(central) // Ensure we're still advertising for other devices to find us @@ -2071,6 +2766,13 @@ extension BLEService: CBPeripheralManagerDelegate { // Find and disconnect the peer associated with this central if let peerID = removedPeerID { + // The remote side retiring a redundant duplicate connection + // arrives here as an unsubscribe while the peer stays live on + // its other links; only the peer's last link disconnecting + // counts. If every link truly dropped, the surviving-link + // callbacks (didDisconnectPeripheral, or this one again) run + // the bookkeeping. + guard linkStateStore.links(to: peerID).isEmpty else { return } // Mark peer as not connected; retain for reachability collectionsQueue.sync(flags: .barrier) { peerRegistry.markDisconnected(peerID) @@ -2226,8 +2928,13 @@ extension BLEService: CBPeripheralManagerDelegate { if packet.type == MessageType.announce.rawValue, packet.ttl == messageTTL { - linkStateStore.bindCentral(centralUUID, to: claimedSenderID) - refreshLocalTopology() + // Same rule as the peripheral path: raw announces only bind + // unbound links; rotation rebinds require a verified announce. + let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID) + if boundPeerID == nil || boundPeerID == claimedSenderID { + linkStateStore.bindCentral(centralUUID, to: claimedSenderID) + refreshLocalTopology() + } } guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else { @@ -2280,19 +2987,37 @@ extension BLEService { } private func logBluetoothStatus(_ context: String) { - bleQueue.async { [weak self] in - guard let self = self else { return } - self.captureBluetoothStatus(context: context) - } + scheduleBluetoothStatusSample(after: 0, context: context) } private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) { - bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self else { return } - self.captureBluetoothStatus(context: context) + #if os(iOS) + // Sample the main-actor background budget first (async hop, never a + // sync wait), then log from bleQueue off the cache — bleQueue must + // never block on main (see captureBluetoothStatus). + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.refreshCachedBackgroundTimeRemaining() + self.bleQueue.async { self.captureBluetoothStatus(context: context) } } + #else + bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.captureBluetoothStatus(context: context) + } + #endif } + #if os(iOS) + /// Main thread only (reads main-actor UIApplication state). + private func refreshCachedBackgroundTimeRemaining() { + dispatchPrecondition(condition: .onQueue(.main)) + let seconds = UIApplication.shared.backgroundTimeRemaining + backgroundTimeLock.lock() + _cachedBackgroundTimeRemaining = seconds + backgroundTimeLock.unlock() + } + #endif + private func captureBluetoothStatus(context: String) { assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "captureBluetoothStatus must run on bleQueue") @@ -2310,11 +3035,15 @@ extension BLEService { } #if os(iOS) - var backgroundDescriptor = "" - var backgroundSeconds: TimeInterval = 0 - DispatchQueue.main.sync { - backgroundSeconds = UIApplication.shared.backgroundTimeRemaining - } + // INVARIANT: bleQueue must NEVER sync-dispatch to the main thread. + // The main actor sync-waits on bleQueue along the send paths + // (readLinkState), so a main.sync here completes an ABBA deadlock — + // field-verified as a permanent freeze when a courier-drop storm put + // an ack send (main → bleQueue.sync) up against a status capture + // (bleQueue → main.sync). backgroundTimeRemaining is main-actor + // state, so it is sampled on main and cached. + let backgroundSeconds = cachedBackgroundTimeRemaining + let backgroundDescriptor: String if backgroundSeconds == .greatestFiniteMagnitude { backgroundDescriptor = " bgRemaining=∞" } else { @@ -2344,13 +3073,31 @@ extension BLEService { } private func computeRoute(to peerID: PeerID) -> [Data]? { - meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID)) + // Version-gated: every hop and the recipient must have been observed + // speaking v2, since a v1-only node drops v2 frames on decode. + meshTopology.computeRoute( + from: myPeerIDData, + to: routingData(for: peerID), + maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops, + requiringVersion: 2 + ) } private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { - guard let route = computeRoute(to: recipient), route.count >= 1 else { - return packet - } + let now = Date() + let route = BLESourceRouteOriginationPolicy.route( + for: packet, + to: recipient, + localPeerIDData: myPeerIDData, + isRecipientConnected: { self.isPeerConnected($0) }, + shouldAttemptRoute: { peer in + self.collectionsQueue.sync(flags: .barrier) { + self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now) + } + }, + computeRoute: { self.computeRoute(to: $0) } + ) + guard let route else { return packet } // Create new packet with route applied and version upgraded to 2 let routedPacket = BitchatPacket( type: packet.type, @@ -2368,6 +3115,9 @@ extension BLEService { SecureLogger.error("❌ Failed to re-sign packet with route", category: .security) return packet // Return original packet if signing fails } + collectionsQueue.sync(flags: .barrier) { + sourceRouteFailures.noteRoutedSend(to: recipient, now: now) + } return signedPacket } @@ -2375,6 +3125,150 @@ extension BLEService { PeerID(routingData: data) } + // MARK: - Mesh Diagnostics (/ping, /trace, topology map) + + /// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL). + /// The completion fires exactly once on the main actor: with RTT/hops + /// when the matching pong returns, or nil after the timeout window. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + messageQueue.async { [weak self] in + guard let self, + let recipientData = peerID.toShort().routingData, + let payload = MeshPingPayload( + nonce: Data((0.. PendingMeshPing? in + guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil } + return pendingMeshPings.removeValue(forKey: pong.nonce) + } + guard let pending else { return } + pending.timeout.cancel() + let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded()) + let result = MeshPingResult( + rttMs: max(0, rttMs), + hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl) + ) + Task { @MainActor in pending.completion(result) } + } + + /// Estimated intermediate hops toward `peerID`, BFS over gossiped + /// bidirectionally-confirmed neighbor claims ([] = direct, nil = none). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { + refreshLocalTopology() + if let route = computeRoute(to: peerID) { + return route.compactMap { PeerID(routingData: $0) } + } + // Confirmed claims can lag a brand-new link (the peer's next announce + // hasn't arrived yet); a live direct connection is still a known path. + return isPeerConnected(peerID) ? [] : nil + } + + /// Mesh graph for the topology map. Edges are advisory: announces cap + /// neighbor lists at 10, so an edge claimed by either endpoint counts. + func currentMeshTopology() -> MeshTopologySnapshot? { + refreshLocalTopology() + let claims = meshTopology.adjacencySnapshot() + var nodes = Set() + var edges = Set() + for (source, neighbors) in claims { + guard let sourcePeer = PeerID(routingData: source) else { continue } + nodes.insert(sourcePeer) + for neighborData in neighbors { + guard let neighborPeer = PeerID(routingData: neighborData), + neighborPeer != sourcePeer else { continue } + nodes.insert(neighborPeer) + edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer)) + } + } + nodes.insert(myPeerID) + return MeshTopologySnapshot( + localPeerID: myPeerID, + nodes: nodes.sorted(), + edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) } + ) + } + private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool { let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData) let plan = BLERouteForwardingPolicy.plan( @@ -2401,6 +3295,45 @@ extension BLEService { private func links(to peerID: PeerID?) -> Set { readLinkState { $0.links(to: peerID) } } + + private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? { + switch link { + case .peripheral(let peripheralUUID): + store.peerID(forPeripheralID: peripheralUUID) + case .central(let centralUUID): + store.peerID(forCentralUUID: centralUUID) + } + } + + /// Marks the exact physical ingress link that completed a fresh Noise + /// handshake. An old session keyed only by peer ID is insufficient: a + /// replayed announce can rebind an attacker's link to that ID. + private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) { + guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return } + readLinkState { store in + guard boundPeerID(for: link, in: store) == peerID else { return } + noiseAuthenticatedLinkOwners[link] = peerID + } + } + + private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool { + guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false } + return readLinkState { store in + noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID + } + } + + private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool { + !currentNoiseAuthenticatedLinks(to: peerID).isEmpty + } + + private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set { + readLinkState { store in + Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in + owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil + }) + } + } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { service.onPeerAuthenticated = { [weak self] peerID, fingerprint in @@ -2439,6 +3372,15 @@ extension BLEService { private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) { + // Hop like sendMessage: the Transport-facing wrappers (verify/vouch/ + // group payloads) call this from the main actor, and the send path + // sync-waits on bleQueue for link state. + if DispatchQueue.getSpecific(key: messageQueueKey) == nil { + messageQueue.async { [weak self] in + self?.sendNoisePayload(typedPayload, to: peerID) + } + return + } guard noiseService.hasSession(with: peerID) else { // No session yet - queue the payload SYNCHRONOUSLY before initiating handshake // to prevent race where fast handshake completion drains empty queue @@ -2468,7 +3410,621 @@ extension BLEService { ttl: messageTTL ) } - + + // MARK: Courier Store-and-Forward + + /// Seal `content` for the recipient and hand the envelope to the given + /// couriers for physical delivery. When a verified one-time prekey bundle + /// is cached for the recipient, sealing targets one of its prekeys + /// (forward secret, envelope v2); otherwise it falls back to their static + /// key (one-way Noise X, v1) exactly as before. Returns false when no + /// courier is connected, the payload cannot be built, or sealing fails; + /// link writes are queued asynchronously after the envelope is ready. + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + let connected = couriers.filter { isPeerConnected($0) } + guard !connected.isEmpty, + let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { + return false + } + + let payload: Data + do { + let now = Date() + let sealed: Data + let prekeyID: UInt32? + if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { + sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) + prekeyID = prekey.id + } else { + sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + prekeyID = nil + } + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientNoiseKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), + ciphertext: sealed, + copies: TransportConfig.courierInitialCopies, + prekeyID: prekeyID + ) + guard let encoded = envelope.encode() else { return false } + payload = encoded + } catch { + SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption) + return false + } + + messageQueue.async { [weak self] in + guard let self else { return } + for courier in connected { + SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier) + } + } + return true + } + + // MARK: Courier over the bridge + + /// Seals `content` into a courier envelope for relay parking (a bridge + /// courier drop). Same sealing rules as `sendCourierMessage` — prekey + /// (v2) when a verified bundle is cached, static Noise X (v1) otherwise — + /// but carry-only: a relay copy never sprays. + func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? { + guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { + return nil + } + do { + let now = Date() + let sealed: Data + let prekeyID: UInt32? + if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { + sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) + prekeyID = prekey.id + } else { + sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + prekeyID = nil + } + return CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientNoiseKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), + ciphertext: sealed, + copies: 1, + prekeyID: prekeyID + ) + } catch { + SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption) + return nil + } + } + + /// Opens a courier envelope that arrived as a bridge drop (relay fetch, + /// not a directed mesh packet). Returns false when the rotating tag does + /// not match our static key — a drop for someone else, or a stale tag. + /// The inner Noise X seal authenticates the sender; there is no packet + /// signature to check on this path. + @discardableResult + func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool { + guard !envelope.isExpired else { return false } + let myKey = noiseService.getStaticPublicKeyData() + guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else { + return false + } + return openCourierEnvelope(envelope) + } + + /// Hands a bridge-fetched envelope directly to the matching local peer + /// as a directed courier packet. Delivery-only by design: the recipient's + /// tag matched, so this never lands in a stranger's carry quota. + /// Returns true only if a current Noise-authenticated physical link + /// accepted the packet; a stale peer-level session, reachability record, + /// replay-rebound link, or process-local spool is not delivery. + @discardableResult + func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool { + guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false } + guard let payload = envelope.encode() else { return false } + let packet = makeCourierPacket(payload, to: peerID) + let send = { [weak self] in + self?.sendPacketDirected( + packet, + to: peerID, + requireDirectPeerLink: true, + requireNoiseAuthenticatedPeerLink: true + ) ?? false + } + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + return send() + } + return messageQueue.sync(execute: send) + } + + /// Our own Noise static public key (for computing our courier tags). + func myNoiseStaticPublicKey() -> Data { + noiseService.getStaticPublicKeyData() + } + + /// Verified reachable peers with known Noise keys — the set a bridge + /// gateway watches courier drops for. + func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.snapshotByID.values.compactMap { info in + guard info.isVerifiedNickname, + let key = info.noisePublicKey, + peerRegistry.isReachable(info.peerID, now: now) else { return nil } + return (info.peerID, key) + } + } + } + + /// The prekey to seal a courier message with, or nil to fall back to + /// static sealing. The real signal is a verified, unexpired bundle with a + /// spare prekey; the advertised `.prekeys` capability only acts as a veto + /// for peers we currently see on the mesh (a cached bundle can outlive a + /// peer's downgrade to a build that no longer holds the privates). + /// Re-deposits of the same message reuse its assigned prekey, so one + /// message consumes exactly one prekey ID regardless of courier count. + private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { + let shortID = PeerID(publicKey: recipientNoiseKey) + let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) { + return nil + } + return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) + } + + private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { + let packet = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + // Signed so a courier can authenticate the depositor before carrying + // mail under their quota. Handover to the recipient doesn't need the + // packet signature — the inner Noise X seal authenticates the sender. + return noiseService.signPacket(packet) ?? packet + } + + /// Handles both courier roles for an incoming envelope addressed to us: + /// recipient (the rotating tag matches our static key → open and deliver) + /// or courier (a trusted peer is depositing mail for someone else → store). + private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) { + // Directed packets only; envelopes addressed elsewhere ride the + // generic relay path untouched. + guard packet.recipientID == myPeerIDData else { return } + guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return } + + let myKey = noiseService.getStaticPublicKeyData() + if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) { + openCourierEnvelope(envelope) + } else { + acceptCourierDeposit(envelope, from: peerID, packet: packet) + } + } + + @discardableResult + private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool { + do { + let typedPayload: Data + let senderStaticKey: Data + if let prekeyID = envelope.prekeyID { + // Envelope v2: sealed to one of our one-time prekeys. Opening + // consumes the prekey (48h redelivery grace), which shrinks our + // published bundle under a strictly newer generatedAt. Re-gossip + // so peers replace their cached copy and stop assigning the + // consumed ID before the grace lapses; force the broadcast when + // the batch also topped back up (low-water), otherwise let the + // rebroadcast throttle coalesce bursts. + let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID) + (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey) + if opened.consumedPrekey { + let replenished = noiseService.replenishPrekeysIfNeeded() + sendPrekeyBundle(force: replenished) + } + } else { + (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) + } + guard let typeRaw = typedPayload.first, + let payloadType = NoisePayloadType(rawValue: typeRaw), + payloadType == .privateMessage else { + SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session) + return true // decrypted but deterministically unsupported + } + let payload = Data(typedPayload.dropFirst()) + guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else { + SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session) + return true // decrypted but deterministically malformed + } + // Redundant copies of one message arrive as distinct envelopes + // (fresh seal each: mesh couriers, bridge drops across relays), + // so dedup here on the inner message ID — before delivery, ack, + // and handshake work. A duplicate costs only the decrypt above + // and at most one ack ever goes out per message ID. + let firstOpen = collectionsQueue.sync(flags: .barrier) { + openedCourierMessageIDs.insert(innerMessageID) + } + guard firstOpen else { + SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))…", category: .session) + return true + } + // Couriered mail arrives while the sender is absent, so the UI's + // block check can't resolve their fingerprint from a live session. + // Gate here, where the full static key is in hand. + guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else { + SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security) + return true + } + // A present sender resolves to their live mesh thread via the + // derived short ID. An absent sender — the usual courier case — + // uses the full noise-key ID so the message lands on the stable + // favorite conversation instead of an unresolvable short-ID + // thread labeled "Unknown". + let shortID = PeerID(publicKey: senderStaticKey) + let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey) + SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) + sfMetrics?.record(.courierOpened) + notifyUI { [weak self] in + self?.deliverTransportEvent(.noisePayloadReceived( + peerID: senderPeerID, + type: payloadType, + payload: payload, + timestamp: Date() + )) + } + return true + } catch { + // Tag collision or stale key: not addressed to us after all. + SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption) + return false + } + } + + private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) { + // A deposit must come from its depositor over the direct link: the + // claimed sender has to be the ingress peer, and the packet signature + // has to verify against that peer's announced signing key. Otherwise + // an untrusted sender could route an envelope through any trusted + // neighbor and have us carry it under the neighbor's quota. + guard PeerID(hexData: packet.senderID) == peerID else { + SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security) + return + } + let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) } + guard let depositorKey = depositorInfo?.noisePublicKey else { + SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) + return + } + guard let signingKey = depositorInfo?.signingPublicKey, + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security) + return + } + let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false + let store = courierStore + let policy = courierDepositPolicy + let metrics = sfMetrics + Task { @MainActor in + guard let tier = policy(depositorKey, isVerifiedPeer) else { + SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session) + return + } + if store.deposit(envelope, from: depositorKey, tier: tier) { + SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session) + metrics?.record(.courierAccepted) + } + } + } + + /// Hand over any carried envelopes addressed to a peer we just heard from. + private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) { + let metrics = sfMetrics + let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in + guard let self, + let payload = envelope.encode(), + self.sendPacketDirected( + self.makeCourierPacket(payload, to: peerID), + to: peerID, + requireDirectPeerLink: true, + requireNoiseAuthenticatedPeerLink: true + ) else { + return false + } + metrics?.record(.courierHandedOver) + return true + } + if accepted > 0 { + SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session) + } + } + + /// Speculative handover toward a recipient heard only via a relayed + /// announce: the envelope floods the mesh as a directed packet (relays + /// treat it like a directed DM). Non-destructive — the carried copy stays + /// until a direct handover or expiry, throttled per envelope so repeated + /// announces don't re-flood. + private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) { + let envelopes = courierStore.envelopesForRemoteHandover( + recipientNoiseKey: noiseKey, + cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds + ) + guard !envelopes.isEmpty else { return } + SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session) + for envelope in envelopes { + guard let payload = envelope.encode() else { continue } + broadcastPacket(makeCourierPacket(payload, to: peerID)) + sfMetrics?.record(.courierRemoteHandover) + } + } + + /// Spray-and-wait: split copy budgets with another courier we just + /// encountered, so carried mail diffuses through a moving crowd instead + /// of riding a single carrier. Only favorites and verified peers qualify, + /// mirroring the deposit policy they would apply to us. + private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) { + let store = courierStore + let metrics = sfMetrics + let sendSpray: () -> Void = { [weak self] in + guard let self else { return } + let accepted = store.transferSprayCopies(to: noiseKey) { envelope in + guard let payload = envelope.encode(), + self.sendPacketDirected( + self.makeCourierPacket(payload, to: peerID), + to: peerID, + requireDirectPeerLink: true, + requireNoiseAuthenticatedPeerLink: true + ) else { + return false + } + metrics?.record(.courierSprayed) + return true + } + if accepted > 0 { + SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session) + } + } + let policy = courierDepositPolicy + Task { @MainActor in + // Same trust gate as deposits: don't hand mail to a peer who + // would reject it from us. + guard policy(noiseKey, isVerifiedPeer) != nil else { return } + sendSpray() + } + } + + // MARK: One-Time Prekey Bundles + + /// Broadcasts our signed prekey bundle and tracks it for gossip sync. + /// Unforced sends (piggybacked on announces) are throttled — gossip does + /// the spreading, the broadcast just keeps our own gossip entry fresh. + /// Forced sends (bundle changed after consumption) go immediately. + private func sendPrekeyBundle(force: Bool = false) { + let now = Date() + let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) { + if !force, + let last = lastPrekeyBundleSentAt, + now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds { + return false + } + lastPrekeyBundleSentAt = now + return true + } + guard shouldSend else { return } + guard let bundle = noiseService.currentPrekeyBundle(), + let payload = bundle.encode() else { + SecureLogger.error("❌ Failed to build prekey bundle", category: .security) + return + } + let packet = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + guard let signedPacket = noiseService.signPacket(packet) else { + SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security) + return + } + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(signedPacket) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(signedPacket) + } + } + gossipSyncManager?.onPublicPacketSeen(signedPacket) + } + + /// Ingests a gossiped prekey bundle. Attribution is layered: the outer + /// packet must originate from the bundle owner (fabricated sender IDs, used + /// to multiply cache/gossip entries, are rejected), and BOTH the inner + /// bundle signature and the outer packet signature must verify against the + /// owner's announce-bound signing key. Verifying the outer packet — whose + /// signed bytes cover senderID and timestamp — stops a valid bundle from + /// being replayed under a fresh timestamp or spoofed sender to pass + /// freshness or poison attribution. Only after that does the packet enter + /// our own gossip store, so we never help spread a bundle we couldn't + /// attribute. + private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) { + guard let bundle = PrekeyBundle.decode(packet.payload) else { + SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security) + return + } + // Our own bundle is tracked at send time; a copy echoing back adds nothing. + guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return } + let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) + // The owner's genuine bundle (direct or relayed) always carries the + // owner's senderID + outer signature; gossip resends preserve both. A + // packet whose senderID isn't the owner can't be authenticated here. + guard PeerID(hexData: packet.senderID) == owner else { + SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security) + return + } + // Look up the announce-bound signing key and stash-if-unbound in ONE + // barrier: the receive queue is concurrent, so this bundle can race + // ahead of the announce that binds the key. Reading the live registry + // and stashing atomically closes the check-then-act gap against + // handleAnnounce's drain (see drainPendingPrekeyBundles). + let signingKey: Data? = collectionsQueue.sync(flags: .barrier) { + if let info = peerRegistry.info(for: owner), + info.noisePublicKey == bundle.noiseStaticPublicKey, + let key = info.signingPublicKey { + return key + } + // Offline-verified identities are stable across this race. + for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner) + where candidate.publicKey == bundle.noiseStaticPublicKey { + if let key = candidate.signingPublicKey { return key } + } + // No binding yet: retain the latest bundle per owner, bounded, and + // retry once the verified announce lands. + if pendingPrekeyBundles[owner] != nil + || pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap { + pendingPrekeyBundles[owner] = packet + } + return nil + } + guard let signingKey else { + SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security) + return + } + ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) + } + + /// Verify a bundle's inner + outer signatures against the owner's bound + /// signing key and, on success, cache it and let it enter our gossip store. + private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) { + guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey), + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security) + return + } + if prekeyBundleStore.ingest(bundle) { + SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security) + } + gossipSyncManager?.onPublicPacketSeen(packet) + } + + /// Re-attempt any prekey bundle that arrived before this owner's announce + /// bound a signing key. Called from handleAnnounce after a verified + /// announce, in a barrier ordered after the registry write, so a bundle + /// stashed before the write is always observed here. + private func drainPendingPrekeyBundles(for owner: PeerID) { + let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) { + pendingPrekeyBundles.removeValue(forKey: owner) + } + guard let packet = pending, + let bundle = PrekeyBundle.decode(packet.payload), + let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return } + ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) + } + + /// Ed25519 signing key bound to a Noise static key by a verified + /// announce: from the live registry when the owner is on the mesh, else + /// from identities persisted for offline verification. + private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? { + let shortID = PeerID(publicKey: noiseKey) + if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }), + info.noisePublicKey == noiseKey, + let signingKey = info.signingPublicKey { + return signingKey + } + for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID) + where candidate.publicKey == noiseKey { + if let signingKey = candidate.signingPublicKey { + return signingKey + } + } + return nil + } + + // MARK: Gateway carrier (nostrCarrier) + + /// Sign and send an encoded `toGateway` carrier payload directed at a + /// gateway peer. The packet is signed so the gateway can key its uplink + /// quotas to an authenticated depositor; the carried Nostr event has its + /// own Schnorr signature for content authenticity. Returns false when + /// the gateway is not reachable or signing fails. + func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool { + guard isPeerReachable(gatewayPeer) else { return false } + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: gatewayPeer.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + guard let signed = noiseService.signPacket(packet) else { return false } + messageQueue.async { [weak self] in + // broadcastPacket applies a known route when one exists and + // otherwise floods the directed packet like a DM, so a gateway + // that is reachable but multi-hop still gets the deposit. + self?.broadcastPacket(signed) + } + return true + } + + /// Broadcast an encoded `fromGateway` carrier payload on the mesh with + /// the default TTL. Unsigned at the packet layer — receivers verify the + /// carried event's own Schnorr signature. + func broadcastNostrCarrier(_ payload: Data) { + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + + /// Transport-level handling for a received nostrCarrier packet; policy + /// (verification of the carried event, quotas, loop prevention) lives in + /// `GatewayService` behind `onNostrCarrierPacket`. + private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) { + let senderID = PeerID(hexData: packet.senderID) + let directedToUs: Bool + if let recipientID = packet.recipientID { + // Carriers addressed elsewhere ride the generic relay path untouched. + guard recipientID == myPeerIDData else { return } + // Uplink deposit: quotas are keyed by the depositor, so the + // packet signature must verify against the sender's announced + // signing key. Unlike courier deposits the depositor may be + // multi-hop away, so ingress-link identity is not required. + let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey } + guard let signingKey, + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security) + return + } + directedToUs = true + } else { + directedToUs = false + } + let payload = packet.payload + notifyUI { [weak self] in + self?.onNostrCarrierPacket?(payload, senderID, directedToUs) + } + } + // MARK: Link capability snapshots (thread-safe via bleQueue) private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { @@ -2522,6 +4078,62 @@ extension BLEService { } } + /// Writes immediately or synchronously admits the packet to this + /// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return + /// value distinguishes a retained queue item from one rejected or trimmed + /// immediately, which lets durable courier state commit truthfully. + private func writeOrEnqueueIfAccepted( + _ data: Data, + to peripheral: CBPeripheral, + characteristic: CBCharacteristic, + priority: BLEOutboundWritePriority, + requiredAuthenticatedPeer: PeerID? + ) -> Bool { + let accept = { [self] in + let uuid = peripheral.identifier.uuidString + guard let state = linkStateStore.state(forPeripheralID: uuid), + state.isConnected, + state.characteristic?.uuid == characteristic.uuid else { + return false + } + if let peerID = requiredAuthenticatedPeer { + let link = BLEIngressLinkID.peripheral(uuid) + guard state.peerID == peerID, + noiseAuthenticatedLinkOwners[link] == peerID else { + return false + } + } + + if peripheral.canSendWriteWithoutResponse { + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + return true + } + + let attempt = collectionsQueue.sync(flags: .barrier) { + pendingPeripheralWrites.enqueueReportingAcceptance( + data: data, + for: uuid, + priority: priority, + capBytes: TransportConfig.blePendingWriteBufferCapBytes + ) + } + switch attempt.result { + case .oversized(let bytes): + SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) + case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0: + SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session) + case .enqueued: + break + } + return attempt.accepted + } + + if DispatchQueue.getSpecific(key: bleQueueKey) != nil { + return accept() + } + return bleQueue.sync(execute: accept) + } + private func drainPendingWrites(for peripheral: CBPeripheral) { let uuid = peripheral.identifier.uuidString bleQueue.async { [weak self] in @@ -2575,34 +4187,132 @@ extension BLEService { #if os(iOS) @objc private func appDidBecomeActive() { isAppActive = true + refreshCachedBackgroundTimeRemaining() // Restart scanning with allow duplicates when app becomes active if centralManager?.state == .poweredOn { centralManager?.stopScan() startScanning() } + cancelStalePendingConnects() logBluetoothStatus("became-active") scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") // No Local Name; nothing to refresh for advertising policy } - + @objc private func appDidEnterBackground() { isAppActive = false + refreshCachedBackgroundTimeRemaining() // Restart scanning without allow duplicates in background if centralManager?.state == .poweredOn { centralManager?.stopScan() startScanning() } + armPendingBackgroundConnects() + // Backgrounding may precede a kill; flush the public-history archive + // outside its 30s maintenance cadence. + gossipSyncManager?.persistNow() logBluetoothStatus("entered-background") scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy } + + /// Issue indefinite `connect()` requests to recently seen peripherals on + /// backgrounding. Pending connects live in the Bluetooth controller's + /// allowlist — no scanning and no app CPU — and complete whenever a peer + /// comes into range, waking (or relaunching) the app. A couple of central + /// slots stay reserved for connects driven by live background discovery — + /// except on the disconnect re-arm path, which may consume the slot the + /// disconnect itself just freed (a dense mesh with 4+ remaining links + /// would otherwise compute a zero budget and never re-arm the lost peer). + private func armPendingBackgroundConnects( + slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve + ) { + bleQueue.async { [weak self] in + guard let self, let central = self.centralManager, central.state == .poweredOn else { return } + let budget = TransportConfig.bleMaxCentralLinks + - slotReserve + - self.linkStateStore.connectedOrConnectingPeripheralCount + let now = Date() + let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in + let state = self.linkStateStore.state(forPeripheralID: peripheralID) + return state?.isConnected == true || state?.isConnecting == true + } + guard !targets.isEmpty else { return } + for target in targets { + // lastConnectionAttempt stays nil: an indefinite pending connect + // has no attempt clock, and nil marks it always-stale so + // cancelStalePendingConnects() reclaims it on foreground even + // after a quick background→foreground bounce. + self.linkStateStore.setPeripheralState( + BLEPeripheralLinkState( + peripheral: target.peripheral, + characteristic: nil, + peerID: nil, + isConnecting: true, + isConnected: false, + lastConnectionAttempt: nil, + assembler: NotificationStreamAssembler() + ), + for: target.peripheralID + ) + target.peripheral.delegate = self + central.connect(target.peripheral, options: [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ]) + } + SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session) + } + } + + /// Foreground restores normal connection management: pending connects + /// older than the connect timeout (including ones rebuilt by state + /// restoration after a relaunch) are cancelled so live scanning and the + /// scheduler take over. Anything still nearby is rediscovered within + /// seconds by the allow-duplicates foreground scan. + private func cancelStalePendingConnects() { + bleQueue.async { [weak self] in + guard let self, let central = self.centralManager else { return } + let now = Date() + var cancelled = 0 + for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { + let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity + guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } + let peripheralID = state.peripheral.identifier.uuidString + central.cancelPeripheralConnection(state.peripheral) + self.collectionsQueue.sync(flags: .barrier) { + self.pendingPeripheralWrites.discardAll(for: peripheralID) + } + self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + _ = self.linkStateStore.removePeripheral(peripheralID) + cancelled += 1 + } + if cancelled > 0 { + SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) + self.tryConnectFromQueue() + } + } + } #endif // MARK: Private Message Handling private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) { + // Hop like sendMessage: the Transport-facing wrappers call this from + // the main actor (router sends, favorite notifications), and the send + // path sync-waits on bleQueue for link state. + if DispatchQueue.getSpecific(key: messageQueueKey) == nil { + messageQueue.async { [weak self] in + self?.sendPrivateMessage(content, to: recipientID, messageID: messageID) + } + return + } + // Sessions and wire recipient IDs are keyed by the short 16-hex form; + // callers may pass the full 64-hex noise key (mirrors sendFilePrivate). + let recipientID = recipientID.toShort() SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session) - + // Check if we have an established Noise session if noiseService.hasEstablishedSession(with: recipientID) { // Encrypt and send @@ -2700,7 +4410,7 @@ extension BLEService { // Notify delegate of failure notifyUI { [weak self] in - self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed"))) + self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer")))) } } } @@ -2718,25 +4428,37 @@ extension BLEService { // MARK: Fragmentation (Required for messages > BLE MTU) - private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) { + @discardableResult + private func sendFragmentedPacket( + _ packet: BitchatPacket, + pad: Bool, + maxChunk: Int? = nil, + directedOnlyPeer: PeerID? = nil, + transferId: String? = nil, + requireDirectPeerLink: Bool = false, + requireNoiseAuthenticatedPeerLink: Bool = false + ) -> Bool { let request = BLEOutboundFragmentTransferRequest( packet: packet, pad: pad, maxChunk: maxChunk, directedPeer: directedOnlyPeer, - transferId: transferId + transferId: transferId, + requireDirectPeerLink: requireDirectPeerLink, + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink ) let result = collectionsQueue.sync(flags: .barrier) { outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) } - handleFragmentTransferSubmitResult(result) + return handleFragmentTransferSubmitResult(result) } - private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) { + @discardableResult + private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool { switch result { case let .start(request, reservedTransferId): - startFragmentedPacket(request, reservedTransferId: reservedTransferId) + return startFragmentedPacket(request, reservedTransferId: reservedTransferId) case let .queued(_, transferId, _): if let transferId { @@ -2744,10 +4466,29 @@ extension BLEService { } else { SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session) } + return false + + case let .rejectedStrict(_, transferId): + SecureLogger.debug( + "🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy", + category: .session + ) + return false + + case let .droppedDuplicate(_, activeTransferId): + SecureLogger.debug( + "🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")…", + category: .session + ) + return false } } - private func startFragmentedPacket(_ request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) { + @discardableResult + private func startFragmentedPacket( + _ request: BLEOutboundFragmentTransferRequest, + reservedTransferId: String? + ) -> Bool { let releaseReservedSlot: (String) -> Void = { [weak self] id in guard let self = self else { return } TransferProgressManager.shared.cancel(id: id) @@ -2767,7 +4508,7 @@ extension BLEService { if let id = reservedTransferId { releaseReservedSlot(id) } - return + return false } // Lightweight pacing to reduce floods and allow BLE buffers to drain @@ -2793,6 +4534,42 @@ extension BLEService { return id }() + let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in + guard let self else { return false } + if request.requireDirectPeerLink, let directedPeer = request.directedPeer { + return self.sendPacketDirected( + fragmentPacket, + to: directedPeer, + requireDirectPeerLink: true, + requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink + ) + } + self.broadcastPacket(fragmentPacket) + return true + } + + // Strict courier handoff is transactional at the fragment-admission + // boundary: every fragment must enter the intended authenticated + // link or its bounded retry queue before the durable owner may commit. + // A partial train is harmlessly abandoned and the envelope stays + // retryable with a fresh fragment ID on the next encounter. + if request.requireDirectPeerLink { + let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in + guard sendFragment(fragmentPacket) else { return false } + if let transferId = transferIdentifier { + markFragmentSent(transferId: transferId) + } + return true + } + guard admitted else { + if let id = reservedTransferId { + releaseReservedSlot(id) + } + return false + } + return true + } + var scheduledItems: [(item: DispatchWorkItem, index: Int)] = [] for (index, fragmentPacket) in plan.fragmentPackets.enumerated() { @@ -2805,7 +4582,7 @@ extension BLEService { if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true { self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket) } - self.broadcastPacket(fragmentPacket) + _ = sendFragment(fragmentPacket) if let transferId = transferIdentifier { self.markFragmentSent(transferId: transferId) } @@ -2825,6 +4602,7 @@ extension BLEService { let delayMs = index * plan.spacingMs messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem) } + return true } // MARK: - Fragmentation (Required for messages > BLE MTU) @@ -2924,13 +4702,33 @@ extension BLEService { // Update peer info without verbose logging - update the peer we received from, not the original sender updatePeerLastSeen(peerID) - // Track recent traffic timestamps for adaptive behavior + // Track recent traffic timestamps for adaptive behavior; the same + // barrier hop confirms route health for the packet's originator. collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.recentTrafficTracker.recordPacket(at: Date()) + self.sourceRouteFailures.noteInboundActivity(from: senderID) } - + // Per-peer protocol version: originated source routes only use hops + // observed speaking v2 (a v1-only node cannot decode v2 frames). + if packet.version >= 2 { + meshTopology.recordObservedVersion(packet.version, for: packet.senderID) + if peerID != senderID { + meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID)) + } + } + + #if os(iOS) + // The maintenance timer is suspended with the app, so a packet arriving + // while backgrounded means the radio woke us — use the wake window to + // run the announce/flush/drain pass the timer would have run. + if !isAppActive { + bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() } + } + #endif + + // Process by type switch context.messageType { case .announce: @@ -2952,14 +4750,45 @@ extension BLEService { handleFragment(packet, from: senderID) case .fileTransfer: - handleFileTransfer(packet, from: senderID) - + // Broadcast files that fail sender authentication must not spread + // to downstream (possibly older, ungated) nodes; skip the relay + // step below, like invalid board posts and voice frames. + guard handleFileTransfer(packet, from: senderID) else { return } + + case .courierEnvelope: + handleCourierEnvelope(packet, from: peerID) + + case .groupMessage: + handleGroupMessage(packet, from: senderID) + + case .prekeyBundle: + handlePrekeyBundle(packet, from: senderID) + + case .boardPost: + // Invalid or deleted posts must not spread; skip the relay step. + guard handleBoardPost(packet, from: senderID) else { return } + case .nostrCarrier: + handleNostrCarrier(packet, from: peerID) + + case .voiceFrame: + // Rejected frames (unsigned/stale/spoofed) must not spread; skip + // the relay step below, like invalid board posts. + guard handleVoiceFrame(packet, from: senderID) else { return } + + case .ping: + // Rate limiting must key on the ingress link (`peerID`), not the + // packet-claimed sender: pings are unsigned, so `senderID` is + // attacker-controlled and rotating it would reset the budget. + handleMeshPing(packet, fromLink: peerID) + + case .pong: + handleMeshPong(packet, from: senderID) + case .leave: handleLeave(packet, from: senderID) - + case .none: SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) - break } if forwardAlongRouteIfNeeded(packet) { @@ -3016,7 +4845,266 @@ extension BLEService { } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { - announceHandler.handle(packet, from: peerID) + let result = announceHandler.handle(packet, from: peerID) + + // A verified announce is the moment a signing key becomes bound to this + // owner's noise key: retry any prekey bundle that raced ahead of it. + if let result, result.isVerified { + drainPendingPrekeyBundles(for: result.peerID) + } + + // A verified direct announce proves the sender owns the link it came + // in on: heal any stale binding left by a peer-ID rotation, and + // consolidate duplicate same-role connections onto that link. + if let result, result.isVerified, result.isDirectAnnounce { + rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID) + retireRedundantPeripheralLinks(packet, to: result.peerID) + } + + // Bridge courier watch: a verified announce may add a peer whose + // relay-parked drops we should start watching for. + if let result, result.isVerified { + onVerifiedPeerAnnounce?(result.peerID) + } + + // Courier work: an announce is the moment we learn a peer's Noise + // static key, so check whether we're carrying mail addressed to them + // (or spray-able mail they could carry). Verified announces only. + guard !courierStore.isEmpty, + let result, + result.isVerified else { return } + let noiseKey = result.announcement.noisePublicKey + let authenticatedIngress = result.isDirectAnnounce + && canDeliverSecurely(to: result.peerID) + && isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID) + if authenticatedIngress { + // The session was established on this still-bound ingress link. + // A peer-level Noise session alone is not enough: it can outlive + // its physical link while a replay rebinds an attacker's link to + // the victim's ID. + deliverCourierMail(to: result.peerID, noiseKey: noiseKey) + sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true) + } else { + // Relayed announce, or a direct-looking announce that has not yet + // proved link ownership with Noise: push a speculative copy while + // retaining the durable carried original. + deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey) + if result.isDirectAnnounce, + !hasCurrentNoiseAuthenticatedLink(to: result.peerID) { + if noiseService.hasEstablishedSession(with: result.peerID) { + // A session with no surviving authenticated link is stale; + // force the current link to prove possession again. + noiseService.clearSession(for: result.peerID) + } + if !noiseService.hasSession(with: result.peerID) { + initiateNoiseHandshake(with: result.peerID) + } + } + } + } + + /// When a peer relaunches it rotates its ephemeral peer ID, but an + /// already-open BLE connection keeps its old peripheral/central→peerID + /// binding. Until that binding heals, the rotated peer shows up twice in + /// the peer list and its directed traffic on this link is dropped as + /// spoofed. A signature-verified direct announce proves the claimed + /// sender owns the link it arrived on, so rebind the link to the new ID + /// and retire the old identity. + private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) { + guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return } + bleQueue.async { [weak self] in + guard let self else { return } + let linkUUID: String + let previousPeerID: PeerID? + switch link { + case .peripheral(let peripheralUUID): + linkUUID = peripheralUUID + previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID) + case .central(let centralUUID): + linkUUID = centralUUID + previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID) + } + guard let previousPeerID, previousPeerID != peerID else { return } + + // The signature does not authenticate directness (TTL is excluded + // from signing because relays mutate it), so a "verified direct" + // announce can be a replay of another peer's fresh announce with + // its TTL restored. Contain what a forged rebind could do: + // never steal an identity another live link already owns, and + // allow at most one rebind per link per cooldown window so two + // identities can't fight over a link in a replay flip-flop. + guard self.linkStateStore.links(to: peerID).isEmpty else { + SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security) + return + } + let now = Date() + self.lastLinkRebindAt = self.lastLinkRebindAt.filter { + now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds + } + guard self.lastLinkRebindAt[linkUUID] == nil else { + SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security) + return + } + self.lastLinkRebindAt[linkUUID] = now + + // A Noise proof belongs to the old physical binding. Never carry + // it across an announce-driven rebind, whose direct TTL is + // replayable; the new owner must complete a fresh handshake. + self.noiseAuthenticatedLinkOwners.removeValue(forKey: link) + switch link { + case .peripheral(let peripheralUUID): + self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID) + case .central(let centralUUID): + self.linkStateStore.bindCentral(centralUUID, to: peerID) + } + SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) + self.refreshLocalTopology() + // The announce that triggered this rebind was upserted as + // disconnected: the registry ran while the link still belonged + // to the previous ID (the ambiguous state BLEAnnounceHandler + // denies the connected shortcut). The rebind has now + // containment-checked the claim and the identity owns a live + // link, so promote it — otherwise a healed rotation leaves a + // live link that reads as disconnected until the next announce. + self.messageQueue.async { [weak self] in + self?.promoteReboundPeerToConnected(peerID) + } + // Any other peripheral links still bound to the rotated-away ID + // are stale duplicates of the same physical device (its restored + // connections outlived the relaunch that rotated the ID): cancel + // them now instead of leaving ghost links that spray duplicate + // traffic until the inactivity timeout. + self.cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID) + // Retire the rotated-away ID only once its last link is gone; a + // remaining stale link heals the same way or ages out. + guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return } + self.messageQueue.async { [weak self] in + self?.retireRotatedPeer(previousPeerID) + } + } + } + + /// After a restore relaunch the same phone can reappear under a fresh + /// peripheral UUID while its restored connection lives on, leaving + /// several live central-role connections to one peer that each carry + /// every packet (field-verified: every voice frame arrived 2-3x). A + /// verified direct announce is the consolidation point: keep the link it + /// proves live (or the peer's most recently bound one) and cancel the + /// rest. Only same-role duplicates are touched — one connection per role + /// is the normal dual-role topology — and only connections we own as + /// central: the peer's central subscriptions on our peripheral manager + /// are its connections to cancel, and it runs this same policy. + /// + /// Directness is forgeable (TTL is unsigned), so a replayed announce + /// could nominate the replayer's link as the survivor. Containment + /// mirrors the rotation rebind: only links already BOUND to the peer are + /// retired (announce-evidenced, never pre-announce links), at most one + /// retirement per peer per cooldown window, and the peer keeps a live + /// link either way. + private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) { + let ingressLink = collectionsQueue.sync { ingressLinks.link(for: packet) } + bleQueue.async { [weak self] in + guard let self else { return } + let now = Date() + self.lastRedundantLinkRetirementAt = self.lastRedundantLinkRetirementAt.filter { + now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds + } + guard self.lastRedundantLinkRetirementAt[peerID] == nil else { return } + + var ingressPeripheralUUID: String? + if case .peripheral(let uuid) = ingressLink { + ingressPeripheralUUID = uuid + } + guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: ingressPeripheralUUID, + mostRecentlyBoundUUID: self.linkStateStore.preferredPeripheralBindings[peerID], + links: self.peripheralLinkPolicySnapshot(), + peerID: peerID + ) else { return } + + self.lastRedundantLinkRetirementAt[peerID] = now + // The survivor becomes the peer's reverse-mapped link so directed + // sends follow the consolidation. + self.linkStateStore.bindPeripheral(keptUUID, to: peerID) + self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID) + self.refreshLocalTopology() + } + } + + /// Cancels our central-role connections whose link is bound to `peerID`, + /// except `keptUUID`. bleQueue only. Each entry is removed from the link + /// store BEFORE cancelling so didDisconnectPeripheral sees no peer + /// binding and skips its peer-disconnect bookkeeping — the peer is still + /// live (on the kept link, or under its rotated identity). + private func cancelBoundPeripheralLinks(to peerID: PeerID, keeping keptUUID: String?) { + let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire( + links: peripheralLinkPolicySnapshot(), + peerID: peerID, + keeping: keptUUID ?? "" + ) + for uuid in retiring { + guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue } + collectionsQueue.sync(flags: .barrier) { + pendingPeripheralWrites.discardAll(for: uuid) + } + noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) + _ = linkStateStore.removePeripheral(uuid) + SecureLogger.info( + "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", + category: .session + ) + centralManager?.cancelPeripheralConnection(state.peripheral) + } + } + + /// bleQueue only (reads the link store). + private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] { + linkStateStore.peripheralStates.map { + BLERedundantLinkPolicy.PeripheralLink( + uuid: $0.peripheral.identifier.uuidString, + peerID: $0.peerID, + isConnected: $0.isConnected, + hasCharacteristic: $0.characteristic != nil + ) + } + } + + /// After a successful verified rebind the new identity owns a live link, + /// but its announce was stored disconnected (the link was still bound to + /// the rotated-away ID when the registry upsert ran). Flip it to + /// connected and republish so routing and the peer list see the healed + /// link. The `.peerConnected` UI event already fired from the announce + /// path (new/reconnected + direct), so only list state needs refreshing. + private func promoteReboundPeerToConnected(_ peerID: PeerID) { + let promoted = collectionsQueue.sync(flags: .barrier) { + peerRegistry.markConnected(peerID) + } + guard promoted else { return } + refreshLocalTopology() + publishFullPeerData() + notifyUI { [weak self] in + guard let self else { return } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + } + } + + /// Rotation is an implicit leave of the old identity: drop it immediately + /// instead of letting a ghost duplicate linger for the reachability + /// retention window. + private func retireRotatedPeer(_ peerID: PeerID) { + let removed = collectionsQueue.sync(flags: .barrier) { + peerRegistry.remove(peerID) != nil + } + guard removed else { return } + gossipSyncManager?.removeAnnouncementForPeer(peerID) + refreshLocalTopology() + notifyUI { [weak self] in + guard let self else { return } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + self.deliverTransportEvent(.peerDisconnected(peerID)) + self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + } } /// Builds the announce handler environment. All queue hops stay here so @@ -3038,6 +5126,25 @@ extension BLEService { linkState: { [weak self] peerID in self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) }, + linkBoundToOtherPeer: { [weak self] packet, peerID in + // Reads the CURRENT binding — i.e. the state before + // rebindLinkAfterVerifiedDirectAnnounce (which runs after the + // handler) may steal the link and promote the new owner to + // connected. See the caller in BLEAnnounceHandler for why the + // residual forged-presence window this leaves is accepted. + guard let self else { return false } + guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false } + let boundPeerID: PeerID? = self.readLinkState { store in + switch link { + case .peripheral(let peripheralUUID): + return store.peerID(forPeripheralID: peripheralUUID) + case .central(let centralUUID): + return store.peerID(forCentralUUID: centralUUID) + } + } + guard let boundPeerID else { return false } + return boundPeerID != peerID + }, withRegistryBarrier: { [weak self] body in self?.collectionsQueue.sync(flags: .barrier) { body() } }, @@ -3049,7 +5156,9 @@ extension BLEService { noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, - now: now + now: now, + capabilities: announcement.capabilities ?? [], + bridgeGeohash: announcement.bridgeGeohash ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) }, shouldEmitReconnectLog: { [weak self] peerID, now in @@ -3108,8 +5217,85 @@ extension BLEService { ) } + // MARK: - Board (geohash bulletin board) + + /// Validates and stores an incoming board post or tombstone. Returns + /// whether the packet is worth relaying onward. + private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + guard let wire = BoardWire.decode(from: packet.payload) else { + SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session) + return false + } + // Posts are self-authenticating: the payload embeds the author's + // Ed25519 key and signature, so verification does not depend on the + // author still being around to announce. + guard wire.verifySignature() else { + if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security) + } + return false + } + switch boardStore.ingest(wire, packet: packet) { + case .accepted, .duplicate: + return true + case .rejected: + return false + } + } + + /// Broadcasts a pre-signed board payload (post or tombstone) built by the + /// board manager, and ingests it locally so it shows up on our own board + /// and joins gossip sync immediately. + func sendBoardPayload(_ payload: Data) { + guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else { + SecureLogger.error("❌ Refusing to send invalid board payload", category: .session) + return + } + messageQueue.async { [weak self] in + guard let self = self else { return } + let basePacket = BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: Data(hexString: self.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: self.messageTTL + ) + guard let signedPacket = self.noiseService.signPacket(basePacket) else { + SecureLogger.error("❌ Failed to sign board packet", category: .security) + return + } + // Pre-mark our own broadcast as processed to avoid handling a relayed self copy + let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) + self.messageDeduplicator.markProcessed(dedupID) + self.boardStore.ingest(wire, packet: signedPacket) + self.broadcastPacket(signedPacket) + } + } + // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) { + // REQUEST_SYNC is link-local by design (always sent with ttl 0): a + // nonzero TTL means a crafted or relayed request, and answering one + // would let a single small packet fan a full store replay out of + // every node it reaches. + guard packet.ttl == 0 else { + if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security) + } + return + } + // A response can replay the entire gossip store, so require proof the + // requester owns the claimed sender ID: the request must verify + // against the signing key from that peer's announce. + let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey } + guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security) + } + return + } guard let req = RequestSyncPacket.decode(from: packet.payload) else { SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session) return @@ -3172,8 +5358,78 @@ extension BLEService { ) } + /// Group broadcasts are opaque ciphertext to this layer: track them for + /// gossip backfill and hand the payload to the UI layer, where the group + /// coordinator decrypts and authenticates against the roster. Non-members + /// still relay (generic broadcast relay path) but never decode. + private func handleGroupMessage(_ packet: BitchatPacket, from _: PeerID) { + let isBroadcastRecipient: Bool = { + guard let recipient = packet.recipientID else { return true } + return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } + }() + guard isBroadcastRecipient, !packet.payload.isEmpty else { return } + + gossipSyncManager?.onPublicPacketSeen(packet) + + let payload = packet.payload + let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp)) + } + } + + /// Inbound public live-voice packet: broadcast-only, freshness-gated, and + /// signature-verified against the claimed sender's announce (mirrors the + /// public-message identity gate — `senderID` is attacker-controlled, so a + /// valid packet signature is required before any audio reaches the UI). + /// Returns whether the packet was accepted; rejected packets must not be + /// relayed either, or spoofed 0x29 floods would still amplify. + private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + guard peerID != myPeerID else { return false } + guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false } + guard !BLEPacketFreshnessPolicy.isStale( + timestampMilliseconds: packet.timestamp, + now: Date(), + maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds + ) else { return false } + + let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } + let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey + let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false + let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID) + guard verifiedViaRegistry || signedDisplayName != nil else { + SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security) + return false + } + guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: myPeerID, + localNickname: myNickname, + peers: peersSnapshot, + allowConnectedUnverified: false + ) ?? signedDisplayName else { + return false + } + + let payload = packet.payload + let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.deliverTransportEvent(.publicVoiceFrameReceived( + peerID: peerID, + nickname: senderNickname, + payload: payload, + timestamp: timestamp + )) + } + return true + } + private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { + let wasEstablished = noiseService.hasEstablishedSession(with: peerID) noisePacketHandler.handleHandshake(packet, from: peerID) + if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) { + markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) + } } private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { @@ -3270,8 +5526,6 @@ extension BLEService { let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { peerRegistry.transportSnapshots(selfNickname: myNickname) } - // Notify non-UI listeners - peerSnapshotSubject.send(transportPeers) // Notify UI on MainActor via delegate Task { @MainActor [weak self] in self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers) @@ -3282,6 +5536,7 @@ extension BLEService { private func performMaintenance() { maintenanceCounter += 1 + lastMaintenanceAt = Date() let now = Date() let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } @@ -3346,6 +5601,18 @@ extension BLEService { } } + #if os(iOS) + /// Catch-up maintenance for background wake windows (bleQueue-confined). + /// Rate-limited to the normal maintenance cadence so a burst of inbound + /// packets during one wake still runs at most one extra pass. + private func performBackgroundWakeMaintenanceIfStale() { + guard meshBackgroundEnabled, + !isAppActive, + Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return } + performMaintenance() + } + #endif + private func checkPeerConnectivity() { let now = Date() let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs } @@ -3395,10 +5662,21 @@ extension BLEService { // Clean old processed messages efficiently messageDeduplicator.cleanup() - // Clean old fragments (> configured seconds old) - collectionsQueue.sync(flags: .barrier) { + // Clean old fragments (> configured seconds old), then ask peers for + // the specific fragment streams whose reassembly has stalled instead + // of waiting for the next periodic GCS fragment round. + let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) fragmentAssemblyBuffer.removeExpired(before: cutoff) + sourceRouteFailures.prune(now: now) + return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( + stalledAfter: TransportConfig.bleFragmentResyncStallSeconds, + retryAfter: TransportConfig.bleFragmentResyncRetrySeconds, + now: now + ) + } + if !stalledFragmentIDs.isEmpty { + gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) } // Clean old connection timeout backoff entries (> window) diff --git a/bitchat/Services/BLE/BLESourceRouteFailureCache.swift b/bitchat/Services/BLE/BLESourceRouteFailureCache.swift new file mode 100644 index 00000000..fcf23d2c --- /dev/null +++ b/bitchat/Services/BLE/BLESourceRouteFailureCache.swift @@ -0,0 +1,95 @@ +import BitFoundation +import Foundation + +/// Tracks whether source-routed sends to a recipient appear to be working. +/// +/// A routed unicast rides exactly one path, so a broken hop silently loses the +/// packet where a flood would have healed around it. Rather than building a +/// retransmission machine (MessageRouter already retries at a higher layer), +/// this cache degrades: a routed send that sees no inbound traffic from the +/// recipient within the confirmation window marks the route as failed, and +/// subsequent sends fall back to flooding until the suppression TTL lapses. +struct BLESourceRouteFailureCache { + struct Config { + /// How long a routed send may go unconfirmed before it counts as a + /// route failure. + var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds + /// How long to flood instead of routing after a failure. + var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds + } + + private struct State { + var pendingSince: Date? + var suppressedUntil: Date? + } + + private let config: Config + private var states: [PeerID: State] = [:] + + init(config: Config = Config()) { + self.config = config + } + + /// Whether the next directed send to `recipient` may carry a source + /// route. Flips the recipient into suppression when the last routed send + /// went unconfirmed past the confirmation window. + mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool { + guard var state = states[recipient] else { return true } + + if let until = state.suppressedUntil { + guard now >= until else { return false } + state.suppressedUntil = nil + } + + if let pending = state.pendingSince, + now.timeIntervalSince(pending) > config.confirmationWindowSeconds { + // The routed send was never confirmed: treat the route as broken + // and flood until the suppression window lapses. + state.pendingSince = nil + state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds) + states[recipient] = state + return false + } + + states[recipient] = state + return true + } + + /// Records that a source-routed packet was sent to `recipient`. Keeps the + /// earliest unconfirmed send so back-to-back packets share one deadline. + mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) { + var state = states[recipient] ?? State() + if state.pendingSince == nil { + state.pendingSince = now + } + states[recipient] = state + } + + /// Any inbound packet authored by `peer` confirms the pending routed send + /// (delivery acks and replies arrive this way). Deliberately does not + /// lift an active suppression: that traffic may have arrived via flood. + mutating func noteInboundActivity(from peer: PeerID) { + guard var state = states[peer] else { return } + state.pendingSince = nil + if state.suppressedUntil == nil { + states.removeValue(forKey: peer) + } else { + states[peer] = state + } + } + + /// Drops entries that can no longer influence a routing decision. An + /// expired-but-unconverted pending entry is kept for as long as the + /// suppression it would trigger could still be active. + mutating func prune(now: Date = Date()) { + let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds + states = states.filter { _, state in + if let until = state.suppressedUntil, now < until { return true } + if let pending = state.pendingSince, + now.timeIntervalSince(pending) <= pendingRetention { + return true + } + return false + } + } +} diff --git a/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift new file mode 100644 index 00000000..5cc8bf9c --- /dev/null +++ b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift @@ -0,0 +1,40 @@ +import BitFoundation +import Foundation + +/// Decides whether an outbound directed packet should carry a v2 source +/// route. Pure gating logic so BLEService's hot send path stays a thin wire. +enum BLESourceRouteOriginationPolicy { + /// Returns the intermediate-hop route to attach, or nil to keep the + /// current flood/direct-write behavior unchanged. + /// + /// Routes are only originated when every gate passes: + /// - we authored the packet (relays must not rewrite and re-sign someone + /// else's packet; route-following for in-flight routed packets lives in + /// `BLERouteForwardingPolicy`), + /// - the packet is directed at a single peer (not broadcast), + /// - the packet has TTL headroom to traverse hops (link-local TTL-0 + /// packets like REQUEST_SYNC never route), + /// - the recipient is not directly connected (a direct write already + /// delivers in one hop), + /// - routing to the recipient is not suppressed by a recent unconfirmed + /// routed send, and + /// - the topology yields a complete path. + static func route( + for packet: BitchatPacket, + to recipient: PeerID, + localPeerIDData: Data, + isRecipientConnected: (PeerID) -> Bool, + shouldAttemptRoute: (PeerID) -> Bool, + computeRoute: (PeerID) -> [Data]? + ) -> [Data]? { + guard packet.senderID == localPeerIDData else { return nil } + guard let recipientData = packet.recipientID, + recipientData.count == 8, + !recipientData.allSatisfy({ $0 == 0xFF }) else { return nil } + guard packet.ttl > 1 else { return nil } + guard !isRecipientConnected(recipient) else { return nil } + guard shouldAttemptRoute(recipient) else { return nil } + guard let route = computeRoute(recipient), !route.isEmpty else { return nil } + return route + } +} diff --git a/bitchat/Services/Board/BoardAlertsModel.swift b/bitchat/Services/Board/BoardAlertsModel.swift new file mode 100644 index 00000000..35daedfe --- /dev/null +++ b/bitchat/Services/Board/BoardAlertsModel.swift @@ -0,0 +1,160 @@ +// +// BoardAlertsModel.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation + +/// Turns newly arriving board posts into local, scope-matched chat alerts. +/// Everything here is derived from posts the mesh already synced — no extra +/// wire traffic, nothing another peer can't already see. +/// +/// - Urgent, recent pins get one system line in the matching chat (geo pin → +/// that geohash's timeline, mesh pin → mesh chat), collapsed when several +/// arrive together. +/// - Every other new pin just marks the header's pin icon until the notices +/// sheet is opened. +@MainActor +final class BoardAlertsModel: ObservableObject { + struct Dependencies { + /// Own posts never alert; the author already knows. + var isOwnPost: @MainActor (BoardPostPacket) -> Bool + /// Appends a local system line to a scope's chat timeline + /// (geohash, or "" for mesh chat). + var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void + var now: () -> Date = Date.init + /// Schedules the collapsed flush of pending urgent alerts; tests + /// inject a synchronous hook. + var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000)) + flush() + } + } + } + + /// Posts older than this at arrival are backfilled history carried in by + /// a peer, not something happening now; they badge but never line the chat. + static let inlineRecencyWindow: TimeInterval = 30 * 60 + /// Urgent arrivals within this window collapse into one line. + static let collapseDelaySeconds: TimeInterval = 4 + private static let alertContentMaxChars = 120 + + /// Unseen new pins by postID (hex) → geohash scope, cleared when the + /// notices sheet opens. + @Published private(set) var unseenPostScopes: [String: String] = [:] + + /// PostIDs already handled this session, so store eviction/re-sync churn + /// can't re-alert. Bounded by session wire volume (32-byte strings). + private var handledPostIDs = Set() + private var pendingUrgent: [String: [BoardPostPacket]] = [:] + private var flushScheduled = false + private let dependencies: Dependencies + private var cancellable: AnyCancellable? + private var wipeCancellable: AnyCancellable? + + private enum Strings { + static func urgentSingle(author: String, content: String) -> String { + String( + format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"), + locale: .current, + author, content + ) + } + + static func urgentCollapsed(_ count: Int) -> String { + String( + format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"), + locale: .current, + count + ) + } + } + + init( + arrivals: AnyPublisher, + wipes: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: Dependencies + ) { + self.dependencies = dependencies + cancellable = arrivals + .receive(on: DispatchQueue.main) + .sink { [weak self] post in + self?.handleArrival(post) + } + wipeCancellable = wipes + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.reset() + } + } + + func unseenCount(forGeohash geohash: String) -> Int { + unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) } + } + + /// Marks pins in the given scopes as seen — only the scopes the notices + /// sheet actually shows, so unseen pins for other geohash channels keep + /// their badge until visited. + func markSeen(forScopes scopes: Set) { + guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return } + unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) } + } + + /// Panic wipe: drop everything derived from pre-wipe posts, including + /// urgent lines still waiting on the collapse flush. + func reset() { + pendingUrgent.removeAll() + handledPostIDs.removeAll() + guard !unseenPostScopes.isEmpty else { return } + unseenPostScopes.removeAll() + } + + func handleArrival(_ post: BoardPostPacket) { + let postID = post.postID.hexEncodedString() + guard !handledPostIDs.contains(postID) else { return } + handledPostIDs.insert(postID) + guard !dependencies.isOwnPost(post) else { return } + + unseenPostScopes[postID] = post.geohash + + let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) + guard post.isUrgent, + dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else { + return + } + pendingUrgent[post.geohash, default: []].append(post) + if !flushScheduled { + flushScheduled = true + dependencies.scheduleFlush { [weak self] in + self?.flushPendingUrgent() + } + } + } + + private func flushPendingUrgent() { + flushScheduled = false + let pending = pendingUrgent + pendingUrgent.removeAll() + for (geohash, posts) in pending { + guard let first = posts.first else { continue } + let line: String + if posts.count == 1 { + let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon" + line = Strings.urgentSingle(author: author, content: Self.truncated(first.content)) + } else { + line = Strings.urgentCollapsed(posts.count) + } + dependencies.emitSystemLine(line, geohash) + } + } + + private static func truncated(_ content: String) -> String { + guard content.count > alertContentMaxChars else { return content } + return content.prefix(alertContentMaxChars) + "…" + } +} diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift new file mode 100644 index 00000000..60c6b827 --- /dev/null +++ b/bitchat/Services/Board/BoardManager.swift @@ -0,0 +1,196 @@ +// +// BoardManager.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Combine +import Foundation + +/// UI-facing coordinator for the bulletin board: builds and signs posts and +/// tombstones with the device's Noise signing key, hands them to the mesh +/// transport, and mirrors the store's live posts for SwiftUI. +@MainActor +final class BoardManager: ObservableObject { + /// Live posts across all boards, newest state from the store. + @Published private(set) var posts: [BoardPostPacket] = [] + + private let transport: Transport + /// Publishes a bridged kind-1 note (expiring with the board post via + /// NIP-40) and returns its Nostr event id, or nil when bridging failed or + /// was skipped. + private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String? + /// Requests NIP-09 deletion of a previously bridged note. + private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void + /// Bridged Nostr event ids by postID, for merged deletes. In-memory only: + /// after a relaunch a delete still tombstones the board copy, but the + /// Nostr copy is left to expire with relay retention. + private var bridgedEventIDs: [Data: String] = [:] + private var cancellable: AnyCancellable? + + init( + transport: Transport, + store: BoardStore = .shared, + publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil, + deleteFromNostr: ((String, String) -> Void)? = nil + ) { + self.transport = transport + self.publishToNostr = publishToNostr ?? Self.livePublishToNostr + self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr + cancellable = store.$postsSnapshot + .receive(on: DispatchQueue.main) + .sink { [weak self] snapshot in + self?.posts = snapshot + } + } + + /// Posts for one board context, urgent first, then newest first. + func posts(forGeohash geohash: String) -> [BoardPostPacket] { + posts + .filter { $0.geohash == geohash } + .sorted { + if $0.isUrgent != $1.isUrgent { return $0.isUrgent } + return $0.createdAt > $1.createdAt + } + } + + func isOwnPost(_ post: BoardPostPacket) -> Bool { + let key = transport.noiseSigningPublicKeyData() + return !key.isEmpty && key == post.authorSigningKey + } + + /// Creates, signs, and broadcasts a board post. Returns false when the + /// content is empty/oversized or signing fails. + @discardableResult + func createPost( + content: String, + geohash: String, + urgent: Bool, + expiryDays: Int, + nickname: String + ) -> Bool { + guard let trimmed = content.trimmedOrNilIfEmpty, + trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else { + return false + } + let signingKey = transport.noiseSigningPublicKeyData() + guard signingKey.count == BoardWireConstants.signingKeyLength else { return false } + + var cleanNickname = nickname + while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes { + cleanNickname.removeLast() + } + let createdAt = UInt64(Date().timeIntervalSince1970 * 1000) + let lifetimeMs = min( + UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000, + BoardWireConstants.maxLifetimeMs + ) + let expiresAt = createdAt + lifetimeMs + let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0 + var postID = Data(count: BoardWireConstants.postIDLength) + let status = postID.withUnsafeMutableBytes { buffer -> Int32 in + guard let base = buffer.baseAddress else { return -1 } + return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base) + } + guard status == errSecSuccess else { return false } + + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: trimmed, + authorSigningKey: signingKey, + authorNickname: cleanNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags + ) + guard let signature = transport.noiseSignData(signingBytes) else { + SecureLogger.error("Board: failed to sign post", category: .session) + return false + } + let post = BoardPostPacket( + postID: postID, + geohash: geohash, + content: trimmed, + authorSigningKey: signingKey, + authorNickname: cleanNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags, + signature: signature + ) + transport.sendBoardPayload(BoardWire.post(post).encode()) + + // Nostr bridge: geohash posts also go out as kind-1 location notes so + // online users see them. Remember the event id for merged deletes. + if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) { + bridgedEventIDs[postID] = eventID + } + return true + } + + /// Signs and broadcasts a tombstone for one of our own posts. + @discardableResult + func deletePost(_ post: BoardPostPacket) -> Bool { + guard isOwnPost(post) else { return false } + let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000) + let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt) + guard let signature = transport.noiseSignData(signingBytes) else { + SecureLogger.error("Board: failed to sign tombstone", category: .session) + return false + } + let tombstone = BoardTombstonePacket( + postID: post.postID, + authorSigningKey: post.authorSigningKey, + deletedAt: deletedAt, + signature: signature + ) + transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) + + // Merged delete: also retract the bridged Nostr copy when we still + // know its event id. + if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) { + deleteFromNostr(eventID, post.geohash) + } + return true + } + + private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? { + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session) + return nil + } + do { + let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) + let event = try NostrProtocol.createGeohashTextNote( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: nickname, + expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000), + urgent: urgent + ) + NostrRelayManager.shared.sendEvent(event, to: relays) + return event.id + } catch { + SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session) + return nil + } + } + + private static func liveDeleteFromNostr(eventID: String, geohash: String) { + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { return } + do { + let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) + let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity) + NostrRelayManager.shared.sendEvent(deletion, to: relays) + } catch { + SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session) + } + } +} diff --git a/bitchat/Services/Board/BoardStore.swift b/bitchat/Services/Board/BoardStore.swift new file mode 100644 index 00000000..db6a9605 --- /dev/null +++ b/bitchat/Services/Board/BoardStore.swift @@ -0,0 +1,368 @@ +// +// BoardStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Outcome of feeding a board packet into the store, so the transport can +/// decide whether the packet is still worth relaying. +enum BoardIngestResult { + /// New post or tombstone accepted (or a quota rejected it locally while + /// it remains valid for other devices). + case accepted + /// Already known; nothing changed. + case duplicate + /// Invalid, expired, or deleted; do not relay. + case rejected +} + +/// Persistent storage for bulletin-board posts and their tombstones. +/// +/// Posts are signed public notices designed to outlive chat: they stay on +/// disk until their author-chosen expiry (max 7 days) and re-enter gossip +/// sync after a restart. Tombstones are retained until the deleted post's +/// original expiry so the delete keeps outrunning stale copies of the post. +/// +/// The on-disk format is the raw signed packets themselves (like +/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting +/// them on launch. Wiped on panic. +final class BoardStore { + enum Limits { + static let maxPosts = 200 + static let maxPostsPerAuthor = 5 + /// Retention for a tombstone whose post we never saw: we cannot know + /// the original expiry, so cap at the max post lifetime. + static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs + /// Orphan tombstones name posts nobody here has seen, so their volume + /// is entirely sender-controlled; cap them like posts. + static let maxOrphanTombstones = 100 + static let maxOrphanTombstonesPerAuthor = 5 + /// Allowance for clock skew between peers when judging received + /// timestamps against local time. + static let clockSkewMs: UInt64 = 60 * 60 * 1000 + } + + private struct StoredPost { + let post: BoardPostPacket + let packet: BitchatPacket + let rawPacket: Data + } + + private struct StoredTombstone { + let tombstone: BoardTombstonePacket + let packet: BitchatPacket + let rawPacket: Data + let retainUntil: UInt64 + /// True when no matching post was known at ingest time; only these + /// count against the orphan caps. + let isOrphan: Bool + } + + /// On-disk entry: the raw signed packet, plus the retention deadline for + /// tombstones (derived from the deleted post's original expiry, which is + /// no longer recoverable once the post is gone). + private struct PersistedEntry: Codable { + let packet: Data + let retainUntil: UInt64? + } + + static let shared = BoardStore() + + /// Live posts, published on the main thread for the board UI. + @Published private(set) var postsSnapshot: [BoardPostPacket] = [] + + /// Fires on the main thread for each post newly accepted from the wire + /// (radio, sync, or local echo) — not for disk restores. Drives the + /// local new-pin chat alerts; duplicates never fire twice because the + /// store rejects them. + let postArrivals = PassthroughSubject() + + /// Fires on the main thread after a panic wipe so derived state (pending + /// alerts, unseen badges) is dropped along with the posts themselves. + let didWipe = PassthroughSubject() + + private var posts: [StoredPost] = [] + private var tombstones: [StoredTombstone] = [] + private let queue = DispatchQueue(label: "chat.bitchat.board.store") + private let fileURL: URL? + private let now: () -> Date + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) { + self.now = now + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Ingest + + /// Ingest a board packet whose payload decodes to `wire`. The caller must + /// have verified the wire signature already (`BoardWire.verifySignature`). + @discardableResult + func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult { + guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected } + let nowMs = currentMs() + return queue.sync { + let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs) + if result == .accepted { + persistLocked() + if case .post(let post) = wire { + DispatchQueue.main.async { [weak self] in + self?.postArrivals.send(post) + } + } + } + return result + } + } + + // MARK: - Reads + + /// Live posts scoped to one board (geohash, or "" for the mesh board). + func posts(forGeohash geohash: String) -> [BoardPostPacket] { + let nowMs = currentMs() + return queue.sync { + pruneExpiredLocked(nowMs: nowMs) + return posts.map(\.post).filter { $0.geohash == geohash } + } + } + + /// Raw signed packets (posts and live tombstones) for gossip sync rounds. + func syncCandidates() -> [BitchatPacket] { + let nowMs = currentMs() + return queue.sync { + pruneExpiredLocked(nowMs: nowMs) + return posts.map(\.packet) + tombstones.map(\.packet) + } + } + + // MARK: - Maintenance + + /// Panic wipe: drop all board data from memory and disk. + func wipe() { + queue.sync { + posts.removeAll() + tombstones.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + publishSnapshotLocked() + } + DispatchQueue.main.async { [weak self] in + self?.didWipe.send() + } + } + + // MARK: - Internals (call only on `queue`) + + private func ingestLocked( + _ wire: BoardWire, + packet: BitchatPacket, + rawPacket: Data, + nowMs: UInt64, + retainUntilOverride: UInt64? = nil + ) -> BoardIngestResult { + pruneExpiredLocked(nowMs: nowMs) + switch wire { + case .post(let post): + return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs) + case .tombstone(let tombstone): + return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride) + } + } + + private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult { + guard post.expiresAt > nowMs else { return .rejected } + // Receive-time sanity (this is the single chokepoint for radio, sync, + // and disk restores): the decoder only enforces the createdAt to + // expiresAt span, so a forged future createdAt would sort ahead of + // honest posts and hold a store slot without ever pruning. + guard post.createdAt <= nowMs &+ Limits.clockSkewMs, + post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else { + return .rejected + } + if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) { + return .rejected + } + guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate } + + posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket)) + + // Per-author cap, then global cap; oldest posts are evicted first. + let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey } + if authorPosts.count > Limits.maxPostsPerAuthor { + evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor) + } + if posts.count > Limits.maxPosts { + evictOldestLocked(from: posts, keep: Limits.maxPosts) + } + publishSnapshotLocked() + // Even when the new post itself was the eviction victim it stays + // valid mesh-wide; peers with room should still receive it. + return .accepted + } + + private func ingestTombstoneLocked( + _ tombstone: BoardTombstonePacket, + packet: BitchatPacket, + rawPacket: Data, + nowMs: UInt64, + retainUntilOverride: UInt64? = nil + ) -> BoardIngestResult { + guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate } + + // Cap retention by both the claimed deletion time (so a doctored file + // cannot pin a tombstone past any legal expiry) and the receive time: + // deletedAt is sender-chosen, so a far-future value must not retain + // the tombstone longer than any post still able to arrive could live. + let maxRetain = min( + tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs, + nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs + ) + let retainUntil: UInt64 + let isOrphan: Bool + if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) { + let target = posts[index].post + // Only the author's key can delete: the tombstone signature was + // already verified against its embedded key, so it suffices to + // require that key to be the post's author key. + guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected } + retainUntil = target.expiresAt + isOrphan = false + posts.remove(at: index) + publishSnapshotLocked() + } else if let retainUntilOverride { + // Restored from disk: the post is long gone, so trust the + // retention deadline recorded when the delete was first applied. + // Orphans were already capped when first ingested off the air. + retainUntil = min(retainUntilOverride, maxRetain) + isOrphan = false + } else { + // Post unknown (tombstone raced ahead); keep it around so the + // post is suppressed if it arrives later. + retainUntil = maxRetain + isOrphan = true + } + guard retainUntil > nowMs else { return .rejected } + tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan)) + if isOrphan { + enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey) + } + // Like posts, a locally evicted tombstone stays valid mesh-wide. + return .accepted + } + + /// Orphan tombstones reference posts we never saw, so a peer can mint + /// unlimited valid ones for random IDs; bound them per author and + /// globally, evicting the oldest received first (array order). + private func enforceOrphanTombstoneCapsLocked(author: Data) { + let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author } + if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor { + removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor)) + } + let orphans = tombstones.filter(\.isOrphan) + if orphans.count > Limits.maxOrphanTombstones { + removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones)) + } + } + + private func removeTombstonesLocked(_ victims: ArraySlice) { + guard !victims.isEmpty else { return } + let victimIDs = Set(victims.map { $0.tombstone.postID }) + tombstones.removeAll { victimIDs.contains($0.tombstone.postID) } + } + + private func evictOldestLocked(from candidates: [StoredPost], keep: Int) { + let victims = candidates + .sorted { $0.post.createdAt < $1.post.createdAt } + .prefix(max(0, candidates.count - keep)) + guard !victims.isEmpty else { return } + let victimIDs = Set(victims.map { $0.post.postID }) + posts.removeAll { victimIDs.contains($0.post.postID) } + } + + private func pruneExpiredLocked(nowMs: UInt64) { + let postsBefore = posts.count + posts.removeAll { $0.post.expiresAt <= nowMs } + tombstones.removeAll { $0.retainUntil <= nowMs } + if posts.count != postsBefore { + publishSnapshotLocked() + } + } + + private func publishSnapshotLocked() { + let snapshot = posts.map(\.post) + DispatchQueue.main.async { [weak self] in + self?.postsSnapshot = snapshot + } + } + + private func currentMs() -> UInt64 { + UInt64(max(0, now().timeIntervalSince1970) * 1000) + } + + // MARK: - Persistence + + private func persistLocked() { + guard let fileURL else { return } + let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) } + + tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) } + do { + if payloads.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(payloads) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist board store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else { + return + } + let nowMs = currentMs() + queue.sync { + for entry in payloads { + guard let packet = BitchatPacket.from(entry.packet), + packet.type == MessageType.boardPost.rawValue, + let wire = BoardWire.decode(from: packet.payload), + wire.verifySignature() else { continue } + _ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil) + } + publishSnapshotLocked() + } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("board", isDirectory: true) + .appendingPathComponent("posts.json") + } +} diff --git a/bitchat/Services/Board/UnifiedNotices.swift b/bitchat/Services/Board/UnifiedNotices.swift new file mode 100644 index 00000000..31bc14c8 --- /dev/null +++ b/bitchat/Services/Board/UnifiedNotices.swift @@ -0,0 +1,95 @@ +// +// UnifiedNotices.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// One row in the unified notices sheet: a mesh board post or a Nostr +/// location note, normalized for display. +struct NoticeItem: Identifiable, Equatable { + enum Source: Equatable { + /// Signed board post carried by the mesh. + case board(BoardPostPacket) + /// Kind-1 location note seen on geo relays. + case nostr(LocationNotesManager.Note) + } + + let id: String + let author: String + let content: String + let createdAt: Date + let isUrgent: Bool + /// When the notice fades (board expiry or a note's NIP-40 tag, as dead + /// drops carry). Nil means it only ages out of the relay window. + let expiresAt: Date? + let source: Source + + var isBoardPost: Bool { + if case .board = source { return true } + return false + } + + init(post: BoardPostPacket) { + id = post.postID.hexEncodedString() + author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" + content = post.content + createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) + isUrgent = post.isUrgent + expiresAt = post.expiresAt > 0 + ? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000) + : nil + source = .board(post) + } + + init(note: LocationNotesManager.Note) { + id = note.id + let display = note.displayName + author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + .first.map(String.init) ?? display + content = note.content + createdAt = note.createdAt + isUrgent = note.isUrgent + expiresAt = note.expiresAt + source = .nostr(note) + } +} + +/// Merges mesh board posts and Nostr location notes into one deduplicated +/// list for the notices sheet's geo tab. +enum UnifiedNotices { + /// Board posts on geohash channels are bridged to Nostr as kind-1 notes at + /// post time, so the same notice arrives twice. The copies share content + /// and nickname but are signed by unlinkable keys; match them + /// heuristically by content + author within a time window. + static let bridgeDedupeWindow: TimeInterval = 15 * 60 + + /// Returns board posts and notes as one list, urgent posts first, then + /// newest first. Notes that look like bridged copies of a board post are + /// dropped — the board copy wins because it carries urgency and supports + /// merged deletion. The geohash must match exactly: the notes + /// subscription also surfaces neighboring cells, and a same-text note + /// from a neighbor is not the bridged copy. + static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] { + var items = posts.map(NoticeItem.init(post:)) + for note in notes { + let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon" + let isBridgedCopy = posts.contains { post in + post.geohash == note.geohash + && post.content == note.content + && (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname + && abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow + } + if !isBridgedCopy { + items.append(NoticeItem(note: note)) + } + } + return items.sorted { + if $0.isUrgent != $1.isUrgent { return $0.isUrgent } + return $0.createdAt > $1.createdAt + } + } +} diff --git a/bitchat/Services/CashuTokenDecoder.swift b/bitchat/Services/CashuTokenDecoder.swift new file mode 100644 index 00000000..87dfee02 --- /dev/null +++ b/bitchat/Services/CashuTokenDecoder.swift @@ -0,0 +1,339 @@ +// +// CashuTokenDecoder.swift +// bitchat +// +// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` = +// base64url CBOR) just far enough to summarize them for the UI: total +// amount, unit, mint host, and memo. The app never contacts a mint — tokens +// are bearer strings and redemption is delegated to an external wallet. +// +// This parses attacker-controlled message content, so every path is +// bounds-checked, size-capped, and returns nil instead of trapping. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +enum CashuTokenDecoder { + + struct TokenInfo: Equatable { + /// Token serialization version: "A" (JSON) or "B" (CBOR). + let version: String + /// Sum of all proof amounts; nil when no valid amounts were found. + let amount: Int? + /// Currency unit as declared by the token (commonly "sat"), if any. + let unit: String? + /// Host of the (first) mint URL, for display. + let mintHost: String? + /// Optional sender memo, sanitized for display. + let memo: String? + + /// "500 sat" style summary, defaulting the unit to sats per NUT-00. + var displayAmount: String? { + amount.map { "\($0) \(unit ?? "sat")" } + } + } + + /// Upper bound on accepted token length in characters. Real tokens are a + /// few KB; anything much bigger is abuse we shouldn't spend CPU on. + static let maxTokenLength = 60_000 + /// Per-proof and total amount sanity caps (order of total sats in existence). + private static let maxAmount: Int64 = 2_100_000_000_000_000 + + // MARK: - Public API + + /// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be + /// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the + /// input doesn't look like a Cashu token at all. + static func bareToken(from raw: String) -> String? { + var token = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = token.lowercased() + if lower.hasPrefix("cashu://") { + token = String(token.dropFirst(8)) + } else if lower.hasPrefix("cashu:") { + token = String(token.dropFirst(6)) + } + if token.contains("%"), let decoded = token.removingPercentEncoding { + token = decoded + } + guard token.count >= 12, token.count <= maxTokenLength else { return nil } + guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil } + // Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens) + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=.")) + guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + return token + } + + /// Decodes a token (raw or `cashu:` URI form) into a display summary. + /// + /// In the default (permissive) mode this is for *rendering*: V3 tokens + /// must parse as JSON, but a V4 token whose CBOR we cannot walk still + /// returns a generic `TokenInfo` (version "B", no amount) because the + /// payload may use encodings this minimal reader doesn't support — an + /// unknown chip is fine for display. + /// + /// In `strict` mode (used by the `/pay` SEND path) there is no permissive + /// fallback: the token must cleanly decode to a known version *and* carry + /// a positive amount, otherwise this returns nil. This stops base64 junk + /// and truncated V4 tokens from being relayed as if they were valid money. + static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? { + guard let token = bareToken(from: raw) else { return nil } + let version = String(token[token.index(token.startIndex, offsetBy: 5)]) + guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else { + return nil + } + let info: TokenInfo? + switch version { + case "A": + info = decodeV3(payload) + case "B": + if let walked = decodeV4(payload) { + info = walked + } else if strict { + // Couldn't cleanly walk the CBOR — refuse to send it. + return nil + } else { + info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil) + } + default: + return nil + } + guard let info else { return nil } + if strict { + // A sendable token must resolve to a positive, sane amount. + guard let amount = info.amount, amount > 0 else { return nil } + } + return info + } + + // MARK: - Base64url + + private static func base64URLDecode(_ input: String) -> Data? { + var s = input + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Normalize padding (wallets emit both padded and unpadded forms) + s = s.replacingOccurrences(of: "=", with: "") + let remainder = s.count % 4 + if remainder == 1 { return nil } + if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) } + return Data(base64Encoded: s) + } + + // MARK: - V3 (JSON) + + private static func decodeV3(_ payload: Data) -> TokenInfo? { + guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any], + let entries = obj["token"] as? [[String: Any]], + !entries.isEmpty else { + return nil + } + var total: Int64 = 0 + var sawAmount = false + var mintHost: String? + for entry in entries { + if mintHost == nil, let mint = entry["mint"] as? String { + mintHost = sanitizedHost(from: mint) + } + for proof in (entry["proofs"] as? [[String: Any]]) ?? [] { + guard let number = proof["amount"] as? NSNumber else { continue } + let value = number.int64Value + guard value > 0, value <= maxAmount else { continue } + total += value + guard total <= maxAmount else { return nil } + sawAmount = true + } + } + return TokenInfo( + version: "A", + amount: sawAmount ? Int(total) : nil, + unit: sanitizedUnit(obj["unit"] as? String), + mintHost: mintHost, + memo: sanitizedMemo(obj["memo"] as? String) + ) + } + + // MARK: - V4 (CBOR) + + /// Minimal walk of the NUT-00 TokenV4 CBOR map: + /// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] } + private static func decodeV4(_ payload: Data) -> TokenInfo? { + var reader = CBORReader(data: payload) + guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil } + var mintHost: String? + var unit: String? + var memo: String? + var total: Int64 = 0 + var sawAmount = false + for (key, value) in pairs { + guard case .text(let name) = key else { continue } + switch (name, value) { + case ("m", .text(let mint)): + mintHost = sanitizedHost(from: mint) + case ("u", .text(let u)): + unit = sanitizedUnit(u) + case ("d", .text(let d)): + memo = sanitizedMemo(d) + case ("t", .array(let groups)): + for case .map(let group) in groups { + for case (.text("p"), .array(let proofs)) in group { + for case .map(let proof) in proofs { + for case (.text("a"), .unsigned(let amount)) in proof { + guard amount > 0, amount <= UInt64(maxAmount) else { continue } + total += Int64(amount) + guard total <= maxAmount else { return nil } + sawAmount = true + } + } + } + } + default: + break + } + } + return TokenInfo( + version: "B", + amount: sawAmount ? Int(total) : nil, + unit: unit, + mintHost: mintHost, + memo: memo + ) + } + + // MARK: - Display Sanitization (values are attacker-controlled) + + private static func sanitizedHost(from mint: String) -> String? { + guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil } + return String(host.lowercased().prefix(48)) + } + + private static func sanitizedUnit(_ unit: String?) -> String? { + guard let unit, !unit.isEmpty, unit.count <= 12, + unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else { + return nil + } + return unit + } + + private static func sanitizedMemo(_ memo: String?) -> String? { + guard let memo, memo.count <= 512 else { return nil } + let stripped = CharacterSet.controlCharacters.union(.newlines) + var cleaned = "" + cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) }) + cleaned = cleaned.trimmingCharacters(in: .whitespaces) + guard !cleaned.isEmpty else { return nil } + return String(cleaned.prefix(80)) + } +} + +// MARK: - Minimal CBOR Reader + +/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in +/// depth, item count, and byte length; indefinite-length items and anything +/// else exotic make the parse fail (the caller degrades to a generic chip). +private struct CBORReader { + indirect enum Value { + case unsigned(UInt64) + case text(String) + case array([Value]) + case map([(Value, Value)]) + /// Parsed-and-skipped content we don't need (byte strings, negatives, floats…) + case opaque + } + + private let bytes: [UInt8] + private var index = 0 + /// Total item budget so hostile nesting can't run away. + private var itemBudget = 50_000 + private static let maxDepth = 16 + private static let maxContainerCount: UInt64 = 10_000 + + init(data: Data) { + bytes = [UInt8](data) + } + + mutating func parseValue(depth: Int) -> Value? { + guard depth < Self.maxDepth, itemBudget > 0 else { return nil } + itemBudget -= 1 + guard let (major, argument) = readHead() else { return nil } + switch major { + case 0: // unsigned int + return .unsigned(argument) + case 1: // negative int (argument already consumed) + return .opaque + case 2: // byte string + return readBytes(count: argument) != nil ? .opaque : nil + case 3: // text string + guard let raw = readBytes(count: argument) else { return nil } + return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque + case 4: // array + guard argument <= Self.maxContainerCount else { return nil } + var items: [Value] = [] + items.reserveCapacity(Int(min(argument, 64))) + for _ in 0.. (major: UInt8, argument: UInt64)? { + guard index < bytes.count else { return nil } + let head = bytes[index] + index += 1 + let major = head >> 5 + let info = head & 0x1F + switch info { + case 0...23: + return (major, UInt64(info)) + case 24: + return readUInt(width: 1).map { (major, $0) } + case 25: + return readUInt(width: 2).map { (major, $0) } + case 26: + return readUInt(width: 4).map { (major, $0) } + case 27: + return readUInt(width: 8).map { (major, $0) } + default: // 28-30 reserved, 31 indefinite + return nil + } + } + + private mutating func readUInt(width: Int) -> UInt64? { + guard bytes.count - index >= width else { return nil } + var value: UInt64 = 0 + for _ in 0.. [UInt8]? { + guard count <= UInt64(bytes.count - index) else { return nil } + let length = Int(count) + let slice = Array(bytes[index..<(index + length)]) + index += length + return slice + } +} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index e9e81654..a68bf65b 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -22,6 +22,17 @@ struct CommandGeoParticipant { let displayName: String } +/// The conversation a command was typed into, captured when the command is +/// issued so deferred output (e.g. an async /ping result, which can arrive +/// many seconds later) lands there even if the user switches chats first. +enum CommandOutputDestination: Equatable { + /// The #mesh public timeline. Commands that defer output (/ping) are + /// mesh-only, so a non-DM origin is always the mesh timeline. + case meshTimeline + /// The private chat that was open when the command was typed. + case privateChat(PeerID) +} + /// Protocol defining what CommandProcessor needs from its context. /// This breaks the circular dependency between CommandProcessor and ChatViewModel. @MainActor @@ -45,14 +56,33 @@ protocol CommandContextProvider: AnyObject { /// Empties the peer's chat (single-writer store intent for `/clear`). func clearPrivateChat(_ peerID: PeerID) func sendPublicRaw(_ content: String) + /// Sends a normal public message (with local echo) to the active channel. + func sendPublicMessage(_ content: String) // MARK: - System Messages func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) func addPublicSystemMessage(_ content: String) + /// The conversation the user is typing into right now. Commands that + /// finish asynchronously capture this BEFORE starting async work, so a + /// chat switch cannot misroute their deferred output. + func currentCommandDestination() -> CommandOutputDestination + /// Routes deferred command output (e.g. an async /ping result) into the + /// conversation captured when the command was issued. + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) // MARK: - Favorites + /// Toggles the favorite via the unified peer flow, which persists by the + /// real noise key and notifies the peer over mesh or Nostr. func toggleFavorite(peerID: PeerID) - func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) + + // MARK: - Groups + // Group logic lives in `ChatGroupCoordinator`; these forward the parsed + // /group subcommands. + func groupCreate(named name: String) -> CommandResult + func groupInvite(nickname: String) -> CommandResult + func groupRemove(nickname: String) -> CommandResult + func groupLeave() -> CommandResult + func groupList() -> CommandResult } /// Processes chat commands in a focused, efficient way @@ -99,17 +129,84 @@ final class CommandProcessor { return handleBlock(args) case "/unblock": return handleUnblock(args) + case "/group": + if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") } + return handleGroup(args) case "/fav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: true) case "/unfav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) + case "/ping": + if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") } + return handlePing(args) + case "/trace": + if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") } + return handleTrace(args) + case "/pay": + return handlePay(args) + case "/drop": + return handleDrop(args) + case "/help": + return .success(message: Self.helpText) default: - return .error(message: "unknown command: \(cmd)") + return .error(message: "unknown command: \(cmd) — type /help for commands") } } + /// Local-only command reference, printed as a system message. The + /// suggestion panel hides once arguments are typed, and typos used to + /// dead-end in a bare "unknown command" — this is the way out. + static let helpText = """ + commands: + /msg @name [message] — start a private chat + /who — list who's here + /clear — clear this chat + /hug @name — send a hug + /slap @name — slap with a large trout + /block @name · /unblock @name + /fav @name · /unfav @name — favorites (mesh only) + /group create — start an encrypted group + /group invite @name · /group remove @name — manage members (creator) + /group leave · /group list — leave or list your groups + /ping @name — measure round-trip time (mesh only) + /trace @name — estimated mesh path (mesh only) + /pay — send a cashu ecash token in this chat + /drop — pin a note to this place for 24h (needs location) + /help — this list + """ + + /// /drop — a dead drop: pins a note to the current building-level + /// geohash with a 24h NIP-40 expiry. Anyone who passes through here and + /// looks at notices (or hits the empty-timeline "notes left here" hint) + /// reads it. + private func handleDrop(_ args: String) -> CommandResult { + guard LocationNotesSettings.enabled else { + return .error(message: "location notes are off — enable them in the info screen") + } + guard let content = args.trimmedOrNilIfEmpty else { + return .error(message: "usage: /drop ") + } + let location = LocationChannelManager.shared + guard location.permissionState == .authorized else { + return .error(message: "leaving a note needs location — enable it in the info screen") + } + guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else { + location.refreshChannels() + return .error(message: "still finding this place — try again in a moment") + } + guard let nickname = contextProvider?.nickname, + LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else { + return .error(message: "no geo relays reachable — note not left") + } + // Leaving a note is an explicit notes act: it unlocks the passive + // nearby-notes counter (tap-to-reveal) so the sender sees their own + // drop counted on the timeline. + NearbyNotesCounter.shared.reveal() + return .success(message: "📍 note left here — it fades in 24h") + } + // MARK: - Command Handlers private func handleMessage(_ args: String) -> CommandResult { @@ -272,6 +369,9 @@ final class CommandProcessor { ) identityManager.updateSocialIdentity(blockedIdentity) } + // Scrub their carried public messages now, while the peerID is + // resolvable, so they can't resurface as archived echoes. + meshService?.purgeArchivedPublicMessages(from: peerID) return .success(message: "blocked \(nickname). you will no longer receive messages from them") } // Mesh lookup failed; try geohash (Nostr) participant by display name @@ -313,39 +413,168 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + private static let groupUsage = "usage: /group create · invite @name · remove @name · leave · list" + + private func handleGroup(_ args: String) -> CommandResult { + let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let subcommand = parts.first else { + return .error(message: Self.groupUsage) + } + let rest = parts.count > 1 ? String(parts[1]) : "" + guard let provider = contextProvider else { return .handled } + + switch subcommand { + case "create": + return provider.groupCreate(named: rest) + case "invite": + return provider.groupInvite(nickname: rest) + case "remove": + return provider.groupRemove(nickname: rest) + case "leave": + return provider.groupLeave() + case "list": + return provider.groupList() + default: + return .error(message: Self.groupUsage) + } + } + + // MARK: - Mesh Diagnostics + + private enum MeshPeerResolution { + case resolved(peerID: PeerID, nickname: String) + case failed(CommandResult) + } + + /// Resolves a mesh peer for /ping and /trace. Geohash identities are + /// rejected — diagnostics measure the BLE mesh, not Nostr. + private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution { + let targetName = args.trimmed + guard !targetName.isEmpty else { + return .failed(.error(message: "usage: /\(command) ")) + } + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + guard let peerID = contextProvider?.getPeerIDForNickname(nickname), + !peerID.isGeoDM, !peerID.isGeoChat else { + return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh")) + } + return .resolved(peerID: peerID, nickname: nickname) + } + + private func handlePing(_ args: String) -> CommandResult { + let target: (peerID: PeerID, nickname: String) + switch resolveMeshPeer(args, command: "ping") { + case .resolved(let peerID, let nickname): target = (peerID, nickname) + case .failed(let result): return result + } + + let nickname = target.nickname + let currentProvider = contextProvider + // Capture the origin conversation now: the pong can arrive up to + // meshPingTimeoutSeconds later, and reading the selected chat at + // callback time would misroute the result after a chat switch. + let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline + meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in + let provider = currentProvider + guard let result else { + provider?.addCommandOutput("no reply from \(nickname)", to: destination) + return + } + let hopText: String = result.hops.map { hops in + hops == 1 ? " · direct (1 hop)" : " · \(hops) hops" + } ?? "" + provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination) + } + return .success(message: "pinging \(nickname)…") + } + + private func handleTrace(_ args: String) -> CommandResult { + let target: (peerID: PeerID, nickname: String) + switch resolveMeshPeer(args, command: "trace") { + case .resolved(let peerID, let nickname): target = (peerID, nickname) + case .failed(let result): return result + } + + guard let mesh = meshService, + let intermediates = mesh.computeMeshPath(to: target.peerID) else { + return .success(message: "no known path to \(target.nickname)") + } + // Graph-derived from gossiped neighbor claims, not route-recorded — + // present it as an estimate. + let hopNames = intermediates.map { hop in + mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…" + } + let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ") + let hops = intermediates.count + 1 + return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))") + } + + /// `/pay ` — validates the token decodes, then sends it as + /// the message body in the current chat. Cashu tokens are bearer + /// instruments (whoever redeems first gets the funds), so posting one to + /// a public channel requires an explicit `/pay public` confirm. + /// The app never contacts a mint; it only relays the string. + private func handlePay(_ args: String) -> CommandResult { + var parts = args.trimmed.split(separator: " ").map(String.init) + guard !parts.isEmpty else { + return .success(message: "usage: /pay — paste a cashu token: /pay cashuA…") + } + + let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public" + if confirmedPublic { parts.removeLast() } + + guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else { + return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…") + } + guard let info = CashuTokenDecoder.decode(token, strict: true) else { + return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it") + } + + let summary = info.displayAmount ?? "a cashu token" + + if let peerID = contextProvider?.selectedPrivateChatPeer { + contextProvider?.sendPrivateMessage(token, to: peerID) + return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds") + } + + guard confirmedPublic else { + return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public") + } + + contextProvider?.sendPublicMessage(token) + return .success(message: "sent \(summary) to the public channel — anyone here can redeem it") + } + private func handleFavorite(_ args: String, add: Bool) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { return .error(message: "usage: /\(add ? "fav" : "unfav") ") } - + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - guard let peerID = contextProvider?.getPeerIDForNickname(nickname), - let noisePublicKey = Data(hexString: peerID.id) else { + + guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else { return .error(message: "can't find peer: \(nickname)") } - - if add { - let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: noisePublicKey, - peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, - peerNickname: nickname - ) - - contextProvider?.toggleFavorite(peerID: peerID) - contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true) - - return .success(message: "added \(nickname) to favorites") + + // Resolve current state by the peer's real noise key. The resolved + // peerID is either the short 16-hex mesh ID or the full 64-hex + // noise-key ID (offline favorite row) — never the noise key itself. + let isCurrentlyFavorite: Bool + if let noiseKey = peerID.noiseKey { + isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey) } else { - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) - - contextProvider?.toggleFavorite(peerID: peerID) - contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false) - - return .success(message: "removed \(nickname) from favorites") + isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false } + + guard add != isCurrentlyFavorite else { + return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite") + } + + // toggleFavorite persists by the real noise key and notifies the peer. + contextProvider?.toggleFavorite(peerID: peerID) + + return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites") } } diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift new file mode 100644 index 00000000..77c7d100 --- /dev/null +++ b/bitchat/Services/Courier/CourierStore.swift @@ -0,0 +1,629 @@ +// +// CourierStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation +#if os(iOS) +import UIKit +#endif + +/// Trust level of a courier deposit, decided by the caller's policy. +/// Favorites get the larger quota and are never evicted to make room for +/// verified-tier mail; verified (signature-verified announce, not a mutual +/// favorite) get a small quota so a crowd of strangers can still carry mail. +enum CourierDepositTier: String, Codable { + case favorite + case verified +} + +/// Holds courier envelopes this device is carrying for offline third parties. +/// +/// Envelopes are opaque ciphertext; this store never learns sender, +/// recipient, or content. Strict quotas keep the device from becoming a +/// public mailbag: bounded count, bounded per-depositor count by trust tier, +/// bounded size, and a 24-hour lifetime aligned with the outbox retention +/// policy. Carried mail is included in the panic wipe. +final class CourierStore { + struct StoredEnvelope: Codable, Equatable { + let recipientTag: Data + let expiry: UInt64 + let ciphertext: Data + let depositorNoiseKey: Data + let storedAt: Date + var tier: CourierDepositTier + /// Remaining spray-and-wait budget (1 = carry-only). + var copies: UInt8 + /// Couriers this envelope was already sprayed to, so a repeat announce + /// from the same peer doesn't burn budget on a copy they already hold. + var sprayedTo: Set + /// Last speculative multi-hop handover toward a relayed announce. + var lastRemoteHandoverAt: Date? + /// Last publish of this envelope as a bridge courier drop on relays. + var lastBridgePublishAt: Date? + /// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1. + let prekeyID: UInt32? + + var envelope: CourierEnvelope { + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) + } + + init( + recipientTag: Data, + expiry: UInt64, + ciphertext: Data, + depositorNoiseKey: Data, + storedAt: Date, + tier: CourierDepositTier, + copies: UInt8, + sprayedTo: Set = [], + lastRemoteHandoverAt: Date? = nil, + lastBridgePublishAt: Date? = nil, + prekeyID: UInt32? = nil + ) { + self.recipientTag = recipientTag + self.expiry = expiry + self.ciphertext = ciphertext + self.depositorNoiseKey = depositorNoiseKey + self.storedAt = storedAt + self.tier = tier + self.copies = copies + self.sprayedTo = sprayedTo + self.lastRemoteHandoverAt = lastRemoteHandoverAt + self.lastBridgePublishAt = lastBridgePublishAt + self.prekeyID = prekeyID + } + + // Files written before tiers/spray lack the newer fields; treat that + // mail as favorite-tier carry-only, which is what it was. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + recipientTag = try container.decode(Data.self, forKey: .recipientTag) + expiry = try container.decode(UInt64.self, forKey: .expiry) + ciphertext = try container.decode(Data.self, forKey: .ciphertext) + depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey) + storedAt = try container.decode(Date.self, forKey: .storedAt) + tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite + copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1 + sprayedTo = try container.decodeIfPresent(Set.self, forKey: .sprayedTo) ?? [] + lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt) + lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt) + prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID) + } + } + + enum Limits { + static let maxEnvelopes = 40 + /// Verified-tier mail can never crowd out favorites' share. + static let maxVerifiedEnvelopes = 20 + static let maxPerFavoriteDepositor = 5 + static let maxPerVerifiedDepositor = 2 + /// Slack on top of the 24h lifetime for depositor clock skew. + static let maxExpirySlack: TimeInterval = 60 * 60 + } + + static let shared = CourierStore() + + /// Number of envelopes currently carried, published on the main thread + /// so the UI can show a "carrying mail" indicator. + @Published private(set) var carriedCount: Int = 0 + + /// Fast path so hot code (announce handling) can skip tag computation. + var isEmpty: Bool { + queue.sync { envelopes.isEmpty } + } + + private var envelopes: [StoredEnvelope] = [] + private let queue = DispatchQueue(label: "chat.bitchat.courier.store") + private let fileURL: URL? + private let now: () -> Date + private let readData: (URL) throws -> Data + /// A protected file can be present but unreadable during an iOS + /// background restoration before first unlock. Keep that distinct from + /// an absent file: mutations may proceed in memory, but must not replace + /// the unreadable durable snapshot until it can be merged. + private var diskLoadDeferred = false + #if os(iOS) + private var protectedDataObserver: NSObjectProtocol? + #endif + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init( + persistsToDisk: Bool = true, + fileURL: URL? = nil, + now: @escaping () -> Date = Date.init, + readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) } + ) { + self.now = now + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + self.readData = readData + loadFromDisk() + #if os(iOS) + protectedDataObserver = NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.retryDeferredPersistence() + } + #endif + } + + deinit { + #if os(iOS) + if let protectedDataObserver { + NotificationCenter.default.removeObserver(protectedDataObserver) + } + #endif + } + + // MARK: - Depositing (courier side) + + /// Accept an envelope from a depositor. Returns false when quotas or + /// validity checks reject it. Trust policy (which tier a depositor gets, + /// if any) is the caller's responsibility; this store only enforces + /// resource bounds. + @discardableResult + func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool { + let date = now() + guard envelope.recipientTag.count == CourierEnvelope.tagLength, + !envelope.ciphertext.isEmpty, + envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes, + !envelope.isExpired(at: date) else { + return false + } + // Reject expiries beyond the policy lifetime so depositors can't pin + // storage longer than the outbox would retain the message itself. + let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack) + guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else { + return false + } + + return queue.sync { + pruneExpiredLocked(at: date) + + // Identical ciphertext is the same envelope. Before any spray, + // a carry-only copy may legitimately arrive ahead of the original + // higher-budget copy, so keep the larger initial budget. Once a + // branch has sprayed, however, replaying the depositor's original + // packet must never replenish spent copies: that would defeat + // spray-and-wait and let `sprayedTo` grow without bound. + if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) { + if envelopes[existing].sprayedTo.isEmpty { + envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies) + } + persistLocked() + return true + } + + let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor + guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else { + SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session) + return false + } + if tier == .verified, + envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes { + SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session) + return false + } + if envelopes.count >= Limits.maxEnvelopes { + // Oldest-first eviction, shedding verified-tier mail before + // favorites' so open couriering can't crowd out trusted mail. + // A verified deposit never displaces a favorite: when only + // favorite mail is stored, it is rejected instead. + if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) { + let evicted = envelopes.remove(at: victim) + SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session) + } else if tier == .favorite { + let evicted = envelopes.removeFirst() + SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session) + } else { + SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session) + return false + } + } + + envelopes.append(StoredEnvelope( + recipientTag: envelope.recipientTag, + expiry: envelope.expiry, + ciphertext: envelope.ciphertext, + depositorNoiseKey: depositorNoiseKey, + storedAt: date, + tier: tier, + copies: envelope.copies, + prekeyID: envelope.prekeyID + )) + persistLocked() + return true + } + } + + // MARK: - Handover (on encountering a peer) + + /// Remove and return all envelopes addressed to the given peer, matching + /// the rotating recipient tag across adjacent days. This compatibility + /// helper accepts every offer; transport callers should use + /// `handoverEnvelopes(for:accepting:)` so failed sends remain durable. + func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] { + var handedOver: [CourierEnvelope] = [] + handoverEnvelopes(for: noiseStaticKey) { envelope in + handedOver.append(envelope) + return true + } + return handedOver + } + + /// Attempts direct handover without retiring the durable carried copy + /// until the transport accepts it onto the intended peer's physical link. + /// A failed encode, stale binding, or backpressure rejection leaves the + /// envelope unchanged for the next authenticated encounter. + @discardableResult + func handoverEnvelopes( + for noiseStaticKey: Data, + accepting: (CourierEnvelope) -> Bool + ) -> Int { + let date = now() + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date) + let offered = queue.sync { + pruneExpiredLocked(at: date) + return envelopes + .filter { candidates.contains($0.recipientTag) } + .map(\.envelope) + } + + var acceptedCount = 0 + for envelope in offered where accepting(envelope) { + // Do not hold the store queue while the acceptance closure enters + // BLE/collections queues. Commit in a second short critical + // section, rechecking that another handover did not win first. + let committed = queue.sync { + guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else { + return false + } + envelopes.remove(at: index) + persistLocked() + return true + } + if committed { acceptedCount += 1 } + } + return acceptedCount + } + + /// Envelopes addressed to a recipient we heard from via a *relayed* + /// announce. Non-destructive: a multi-hop send is speculative, so the + /// envelope stays carried until a direct handover or expiry. The per- + /// envelope cooldown keeps repeated announces from re-flooding the mesh. + func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] { + let date = now() + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date) + return queue.sync { + pruneExpiredLocked(at: date) + var matched: [CourierEnvelope] = [] + for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) { + if let last = envelopes[index].lastRemoteHandoverAt, + date.timeIntervalSince(last) < cooldown { + continue + } + envelopes[index].lastRemoteHandoverAt = date + // The delivered copy carries no spray budget. + matched.append(envelopes[index].envelope.withCopies(1)) + } + if !matched.isEmpty { persistLocked() } + return matched + } + } + + /// Envelopes eligible to park on relays as bridge courier drops. Merely + /// offering one does not start its cooldown: the caller commits that only + /// after a relay explicitly accepts the event via NIP-20 OK. + func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] { + let date = now() + return queue.sync { + pruneExpiredLocked(at: date) + return envelopes.compactMap { stored in + if let last = stored.lastBridgePublishAt, + date.timeIntervalSince(last) < cooldown { + return nil + } + // The relay copy carries no spray budget. + return stored.envelope.withCopies(1) + } + } + } + + /// Starts the bridge-publish cooldown only for a relay-confirmed copy. + func markBridgePublished(_ envelope: CourierEnvelope) { + let date = now() + queue.sync { + guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else { + return + } + envelopes[index].lastBridgePublishAt = date + persistLocked() + } + } + + // MARK: - Spray-and-wait (on encountering another courier) + + /// Envelopes to re-deposit with a courier we just encountered, each with + /// half its remaining budget (binary spray). Skips envelopes the courier + /// deposited, envelopes addressed to them (those ride the handover path), + /// carry-only envelopes, and couriers already sprayed. + func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] { + var sprayed: [CourierEnvelope] = [] + transferSprayCopies(to: courierNoiseKey) { envelope in + sprayed.append(envelope) + return true + } + return sprayed + } + + /// Offers binary-spray copies one at a time and commits the reduced local + /// budget plus `sprayedTo` marker only after the directed transport accepts + /// that copy. A false result is a rollback: retrying the same courier sees + /// the original budget and eligibility. + @discardableResult + func transferSprayCopies( + to courierNoiseKey: Data, + accepting: (CourierEnvelope) -> Bool + ) -> Int { + let date = now() + let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date) + let offered = queue.sync { + pruneExpiredLocked(at: date) + return envelopes.compactMap { stored -> CourierEnvelope? in + guard stored.copies > 1, + stored.depositorNoiseKey != courierNoiseKey, + !stored.sprayedTo.contains(courierNoiseKey), + !courierTags.contains(stored.recipientTag) else { return nil } + return stored.envelope.withCopies(stored.copies / 2) + } + } + + var acceptedCount = 0 + for copy in offered where accepting(copy) { + // As with direct handover, BLE acceptance runs outside the store + // queue. Revalidate and commit the exact budget that left this + // device; a competing successful transfer makes this a no-op. + let committed = queue.sync { + guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else { + return false + } + let stored = envelopes[index] + guard stored.copies > copy.copies, + stored.depositorNoiseKey != courierNoiseKey, + !stored.sprayedTo.contains(courierNoiseKey), + !courierTags.contains(stored.recipientTag) else { + return false + } + envelopes[index].copies = stored.copies - copy.copies + envelopes[index].sprayedTo.insert(courierNoiseKey) + persistLocked() + return true + } + if committed { acceptedCount += 1 } + } + return acceptedCount + } + + // MARK: - Maintenance + + /// Panic wipe: drop all carried mail from memory and disk. + func wipe() { + queue.sync { + envelopes.removeAll() + diskLoadDeferred = false + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + publishCountLocked() + } + } + + /// Retries a protected-data read and merges any envelopes accepted while + /// the file was unavailable. Internal so persistence tests can drive the + /// same transition as iOS's protected-data notification. + func retryDeferredPersistence() { + queue.sync { + guard diskLoadDeferred, resolveDeferredLoadLocked() else { return } + persistLocked() + } + } + + // MARK: - Internals (call only on `queue`) + + private func pruneExpiredLocked(at date: Date) { + let before = envelopes.count + envelopes.removeAll { $0.envelope.isExpired(at: date) } + if envelopes.count != before { + SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session) + } + } + + private func publishCountLocked() { + let count = envelopes.count + DispatchQueue.main.async { [weak self] in + self?.carriedCount = count + } + } + + private func persistLocked() { + publishCountLocked() + guard let fileURL else { return } + // Never turn a transient protected-data read failure into an + // authoritative empty/new file. Once readable, resolve first by + // merging the durable and in-memory snapshots. + if diskLoadDeferred, !resolveDeferredLoadLocked() { + return + } + do { + if envelopes.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(envelopes) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist courier store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL else { return } + queue.sync { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + diskLoadDeferred = false + return + } + do { + let data = try readData(fileURL) + do { + envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data) + diskLoadDeferred = false + pruneExpiredLocked(at: now()) + publishCountLocked() + Self.migrateFileProtectionIfNeeded(at: fileURL) + } catch { + // The bytes were readable, so this is corruption/schema + // failure rather than protected-data unavailability. + diskLoadDeferred = false + SecureLogger.error("Failed to decode courier store: \(error)", category: .session) + } + } catch { + diskLoadDeferred = true + SecureLogger.warning("Courier store unavailable; deferring load until protected data is available: \(error)", category: .session) + } + } + } + + /// Must be called on `queue`. + private func resolveDeferredLoadLocked() -> Bool { + guard diskLoadDeferred, let fileURL else { return true } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + diskLoadDeferred = false + return true + } + do { + let data = try readData(fileURL) + let durable = try JSONDecoder().decode([StoredEnvelope].self, from: data) + envelopes = Self.merge(durable: durable, inMemory: envelopes) + diskLoadDeferred = false + pruneExpiredLocked(at: now()) + publishCountLocked() + Self.migrateFileProtectionIfNeeded(at: fileURL) + return true + } catch let error as DecodingError { + // Readability returned but the snapshot is corrupt. Do not pin + // persistence forever; retain the valid in-memory snapshot. + diskLoadDeferred = false + SecureLogger.error("Failed to decode deferred courier store: \(error)", category: .session) + return true + } catch { + SecureLogger.warning("Courier store still unavailable: \(error)", category: .session) + return false + } + } + + private static func merge(durable: [StoredEnvelope], inMemory: [StoredEnvelope]) -> [StoredEnvelope] { + var merged = durable + for candidate in inMemory { + if let index = merged.firstIndex(where: { $0.ciphertext == candidate.ciphertext }) { + // Union progress before deciding the budget. Once either copy + // has sprayed, the lower remaining budget is authoritative; + // an older durable/original snapshot must not replenish it. + let durableHasProgress = !merged[index].sprayedTo.isEmpty + let memoryHasProgress = !candidate.sprayedTo.isEmpty + let combinedSprayedTo = merged[index].sprayedTo.union(candidate.sprayedTo) + switch (durableHasProgress, memoryHasProgress) { + case (false, false): + merged[index].copies = max(merged[index].copies, candidate.copies) + case (true, false): + break // durable progress owns its remaining budget + case (false, true): + merged[index].copies = candidate.copies + case (true, true): + // Concurrent progress can only spend budget; choosing the + // lower branch prevents a merge from minting copies. + merged[index].copies = min(merged[index].copies, candidate.copies) + } + merged[index].sprayedTo = combinedSprayedTo + if candidate.tier == .favorite { merged[index].tier = .favorite } + merged[index].lastRemoteHandoverAt = [merged[index].lastRemoteHandoverAt, candidate.lastRemoteHandoverAt] + .compactMap { $0 } + .max() + merged[index].lastBridgePublishAt = [merged[index].lastBridgePublishAt, candidate.lastBridgePublishAt] + .compactMap { $0 } + .max() + } else { + merged.append(candidate) + } + } + + // A long locked wake can accept new mail alongside a full durable + // store. Re-apply deposit quotas: existing per-depositor mail keeps + // its slot, while total-cap eviction sheds oldest verified mail first. + merged.sort { $0.storedAt < $1.storedAt } + var perDepositorCounts: [Data: Int] = [:] + merged = merged.filter { envelope in + let limit = envelope.tier == .favorite + ? Limits.maxPerFavoriteDepositor + : Limits.maxPerVerifiedDepositor + let count = perDepositorCounts[envelope.depositorNoiseKey, default: 0] + guard count < limit else { return false } + perDepositorCounts[envelope.depositorNoiseKey] = count + 1 + return true + } + while merged.filter({ $0.tier == .verified }).count > Limits.maxVerifiedEnvelopes { + guard let victim = merged.firstIndex(where: { $0.tier == .verified }) else { break } + merged.remove(at: victim) + } + while merged.count > Limits.maxEnvelopes { + if let victim = merged.firstIndex(where: { $0.tier == .verified }) { + merged.remove(at: victim) + } else { + merged.removeFirst() + } + } + return merged + } + + private static func migrateFileProtectionIfNeeded(at fileURL: URL) { + #if os(iOS) + do { + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + } catch { + SecureLogger.warning("Failed to migrate courier store file protection: \(error)", category: .session) + } + #endif + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("courier", isDirectory: true) + .appendingPathComponent("envelopes.json") + } +} diff --git a/bitchat/Services/Courier/MessageOutboxStore.swift b/bitchat/Services/Courier/MessageOutboxStore.swift new file mode 100644 index 00000000..5543a8d0 --- /dev/null +++ b/bitchat/Services/Courier/MessageOutboxStore.swift @@ -0,0 +1,846 @@ +// +// MessageOutboxStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CryptoKit +import Foundation +import Security +#if os(iOS) +import UIKit +#endif + +/// Disk persistence for the MessageRouter outbox, so private messages queued +/// for an offline peer survive an app kill instead of silently evaporating. +/// +/// Nothing else in the app persists message plaintext, and this store keeps +/// that property: the outbox is sealed with a ChaChaPoly key that lives only +/// in the Keychain (after-first-unlock, this device only), on top of iOS file +/// protection. Wiped on panic alongside the courier store. +final class MessageOutboxStore { + struct QueuedMessage: Codable, Equatable { + let content: String + let nickname: String + let messageID: String + let timestamp: Date + var sendAttempts: Int + /// Noise keys of couriers already carrying this message, so deposit + /// retries add couriers instead of re-burning the same ones. + var depositedCourierKeys: Set + + init( + content: String, + nickname: String, + messageID: String, + timestamp: Date, + sendAttempts: Int = 0, + depositedCourierKeys: Set = [] + ) { + self.content = content + self.nickname = nickname + self.messageID = messageID + self.timestamp = timestamp + self.sendAttempts = sendAttempts + self.depositedCourierKeys = depositedCourierKeys + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decode(String.self, forKey: .content) + nickname = try container.decode(String.self, forKey: .nickname) + messageID = try container.decode(String.self, forKey: .messageID) + timestamp = try container.decode(Date.self, forKey: .timestamp) + sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0 + depositedCourierKeys = try container.decodeIfPresent(Set.self, forKey: .depositedCourierKeys) ?? [] + } + } + + private static let keychainService = "chat.bitchat.outbox" + private static let keychainKey = "outbox-encryption-key" + + typealias Snapshot = [PeerID: [QueuedMessage]] + + private enum DiskState { + case unknown + case loaded + case deferred + } + + private enum DiskReadResult { + case missing + case loaded(Snapshot) + case deferred(Error?) + case corrupt(Error) + } + + private enum EncryptionKeyReadResult { + case available(SymmetricKey) + case missing + case invalid + case unavailable(Error?) + } + + private struct RecoveredSnapshot { + let snapshot: Snapshot + let generation: UInt64 + let unseenDurable: Snapshot + } + + private let fileURL: URL? + private let keychain: KeychainManagerProtocol + private let readData: (URL) throws -> Data + private let writeData: (Data, URL, Data.WritingOptions) throws -> Void + private let beforeRecoveryNotification: () -> Void + private let lock = NSLock() + private var diskState: DiskState = .unknown + private var cachedSnapshot: Snapshot = [:] + /// Mutations made after a protected-data load failed. They are merged + /// with the durable snapshot once it becomes readable, never written on + /// top of an unreadable file. + private var pendingSnapshot: Snapshot? + /// True after a write failed after the durable baseline was already + /// loaded. That full-router snapshot includes removals and must replace, + /// rather than union with, the older disk contents on retry. + private var pendingSnapshotIsAuthoritative = false + /// Delivery/read acknowledgments received before a deferred cold-load + /// reveals the durable queue. Applied to every merge before persistence. + private var pendingRemovalMessageIDs = Set() + private var recoveryHandler: (@MainActor (Snapshot) -> Void)? + /// Recovery loaded durable state that MessageRouter has not merged yet. + /// While true, router saves must union with `cachedSnapshot` instead of + /// replacing unseen durable messages. + private var recoveryDeliveryPending = false + /// Recovery read durable state and classified the unseen subset, but the + /// merged snapshot could not yet be persisted. Preserve that classification + /// across retries instead of treating the cached union as router-known. + private var unseenRecoveryPendingPersistence = false + /// MessageRouter's latest authoritative in-memory snapshot while a + /// recovery classification is awaiting persistence or delivery. This is + /// deliberately separate from `pendingSnapshot`, which may contain the + /// union of router-known and unseen durable work after a failed write. + private var recoveryRouterSnapshot: Snapshot = [:] + /// The durable messages absent from MessageRouter's locked-wake snapshot + /// when recovery completed. Only this subset is unioned into authoritative + /// router saves before the recovery callback is claimed. + private var unseenRecoveredSnapshot: Snapshot = [:] + /// Covers the narrow launch race where protected data becomes available + /// after `load()` returned but before MessageRouter installs its handler. + private var unreportedRecoveredSnapshot: RecoveredSnapshot? + /// Invalidates recovery callbacks already queued onto the main actor when + /// panic wipe begins. + private var lifecycleGeneration: UInt64 = 0 + #if os(iOS) + private var protectedDataObserver: NSObjectProtocol? + #endif + + init( + keychain: KeychainManagerProtocol, + fileURL: URL? = nil, + readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }, + writeData: @escaping (Data, URL, Data.WritingOptions) throws -> Void = { + try $0.write(to: $1, options: $2) + }, + beforeRecoveryNotification: @escaping () -> Void = {} + ) { + self.keychain = keychain + self.fileURL = fileURL ?? Self.defaultFileURL() + self.readData = readData + self.writeData = writeData + self.beforeRecoveryNotification = beforeRecoveryNotification + #if os(iOS) + protectedDataObserver = NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.retryDeferredLoad() + } + #endif + } + + deinit { + #if os(iOS) + if let protectedDataObserver { + NotificationCenter.default.removeObserver(protectedDataObserver) + } + #endif + } + + // MARK: - API (call from the router's actor; IO is small and atomic) + + func load() -> Snapshot { + lock.lock() + defer { lock.unlock() } + + if case .loaded = diskState { + return cachedSnapshot + } + // Once a deferred recovery has classified the durable baseline, + // `cachedSnapshot` is the only safe synchronous view. Re-reading via + // the generic load path would confuse its durable+router union with a + // fully router-known authoritative snapshot. + if unseenRecoveryPendingPersistence || recoveryDeliveryPending { + return cachedSnapshot + } + + let wasDeferred = diskState == .deferred + switch readSnapshotLocked() { + case .loaded(let durable): + cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshotIsAuthoritative + ? (pendingSnapshot ?? [:]) + : Self.merge(durable, pendingSnapshot ?? [:])) + diskState = .loaded + if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + } else { + pendingSnapshot = cachedSnapshot + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + } + // The recovery callback is driven by `retryDeferredLoad`; `load` + // itself returns the recovered value synchronously to its caller. + return cachedSnapshot + + case .missing: + cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) + diskState = .loaded + if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + } else { + pendingSnapshot = cachedSnapshot + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + } + return cachedSnapshot + + case .deferred(let error): + diskState = .deferred + if !wasDeferred { + SecureLogger.warning("Outbox unavailable; deferring load until protected data is available: \(String(describing: error))", category: .session) + } + return pendingSnapshot ?? [:] + + case .corrupt(let error): + diskState = .loaded + cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) + SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session) + if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + } else { + pendingSnapshot = cachedSnapshot + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + } + return cachedSnapshot + } + } + + func save(_ outbox: Snapshot) { + var recovered: RecoveredSnapshot? + + lock.lock() + let recoveryWasPending = recoveryDeliveryPending + let unseenClassificationWasPending = unseenRecoveryPendingPersistence + let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending + let flattened = applyingPendingRemovalsLocked(outbox.filter { !$0.value.isEmpty }) + if recoveryStateWasPending { + // `save` is the router's complete current view. Keep it separate + // from the durable union retained for disk retry. + recoveryRouterSnapshot = flattened + } + switch diskState { + case .loaded: + cachedSnapshot = applyingPendingRemovalsLocked( + recoveryStateWasPending + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : flattened + ) + if !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { + // Retain the latest complete router snapshot. Because its + // durable baseline was already loaded, it replaces the older + // disk file after protected data returns (preserving removals). + pendingSnapshot = cachedSnapshot + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + + case .unknown, .deferred: + let wasDeferred = diskState == .deferred + // A non-authoritative deferred snapshot means the initial cold + // load never completed. An authoritative deferred snapshot with + // no recovery state is merely an ordinary post-load write retry. + let isRecoveryAttempt = recoveryStateWasPending || + (wasDeferred && !pendingSnapshotIsAuthoritative) + switch readSnapshotLocked() { + case .loaded(let durable): + // `save` before a successful `load` is not authoritative over + // an unreadable snapshot. Union by message ID so neither the + // durable queue nor work accepted during the locked wake is + // lost. + let newlyUnseen = recoveryStateWasPending + ? unseenRecoveredSnapshot + : (isRecoveryAttempt + ? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: flattened)) + : [:]) + if isRecoveryAttempt && !recoveryStateWasPending { + unseenRecoveredSnapshot = newlyUnseen + recoveryRouterSnapshot = flattened + unseenRecoveryPendingPersistence = true + } + let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence + let merged = preservesUnseenRecovery + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : (pendingSnapshotIsAuthoritative ? flattened : Self.merge(durable, flattened)) + cachedSnapshot = applyingPendingRemovalsLocked(merged) + diskState = .loaded + if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + if isRecoveryAttempt && !recoveryWasPending { + recovered = RecoveredSnapshot( + snapshot: cachedSnapshot, + generation: lifecycleGeneration, + unseenDurable: newlyUnseen + ) + } + } else { + pendingSnapshot = cachedSnapshot + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + + case .missing: + if isRecoveryAttempt && !recoveryStateWasPending { + unseenRecoveredSnapshot = [:] + recoveryRouterSnapshot = flattened + unseenRecoveryPendingPersistence = true + } + let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence + let merged = applyingPendingRemovalsLocked( + preservesUnseenRecovery + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : flattened + ) + cachedSnapshot = merged + diskState = .loaded + if persistSnapshotAndClearRemovalsLocked(merged) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + if isRecoveryAttempt && !recoveryWasPending { + recovered = RecoveredSnapshot( + snapshot: merged, + generation: lifecycleGeneration, + unseenDurable: unseenRecoveredSnapshot + ) + } + } else { + pendingSnapshot = merged + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + + case .deferred: + // `save` receives MessageRouter's complete current in-memory + // snapshot. Replace prior locked-wake state so delivery acks + // and expiry removals become tombstones for that state; only + // the still-unknown durable snapshot is unioned on recovery. + pendingSnapshot = applyingPendingRemovalsLocked( + recoveryStateWasPending + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : flattened + ) + diskState = .deferred + + case .corrupt(let error): + SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session) + if isRecoveryAttempt && !recoveryStateWasPending { + unseenRecoveredSnapshot = [:] + recoveryRouterSnapshot = flattened + unseenRecoveryPendingPersistence = true + } + let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence + let merged = applyingPendingRemovalsLocked( + preservesUnseenRecovery + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : flattened + ) + cachedSnapshot = merged + diskState = .loaded + if persistSnapshotAndClearRemovalsLocked(merged) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + if isRecoveryAttempt && !recoveryWasPending { + recovered = RecoveredSnapshot( + snapshot: merged, + generation: lifecycleGeneration, + unseenDurable: unseenRecoveredSnapshot + ) + } + } else { + pendingSnapshot = merged + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + } + } + if let recovered { + unseenRecoveryPendingPersistence = false + recoveryDeliveryPending = true + unseenRecoveredSnapshot = recovered.unseenDurable + } + lock.unlock() + + if let recovered { + beforeRecoveryNotification() + notifyRecovered(recovered.snapshot, generation: recovered.generation) + } + } + + /// Installs the router-side merge hook used when a cold, locked launch + /// initially received an empty snapshot and protected data later becomes + /// readable. + func setRecoveryHandler(_ handler: @escaping @MainActor (Snapshot) -> Void) { + lock.lock() + recoveryHandler = handler + let unreported = unreportedRecoveredSnapshot + unreportedRecoveredSnapshot = nil + lock.unlock() + if let unreported { + Task { @MainActor [weak self] in + guard let latest = self?.claimPendingRecovery(generation: unreported.generation) else { return } + handler(latest) + } + } + } + + /// Records an ack even when a locked cold-load has not revealed the + /// matching durable message yet. The next `save`/recovery applies this + /// tombstone before writing or notifying MessageRouter. + func recordRemoval(messageID: String) { + lock.lock() + pendingRemovalMessageIDs.insert(messageID) + cachedSnapshot = Self.removing([messageID], from: cachedSnapshot) + unseenRecoveredSnapshot = Self.removing([messageID], from: unseenRecoveredSnapshot) + recoveryRouterSnapshot = Self.removing([messageID], from: recoveryRouterSnapshot) + if let pendingSnapshot { + self.pendingSnapshot = Self.removing([messageID], from: pendingSnapshot) + } + lock.unlock() + } + + /// Retries a deferred protected-data load. The returned snapshot includes + /// both durable messages and any messages queued during the locked wake. + @discardableResult + func retryDeferredLoad() -> Snapshot? { + var recovered: RecoveredSnapshot? + lock.lock() + guard diskState == .deferred else { + lock.unlock() + return nil + } + let recoveryWasPending = recoveryDeliveryPending + let unseenClassificationWasPending = unseenRecoveryPendingPersistence + let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending + // `pendingSnapshotIsAuthoritative` alone means a normal write failed + // after the router had already loaded its baseline. It must retry, but + // must not masquerade as cold-load recovery or schedule a callback. + let isRecoveryAttempt = recoveryStateWasPending || !pendingSnapshotIsAuthoritative + switch readSnapshotLocked() { + case .loaded(let durable): + let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:]) + let newlyUnseen = recoveryStateWasPending + ? unseenRecoveredSnapshot + : (isRecoveryAttempt + ? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: known)) + : [:]) + if isRecoveryAttempt && !recoveryStateWasPending { + unseenRecoveredSnapshot = newlyUnseen + recoveryRouterSnapshot = known + unseenRecoveryPendingPersistence = true + } + let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence + let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known))) + cachedSnapshot = merged + diskState = .loaded + if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + persistSnapshotAndClearRemovalsLocked(merged) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + if isRecoveryAttempt && !recoveryWasPending { + recovered = RecoveredSnapshot( + snapshot: merged, + generation: lifecycleGeneration, + unseenDurable: newlyUnseen + ) + } + } else { + pendingSnapshot = merged + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + case .missing: + let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:]) + if isRecoveryAttempt && !recoveryStateWasPending { + unseenRecoveredSnapshot = [:] + recoveryRouterSnapshot = known + unseenRecoveryPendingPersistence = true + } + let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence + let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : known) + cachedSnapshot = merged + diskState = .loaded + if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + persistSnapshotAndClearRemovalsLocked(merged) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + if isRecoveryAttempt && !recoveryWasPending { + recovered = RecoveredSnapshot( + snapshot: merged, + generation: lifecycleGeneration, + unseenDurable: unseenRecoveredSnapshot + ) + } + } else { + pendingSnapshot = merged + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + case .deferred: + break + case .corrupt(let error): + SecureLogger.error("Failed to decode encrypted outbox after protected-data recovery: \(error)", category: .session) + let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:]) + if isRecoveryAttempt && !recoveryStateWasPending { + unseenRecoveredSnapshot = [:] + recoveryRouterSnapshot = known + unseenRecoveryPendingPersistence = true + } + let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence + let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : known) + cachedSnapshot = merged + diskState = .loaded + if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + persistSnapshotAndClearRemovalsLocked(merged) { + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + if isRecoveryAttempt && !recoveryWasPending { + recovered = RecoveredSnapshot( + snapshot: merged, + generation: lifecycleGeneration, + unseenDurable: unseenRecoveredSnapshot + ) + } + } else { + pendingSnapshot = merged + pendingSnapshotIsAuthoritative = true + diskState = .deferred + } + } + if let recovered { + unseenRecoveryPendingPersistence = false + recoveryDeliveryPending = true + unseenRecoveredSnapshot = recovered.unseenDurable + } + lock.unlock() + + if let recovered { + beforeRecoveryNotification() + notifyRecovered(recovered.snapshot, generation: recovered.generation) + } + return recovered?.snapshot + } + + /// Panic wipe: drop the queued mail and the key that could ever read it. + func wipe() { + lock.lock() + diskState = .loaded + cachedSnapshot = [:] + pendingSnapshot = nil + pendingSnapshotIsAuthoritative = false + pendingRemovalMessageIDs.removeAll() + recoveryDeliveryPending = false + unseenRecoveryPendingPersistence = false + unseenRecoveredSnapshot = [:] + recoveryRouterSnapshot = [:] + unreportedRecoveredSnapshot = nil + lifecycleGeneration &+= 1 + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + keychain.delete(key: Self.keychainKey, service: Self.keychainService) + lock.unlock() + } + + // MARK: - Internals + + private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? { + switch readEncryptionKey() { + case .available(let key): + return key + case .missing: + break + case .invalid: + guard createIfMissing else { return nil } + keychain.delete(key: Self.keychainKey, service: Self.keychainService) + case .unavailable: + return nil + } + guard createIfMissing else { return nil } + + let key = SymmetricKey(size: .bits256) + let data = key.withUnsafeBytes { Data($0) } + // After-first-unlock so queued mail can flush from background BLE wakes. + keychain.save( + key: Self.keychainKey, + data: data, + service: Self.keychainService, + accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) + // The protocol's generic save predates result-bearing writes. Verify + // the item before sealing a file: otherwise a locked/full Keychain + // could drop the key while we successfully write unrecoverable mail. + guard case .success(let stored) = keychain.loadWithResult( + key: Self.keychainKey, + service: Self.keychainService + ), stored == data else { + keychain.delete(key: Self.keychainKey, service: Self.keychainService) + SecureLogger.error("Outbox encryption key was not retained by Keychain", category: .session) + return nil + } + return key + } + + private func readEncryptionKey() -> EncryptionKeyReadResult { + switch keychain.loadWithResult(key: Self.keychainKey, service: Self.keychainService) { + case .success(let data): + guard data.count == 32 else { return .invalid } + return .available(SymmetricKey(data: data)) + case .itemNotFound: + return .missing + case .deviceLocked, .authenticationFailed: + return .unavailable(nil) + case .accessDenied: + return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(errSecNotAvailable))) + case .otherError(let status): + return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(status))) + } + } + + /// Must be called with `lock` held. + private func readSnapshotLocked() -> DiskReadResult { + guard let fileURL, + FileManager.default.fileExists(atPath: fileURL.path) else { + return .missing + } + let sealed: Data + do { + sealed = try readData(fileURL) + } catch { + return .deferred(error) + } + // A locked Keychain is transient; a genuine item-not-found next to an + // existing sealed file is permanent (notably after restoring onto a + // new device, because the key is ThisDeviceOnly). Remove that + // unrecoverable ciphertext before allowing a replacement key/file. + let key: SymmetricKey + switch readEncryptionKey() { + case .available(let availableKey): + key = availableKey + case .missing: + return discardOrphanedSnapshotLocked(reason: "encryption key is missing") + case .invalid: + keychain.delete(key: Self.keychainKey, service: Self.keychainService) + return discardOrphanedSnapshotLocked(reason: "encryption key has an invalid length") + case .unavailable(let error): + return .deferred(error) + } + do { + let box = try ChaChaPoly.SealedBox(combined: sealed) + let plaintext = try ChaChaPoly.open(box, using: key) + let decoded = try JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) + var outbox: Snapshot = [:] + for (peerID, queue) in decoded where !queue.isEmpty { + outbox[PeerID(str: peerID)] = queue + } + Self.migrateFileProtectionIfNeeded(at: fileURL) + return .loaded(outbox) + } catch { + return .corrupt(error) + } + } + + /// Must be called with `lock` held. A ciphertext whose ThisDeviceOnly key + /// is definitively absent can never become readable; removing it is safer + /// than deferring forever or overwriting it while pretending it loaded. + private func discardOrphanedSnapshotLocked(reason: String) -> DiskReadResult { + guard let fileURL else { return .missing } + do { + try FileManager.default.removeItem(at: fileURL) + SecureLogger.warning("Removed unrecoverable encrypted outbox because its \(reason)", category: .session) + return .missing + } catch { + SecureLogger.error("Could not remove unrecoverable encrypted outbox: \(error)", category: .session) + return .deferred(error) + } + } + + /// Must be called with `lock` held. + @discardableResult + private func persistSnapshotLocked(_ snapshot: Snapshot) -> Bool { + guard let fileURL else { return true } + do { + if snapshot.isEmpty { + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.removeItem(at: fileURL) + } + return true + } + guard let key = encryptionKey(createIfMissing: true) else { + SecureLogger.error("Outbox not persisted: no encryption key available", category: .session) + return false + } + let keyed = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.key.id, $0.value) }) + let plaintext = try JSONEncoder().encode(keyed) + let sealed = try ChaChaPoly.seal(plaintext, using: key).combined + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + try writeData(sealed, fileURL, options) + return true + } catch { + SecureLogger.error("Failed to persist outbox: \(error)", category: .session) + return false + } + } + + /// Must be called with `lock` held. + private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool { + guard persistSnapshotLocked(snapshot) else { return false } + pendingRemovalMessageIDs.removeAll() + return true + } + + /// Must be called with `lock` held. + private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot { + Self.removing(pendingRemovalMessageIDs, from: snapshot) + } + + private static func removing(_ messageIDs: Set, from snapshot: Snapshot) -> Snapshot { + guard !messageIDs.isEmpty else { return snapshot } + var filtered: Snapshot = [:] + for (peerID, queue) in snapshot { + let remaining = queue.filter { !messageIDs.contains($0.messageID) } + if !remaining.isEmpty { filtered[peerID] = remaining } + } + return filtered + } + + private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot { + let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) }) + return removing(knownIDs, from: durable) + } + + private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot { + var merged = durable + for (peerID, pendingQueue) in pending { + var queue = merged[peerID] ?? [] + for var candidate in pendingQueue { + if let index = queue.firstIndex(where: { $0.messageID == candidate.messageID }) { + candidate.sendAttempts = max(candidate.sendAttempts, queue[index].sendAttempts) + candidate.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys) + queue[index] = candidate + } else { + queue.append(candidate) + } + } + queue.sort { $0.timestamp < $1.timestamp } + if !queue.isEmpty { merged[peerID] = queue } + } + return merged.filter { !$0.value.isEmpty } + } + + private func notifyRecovered(_ snapshot: Snapshot, generation: UInt64) { + lock.lock() + guard lifecycleGeneration == generation else { + lock.unlock() + return + } + let handler = recoveryHandler + if handler == nil { + unreportedRecoveredSnapshot = RecoveredSnapshot( + snapshot: snapshot, + generation: generation, + unseenDurable: unseenRecoveredSnapshot + ) + } + lock.unlock() + guard let handler else { return } + Task { @MainActor [weak self] in + guard let latest = self?.claimPendingRecovery(generation: generation) else { return } + handler(latest) + } + } + + private func claimPendingRecovery(generation: UInt64) -> Snapshot? { + lock.lock() + defer { lock.unlock() } + guard lifecycleGeneration == generation, recoveryDeliveryPending else { return nil } + let latest = cachedSnapshot + recoveryDeliveryPending = false + unseenRecoveryPendingPersistence = false + unseenRecoveredSnapshot = [:] + recoveryRouterSnapshot = [:] + unreportedRecoveredSnapshot = nil + return latest + } + + private static func migrateFileProtectionIfNeeded(at fileURL: URL) { + #if os(iOS) + do { + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + } catch { + SecureLogger.warning("Failed to migrate outbox file protection: \(error)", category: .session) + } + #endif + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("courier", isDirectory: true) + .appendingPathComponent("outbox.sealed") + } +} diff --git a/bitchat/Services/Courier/StoreAndForwardMetrics.swift b/bitchat/Services/Courier/StoreAndForwardMetrics.swift new file mode 100644 index 00000000..4fddf251 --- /dev/null +++ b/bitchat/Services/Courier/StoreAndForwardMetrics.swift @@ -0,0 +1,68 @@ +// +// StoreAndForwardMetrics.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Privacy-safe local counters for the store-and-forward stack: bare event +/// tallies with no message IDs, peer identities, or timestamps, so delivery +/// behavior can be measured on-device without recording who talked to whom. +/// Log-only surface — nothing here ever leaves the device. +final class StoreAndForwardMetrics { + enum Event: String, CaseIterable { + /// A private message entered the outbox (no prompt route available). + case outboxQueued = "outbox.queued" + /// A retained message was re-sent on a flush. + case outboxResent = "outbox.resent" + /// A delivery/read ack cleared a retained message. + case outboxDelivered = "outbox.delivered" + /// A retained message was dropped (attempt cap, TTL, or overflow). + case outboxDropped = "outbox.dropped" + /// We handed sealed mail to a courier. + case courierDeposited = "courier.deposited" + /// We accepted sealed mail to carry for a third party. + case courierAccepted = "courier.accepted" + /// We handed carried mail to its recipient over a direct link. + case courierHandedOver = "courier.handedOver" + /// We pushed carried mail toward a recipient heard via relay. + case courierRemoteHandover = "courier.remoteHandover" + /// We split spray copies to another courier. + case courierSprayed = "courier.sprayed" + /// Couriered mail addressed to us was opened and delivered. + case courierOpened = "courier.opened" + } + + static let shared = StoreAndForwardMetrics() + + private let lock = NSLock() + private var counts: [String: Int] + private let defaults: UserDefaults + private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:] + } + + func record(_ event: Event) { + lock.lock() + let total = (counts[event.rawValue] ?? 0) + 1 + counts[event.rawValue] = total + defaults.set(counts, forKey: Self.defaultsKey) + lock.unlock() + SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session) + } + + /// Included in the panic wipe alongside the stores it describes. + func reset() { + lock.lock() + counts = [:] + defaults.removeObject(forKey: Self.defaultsKey) + lock.unlock() + } +} diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index b53d640e..84abe5d3 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -34,23 +34,7 @@ final class FavoritesPersistenceService: ObservableObject { static let shared = FavoritesPersistenceService() - /// Default keychain for the `shared` singleton. Under test this is an - /// in-memory keychain so touching `shared` never blocks on securityd - /// (`SecItemCopyMatching` can hang in test environments) and never reads - /// or writes the developer's real keychain. Production behavior is - /// unchanged. Tests that need their own instance keep injecting a mock - /// via `init(keychain:)`. - private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol { - // PreviewKeychainManager lives in _PreviewHelpers, a development - // asset excluded from archive builds — release code must not - // reference it. Tests always run Debug, so the guard is lossless. - #if DEBUG - if TestEnvironment.isRunningTests { return PreviewKeychainManager() } - #endif - return KeychainManager() - } - - init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) { + init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) { self.keychain = keychain loadFavorites() @@ -141,7 +125,13 @@ final class FavoritesPersistenceService: ObservableObject { peerNostrPublicKey: String? = nil ) { let existing = favorites[peerNoisePublicKey] - let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" + // Callers that can't resolve the live nickname pass the "Unknown" + // placeholder (e.g. a notification arriving before the announce); + // never let it clobber a real stored nickname. + let incoming = peerNickname.flatMap { name in + (name.isEmpty || name == "Unknown") ? nil : name + } + let displayName = incoming ?? existing?.peerNickname ?? "Unknown" SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session) diff --git a/bitchat/Services/Gateway/BoundedIDSet.swift b/bitchat/Services/Gateway/BoundedIDSet.swift new file mode 100644 index 00000000..8509c295 --- /dev/null +++ b/bitchat/Services/Gateway/BoundedIDSet.swift @@ -0,0 +1,35 @@ +// +// BoundedIDSet.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +/// Insertion-ordered string set with a fixed capacity; the oldest entry is +/// evicted when full. Shared by the gateway and bridge loop-prevention +/// caches. +struct BoundedIDSet { + private var members: Set = [] + private var order: [String] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = capacity + } + + func contains(_ id: String) -> Bool { + members.contains(id) + } + + /// Returns false when the ID was already present. + @discardableResult + mutating func insert(_ id: String) -> Bool { + guard members.insert(id).inserted else { return false } + order.append(id) + if order.count > capacity { + members.remove(order.removeFirst()) + } + return true + } +} diff --git a/bitchat/Services/Gateway/BridgeCourierService.swift b/bitchat/Services/Gateway/BridgeCourierService.swift new file mode 100644 index 00000000..f1f72c31 --- /dev/null +++ b/bitchat/Services/Gateway/BridgeCourierService.swift @@ -0,0 +1,565 @@ +// +// BridgeCourierService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Foundation +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// Courier delivery over the internet bridge: sealed courier envelopes are +/// parked on relays as kind-1401 "drops" tagged with their rotating +/// recipient tag, so delivery stops requiring a physical courier to bump +/// into the recipient. +/// +/// Three duties, all gated on the bridge toggle: +/// - Sender: when the message router seals mail for an unreachable peer, a +/// copy is published as a drop (queued until relays connect). The drop is +/// signed with a fresh throwaway key per publish — the envelope +/// authenticates its sender internally via Noise-X, and a stable publisher +/// key would leak courier traffic patterns to relays. +/// - Recipient: subscribes for its own candidate tags (adjacent UTC days) +/// and opens matching drops directly. +/// - Gateway (bridge + gateway toggles): additionally watches the tags of +/// verified local mesh peers and hands matching drops to them as directed +/// courier packets, so mesh-only recipients are served too. +/// +/// Privacy: a drop reveals to relays only that "someone" is messaging "some +/// 16-byte day-rotating tag". Only parties who already know the recipient's +/// Noise static key can compute the tag; the payload is an opaque Noise-X +/// seal. Duplicate deliveries (drop + physical courier + direct link) are +/// absorbed downstream by message-ID dedup. +@MainActor +final class BridgeCourierService: ObservableObject { + enum Limits { + /// Drops waiting for relay connectivity (bounded, drop-oldest). + static let maxPendingDrops = 20 + /// Republish cooldown for gateway-held envelopes. + static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60 + /// Local peers a gateway watches drops for (x3 candidate tags each). + static let maxWatchedPeers = 16 + /// Tag-set refresh cadence (also covers UTC day rollover). + static let refreshIntervalSeconds: TimeInterval = 30 * 60 + /// Minimum spacing for announce-driven refreshes. + static let announceRefreshDebounceSeconds: TimeInterval = 60 + /// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack). + static let maxDropEnvelopeBytes = 20 * 1024 + static let maxTrackedIDs = 512 + /// Coalescing window for dedup-record writes: a backlog re-fetch + /// mutates the seen set once per event, and each snapshot save is a + /// full JSON encode + atomic write on the main actor. + static let dedupPersistCoalesceSeconds: TimeInterval = 1.0 + } + + static let shared = BridgeCourierService() + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + var bridgeEnabled: (@MainActor () -> Bool)? + var relaysConnected: (@MainActor () -> Bool)? + /// Publishes a signed drop event directly to connected default (DM) + /// relays. Completion is true only after at least one relay explicitly + /// accepts the event via NIP-20 OK; this must never mean "queued in RAM" + /// or merely "written to a socket". + var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)? + /// (Re)opens the drop subscription for the given hex tags. + var openSubscription: (@MainActor ([String]) -> Void)? + var closeSubscription: (@MainActor () -> Void)? + /// Our own Noise static public key. + var myNoiseKey: (@MainActor () -> Data?)? + /// Verified reachable local peers with known Noise keys. + var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])? + /// Seals content into a carry-only envelope for a recipient key. + var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)? + /// Opens a drop addressed to us. True means the inner envelope was + /// delivered, deduplicated, or intentionally rejected after decryption; + /// false leaves the relay event retryable after a transient crypto/key + /// failure. + var openEnvelope: (@MainActor (CourierEnvelope) -> Bool)? + /// Hands a drop to a matching local peer as a directed courier packet. + /// Returns true only when the transport accepted the packet onto a live + /// physical link or its link-specific backpressure queue. Stale + /// reachability and process-local directed spooling return false so the + /// drop event stays retryable. + var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Bool)? + /// Held envelopes eligible for (re)publish, honoring the cooldown. + var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])? + /// Commits a held envelope's cooldown after confirmed relay acceptance. + var markHeldEnvelopePublished: (@MainActor (CourierEnvelope) -> Void)? + /// Timer injection for tests; nil arms a real `Task`. + var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? + + // MARK: State + + private(set) var myTagsHex: Set = [] + private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set)] = [] + private(set) var pendingDrops: [( + envelope: CourierEnvelope, + dedupKey: String?, + operationID: UUID? + )] = [] + /// Message IDs already published as drops (sender-side dedup) and drop + /// event IDs already handled (multi-relay dedup). Both persist across + /// relaunches: relays hold drops for the full 24h NIP-40 window and the + /// persisted outbox keeps re-depositing, so in-memory-only dedup meant + /// every relaunch republished the same message as a fresh drop and every + /// gateway relaunch re-delivered the whole backlog (field-verified + /// amplification storm). Entries age out with the 24h drop window. + private var publishedDropKeys: ExpiringIDSet + private var seenDropEventIDs: ExpiringIDSet + private var subscriptionOpen = false + private var lastSubscribedTags: Set = [] + private var refreshTimerArmed = false + private var announceRefreshTimerArmed = false + private var lastAnnounceRefresh = Date.distantPast + private struct ActiveDropOperation { + let id: UUID + let completion: @MainActor (Bool) -> Void + } + /// Sender operations queued locally or awaiting relay confirmation. + /// The per-attempt ID prevents a stale pre-wipe callback from completing + /// a newer attempt for the same message. + private var activeDropOperations: [String: ActiveDropOperation] = [:] + /// Held-envelope publishes have no sender message ID, but still need an + /// in-flight identity: repeated refreshes inside the NIP-20 wait window + /// must not mint duplicate relay events for the same opaque envelope. + private var heldDropOperations: [Data: UUID] = [:] + /// Deterministically invalid envelopes are suppressed for this process, + /// but never persisted as if a relay accepted them. Bound and age them so + /// rotating oversize IDs cannot grow process memory forever. + private var rejectedDropKeys = ExpiringIDSet( + capacity: Limits.maxTrackedIDs, + lifetime: CourierEnvelope.maxLifetimeSeconds + ) + + private let now: () -> Date + private let dedupStore: BridgeDropDedupStore + + private var dedupPersistScheduled = false + + init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) { + self.now = now + self.dedupStore = dedupStore ?? BridgeDropDedupStore(persistsToDisk: !TestEnvironment.isRunningTests) + let snapshot = self.dedupStore.load() + let date = now() + self.publishedDropKeys = ExpiringIDSet( + capacity: Limits.maxTrackedIDs, + lifetime: CourierEnvelope.maxLifetimeSeconds, + entries: snapshot.publishedDropKeys, + now: date + ) + self.seenDropEventIDs = ExpiringIDSet( + capacity: Limits.maxTrackedIDs, + lifetime: CourierEnvelope.maxLifetimeSeconds, + entries: snapshot.seenDropEventIDs, + now: date + ) + // A coalesced dedup write scheduled just before a background kill + // would be lost; flush when the app backgrounds or terminates. + #if os(iOS) + let flushNotifications = [UIApplication.didEnterBackgroundNotification, UIApplication.willTerminateNotification] + #else + let flushNotifications = [NSApplication.willTerminateNotification] + #endif + for name in flushNotifications { + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.flushDedupSnapshot() } + } + } + } + + /// Schedules a coalesced write of the dedup record (see + /// `Limits.dedupPersistCoalesceSeconds`); lifecycle notifications flush + /// any scheduled write before a background kill could drop it. + private func persistDedup() { + guard !dedupPersistScheduled else { return } + dedupPersistScheduled = true + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Limits.dedupPersistCoalesceSeconds * 1_000_000_000)) + guard let self else { return } + self.dedupPersistScheduled = false + self.flushDedupSnapshot() + } + } + + /// Writes the dedup record now. `publishedDropKeys` contains only drops + /// a relay explicitly accepted; queued and in-flight keys + /// live in `activeDropOperations` and are intentionally process-local. + func flushDedupSnapshot() { + dedupStore.save(BridgeDropDedupStore.Snapshot( + publishedDropKeys: publishedDropKeys.entries, + seenDropEventIDs: seenDropEventIDs.entries + )) + } + + /// Panic wipe: forget queued drops and the persisted dedup record. + func wipe() { + cancelActivePublishes() + rejectedDropKeys = ExpiringIDSet( + capacity: Limits.maxTrackedIDs, + lifetime: CourierEnvelope.maxLifetimeSeconds + ) + publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds) + seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds) + dedupStore.wipe() + } + + // MARK: - Sender role + + /// Parallel-deposit a sealed copy of an outbound private message as a + /// relay drop. Called by the message router alongside physical courier + /// deposits; idempotent per message ID. Completion becomes true only + /// after a real relay acceptance arrives, which is when the router may + /// show "carried". + func depositDrop( + content: String, + messageID: String, + recipientNoiseKey: Data, + completion: @escaping @MainActor (Bool) -> Void = { _ in } + ) { + guard bridgeEnabled?() ?? false else { + completion(false) + return + } + guard !publishedDropKeys.contains(messageID, now: now()), + activeDropOperations[messageID] == nil, + !rejectedDropKeys.contains(messageID, now: now()) else { + completion(false) + return + } + guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { + completion(false) + return + } + // An envelope that can't encode within the drop size caps fails the + // same way on every attempt (size is a function of the content, not + // of the sealing); suppress it in-memory so the retry sweep does not + // churn, but never persist it as a published drop. + guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else { + rejectedDropKeys.insert(messageID, now: now()) + completion(false) + return + } + let operationID = UUID() + activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion) + publishDrop(envelope, messageID: messageID, operationID: operationID) + } + + /// Publishes held envelopes (mail we carry for others) as drops, + /// honoring the per-envelope cooldown. + func publishHeldEnvelopes() { + guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return } + for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] { + let key = envelope.ciphertext + guard heldDropOperations[key] == nil else { continue } + let operationID = UUID() + heldDropOperations[key] = operationID + publishDrop(envelope) { [weak self] succeeded in + guard let self, self.heldDropOperations[key] == operationID else { return } + self.heldDropOperations.removeValue(forKey: key) + if succeeded { + self.markHeldEnvelopePublished?(envelope) + } + } + } + } + + /// Publishes a drop, or queues it when relays are down. `messageID` is the + /// sender-side dedup key (nil for held/relayed envelopes we don't track); + /// it rides the pending queue so an evicted or failed drop can release its + /// in-flight slot. Completion reports actual NIP-20 relay acceptance. + private func publishDrop( + _ envelope: CourierEnvelope, + messageID: String? = nil, + operationID: UUID? = nil, + untrackedCompletion: (@MainActor (Bool) -> Void)? = nil + ) { + guard let encoded = envelope.encode(), + encoded.count <= Limits.maxDropEnvelopeBytes, + !envelope.isExpired else { + finishPublish( + messageID: messageID, + operationID: operationID, + succeeded: false, + untrackedCompletion: untrackedCompletion + ) + return + } + guard relaysConnected?() ?? false else { + // Held mail remains in CourierStore and has no sender operation to + // recover after an in-memory queue loss. Leave its cooldown unset + // and let the next connected refresh offer it again. + guard messageID != nil else { + untrackedCompletion?(false) + return + } + pendingDrops.append((envelope, messageID, operationID)) + while pendingDrops.count > Limits.maxPendingDrops { + let evicted = pendingDrops.removeFirst() + finishPublish( + messageID: evicted.dedupKey, + operationID: evicted.operationID, + succeeded: false + ) + } + return + } + guard let identity = try? NostrIdentity.generate(), + let event = try? NostrProtocol.createCourierDropEvent( + envelope: encoded, + recipientTagHex: envelope.recipientTag.hexEncodedString(), + expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000), + senderIdentity: identity + ) else { + SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) + finishPublish( + messageID: messageID, + operationID: operationID, + succeeded: false, + untrackedCompletion: untrackedCompletion + ) + return + } + guard let publishEvent else { + SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session) + finishPublish( + messageID: messageID, + operationID: operationID, + succeeded: false, + untrackedCompletion: untrackedCompletion + ) + return + } + publishEvent(event) { [weak self] succeeded in + guard let self else { return } + guard self.finishPublish( + messageID: messageID, + operationID: operationID, + succeeded: succeeded, + untrackedCompletion: untrackedCompletion + ) else { return } + if succeeded { + SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session) + } else { + SecureLogger.warning("📦🌉 No relay accepted courier drop", category: .session) + } + } + } + + @discardableResult + private func finishPublish( + messageID: String?, + operationID: UUID?, + succeeded: Bool, + untrackedCompletion: (@MainActor (Bool) -> Void)? = nil + ) -> Bool { + guard let messageID else { + untrackedCompletion?(succeeded) + return true + } + // Missing/mismatched means this callback was duplicated, invalidated + // by panic wipe, or belongs to an older attempt for the same key. + guard let operationID, + let operation = activeDropOperations[messageID], + operation.id == operationID else { return false } + activeDropOperations.removeValue(forKey: messageID) + if succeeded { + publishedDropKeys.insert(messageID, now: now()) + persistDedup() + } + operation.completion(succeeded) + return true + } + + /// Drops queued while relays were unreachable publish on reconnect. + func flushPendingDrops() { + guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return } + let queued = pendingDrops + pendingDrops.removeAll() + for item in queued { + publishDrop( + item.envelope, + messageID: item.dedupKey, + operationID: item.operationID + ) + } + } + + // MARK: - Subscription (recipient + gateway watch) + + /// Recomputes the watched tag set and (re)opens the subscription. + /// Call on toggle changes, relay connectivity changes, and periodically + /// (tags rotate daily); idempotent. + func refresh() { + armRefreshTimerIfNeeded() + guard bridgeEnabled?() ?? false else { + cancelActivePublishes() + if subscriptionOpen { + closeSubscription?() + subscriptionOpen = false + } + return + } + guard relaysConnected?() ?? false else { + if subscriptionOpen { + closeSubscription?() + subscriptionOpen = false + } + return + } + let date = now() + if let myKey = myNoiseKey?() { + myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() }) + } else { + myTagsHex = [] + } + // While bridging with internet, every device watches drops for its + // verified local peers — the single-switch analogue of gateway duty. + let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers) + watchedPeerTags = peers.map { peer in + (peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() })) + } + let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex)) + guard !allTags.isEmpty else { + if subscriptionOpen { + closeSubscription?() + subscriptionOpen = false + lastSubscribedTags = [] + } + return + } + // Resubscribe only when the watched set actually changed — refresh + // fires on every verified announce (field logs showed the drop + // subscription rebuilt every ~60s for an unchanged tag set). + if !subscriptionOpen || allTags != lastSubscribedTags { + openSubscription?(allTags.sorted()) + subscriptionOpen = true + lastSubscribedTags = allTags + } + flushPendingDrops() + publishHeldEnvelopes() + } + + /// Announce-driven refresh, debounced — a newly verified peer should be + /// watched promptly, but announce storms must not thrash subscriptions. + /// Calls inside the window coalesce into one trailing refresh so peers + /// learned after the leading edge are not omitted until the 30-minute + /// periodic timer. + func refreshAfterVerifiedAnnounce() { + guard bridgeEnabled?() ?? false else { return } + let date = now() + let elapsed = date.timeIntervalSince(lastAnnounceRefresh) + if elapsed >= Limits.announceRefreshDebounceSeconds { + lastAnnounceRefresh = date + refresh() + return + } + + guard !announceRefreshTimerArmed else { return } + announceRefreshTimerArmed = true + let delay = max(0, Limits.announceRefreshDebounceSeconds - elapsed) + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.announceRefreshTimerArmed = false + guard self.bridgeEnabled?() ?? false else { return } + self.lastAnnounceRefresh = self.now() + self.refresh() + } + if let scheduleTimer { + scheduleTimer(delay, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + fire() + } + } + } + + private func armRefreshTimerIfNeeded() { + guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return } + refreshTimerArmed = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.refreshTimerArmed = false + self.refresh() + } + if let scheduleTimer { + scheduleTimer(Limits.refreshIntervalSeconds, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Inbound drops + + /// Entry point for every drop event the subscription delivers (the relay + /// manager has already verified the event signature). + func handleDropEvent(_ event: NostrEvent) { + guard bridgeEnabled?() ?? false else { return } + guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return } + // A resubscribe can still deliver an event from the old watch set. + // Do not durably consume it until it actually belongs to us/current + // local peer and is opened or accepted for physical delivery. + guard !seenDropEventIDs.contains(event.id, now: now()) else { return } + guard let data = Data(base64Encoded: event.content), + data.count <= Limits.maxDropEnvelopeBytes, + let envelope = CourierEnvelope.decode(data), + !envelope.isExpired else { + return + } + let tagHex = envelope.recipientTag.hexEncodedString() + // The envelope's own tag must match the event's filterable tag — + // otherwise a mislabeled drop could ride a subscription it doesn't + // belong to. + guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return } + + if myTagsHex.contains(tagHex) { + SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session) + if openEnvelope?(envelope) == true { + seenDropEventIDs.insert(event.id, now: now()) + persistDedup() + } + return + } + if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) { + SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))…", category: .session) + if deliverToPeer?(envelope, match.peerID) == true { + seenDropEventIDs.insert(event.id, now: now()) + persistDedup() + } + } + } + + // MARK: - Helpers + + /// Cancels queued and in-flight sender operations after bridge disable or + /// panic wipe. Invalidate first, then resolve false so callback re-entry + /// cannot be mistaken for an active operation; late relay callbacks no-op. + private func cancelActivePublishes() { + let invalidated = activeDropOperations.values.map(\.completion) + pendingDrops.removeAll() + activeDropOperations.removeAll() + // Untracked held publishes are invalidated too. Their late relay + // callbacks compare operation IDs and no-op after this reset. + heldDropOperations.removeAll() + invalidated.forEach { $0(false) } + } + + /// A fresh random Nostr identity for signing one drop. Delegates to the + /// canonical generator (Schnorr key that can't fail validity) instead of + /// hand-rolling SecRandom + retry. + static func makeThrowawayIdentity() -> NostrIdentity? { + try? NostrIdentity.generate() + } +} diff --git a/bitchat/Services/Gateway/BridgeDropDedupStore.swift b/bitchat/Services/Gateway/BridgeDropDedupStore.swift new file mode 100644 index 00000000..76a0fe28 --- /dev/null +++ b/bitchat/Services/Gateway/BridgeDropDedupStore.swift @@ -0,0 +1,137 @@ +// +// BridgeDropDedupStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// ID set with per-entry timestamps: entries expire after `lifetime` and the +/// oldest are evicted past `capacity`. The bridge's dedup caches use this +/// instead of `BoundedIDSet` because their contents persist across relaunches +/// and must age out with the 24h drop window they guard. +struct ExpiringIDSet { + private(set) var entries: [String: Date] + let capacity: Int + let lifetime: TimeInterval + + init(capacity: Int, lifetime: TimeInterval, entries: [String: Date] = [:], now: Date = Date()) { + self.capacity = capacity + self.lifetime = lifetime + self.entries = entries + prune(now: now) + } + + func contains(_ id: String, now: Date) -> Bool { + guard let recorded = entries[id] else { return false } + return now.timeIntervalSince(recorded) <= lifetime + } + + /// Returns false when the ID was already present (and unexpired). + @discardableResult + mutating func insert(_ id: String, now: Date) -> Bool { + guard !contains(id, now: now) else { return false } + entries[id] = now + prune(now: now) + return true + } + + /// Releases a previously inserted ID so it can be re-added later (e.g. a + /// queued drop evicted before it ever published must become retryable). + mutating func remove(_ id: String) { + entries.removeValue(forKey: id) + } + + private mutating func prune(now: Date) { + if entries.contains(where: { now.timeIntervalSince($0.value) > lifetime }) { + entries = entries.filter { now.timeIntervalSince($0.value) <= lifetime } + } + let overflow = entries.count - capacity + guard overflow > 0 else { return } + for (id, _) in entries.sorted(by: { $0.value < $1.value }).prefix(overflow) { + entries.removeValue(forKey: id) + } + } +} + +/// Disk persistence for the bridge courier's drop-dedup record. Relays hold +/// drops for the full 24h NIP-40 window and redeliver them on every launch, +/// and the 120s outbox sweep re-deposits anything undelivered — so with +/// in-memory-only dedup every relaunch republished the same message as a +/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every +/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20 +/// copies of one DM delivered in 40ms fed the storm behind a permanent +/// device freeze. Persisting both sides caps this at one drop per message +/// ID per 24h regardless of relaunch count. +/// +/// Contents are opaque IDs (message UUIDs, relay event IDs) — no plaintext, +/// no peer identities — so until-first-unlock protection matches +/// `NostrProcessedEventStore`, and the file must load during a +/// locked-background restoration relaunch. Wiped on panic with the rest of +/// the courier state. +final class BridgeDropDedupStore { + struct Snapshot: Codable { + var publishedDropKeys: [String: Date] + var seenDropEventIDs: [String: Date] + } + + private let fileURL: URL? + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(persistsToDisk: Bool = true, fileURL: URL? = nil) { + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + } + + func load() -> Snapshot { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data) else { + return Snapshot(publishedDropKeys: [:], seenDropEventIDs: [:]) + } + return snapshot + } + + func save(_ snapshot: Snapshot) { + guard let fileURL else { return } + guard !(snapshot.publishedDropKeys.isEmpty && snapshot.seenDropEventIDs.isEmpty) else { + try? FileManager.default.removeItem(at: fileURL) + return + } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(snapshot) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist bridge drop dedup record: \(error)", category: .session) + } + } + + /// Panic wipe: forget which drops we published or handled. + func wipe() { + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("courier", isDirectory: true) + .appendingPathComponent("bridge-drop-dedup.json") + } +} diff --git a/bitchat/Services/Gateway/BridgeService.swift b/bitchat/Services/Gateway/BridgeService.swift new file mode 100644 index 00000000..2138a397 --- /dev/null +++ b/bitchat/Services/Gateway/BridgeService.swift @@ -0,0 +1,906 @@ +// +// BridgeService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Policy engine for the mesh bridge: an opt-in stitcher of disjoint BLE +/// mesh islands that share a place. While the toggle is on, this device's +/// public mesh messages are additionally signed (with a derived, unlinkable +/// per-cell Nostr identity) as rendezvous events for the local geohash cell +/// and published to the cell's deterministic geo relays — directly when we +/// have internet, or deposited with a bridge gateway peer over a directed +/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from +/// other islands render into the mesh timeline marked as bridged. +/// +/// A device with BOTH this toggle and the gateway toggle on serves the +/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts +/// remote rendezvous events onto the mesh as `fromBridge` carriers so +/// mesh-only peers see across the bridge too. +/// +/// Consent model: +/// - Nothing crosses a bridge unless its author signed it for the bridge: +/// gateways carry only finished, Schnorr-signed rendezvous events, so a +/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message +/// "nearby only" flag simply skips composing a rendezvous copy. +/// - Receiving over radio (`fromBridge` carriers) is always on — it is +/// passive and leaks nothing. Subscribing over the internet (which reveals +/// the coarse cell to relays) and publishing both require the toggle. +/// +/// Loop-prevention rules (adapted from `GatewayService`, unit-tested): +/// 1. An event learned from a `fromBridge` mesh broadcast is never published +/// and never rebroadcast (`meshBroadcastEventIDs`) — a second bridge +/// gateway on the same island cannot echo mesh-carried traffic. +/// 2. An event this device published (own messages or uplinked deposits, +/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on +/// this island, so our own relay subscription redelivering it must not +/// double BLE airtime. +/// 3. A subscription event is rebroadcast at most once +/// (`rebroadcastEventIDs`, marked after send), and never when the island +/// already holds the radio copy (`isMessageSeenLocally` on the event's +/// mesh message ID) — remote islands' traffic is the only thing worth +/// airtime. +/// Receivers key bridge rows by the signed Nostr event ID. The event's `m` tag +/// is only a radio-copy hint: its mesh sender/timestamp fields are public and +/// cannot authenticate the event signer, so letting them own the timeline ID +/// would allow a different signer to front-run the genuine event's dedup slot. +/// When the radio copy is already present the hint avoids duplicate rendering +/// and downlink airtime. If the bridge copy wins the race, a later +/// authenticated radio copy replaces every bridge row that claimed the same +/// hint; the untrusted hint can therefore merge a duplicate but can never +/// suppress the radio-authenticated origin. +/// +/// All dependencies are closure-injected (repo convention) so the policy +/// layer is unit-testable without relays, radios, or CoreLocation. +@MainActor +final class BridgeService: ObservableObject { + enum Limits { + /// Uplink deposits held while relays are unreachable. + static let maxQueuedUplinks = 20 + static let maxQueuedUplinksPerDepositor = 5 + /// Uplink deposits accepted per depositor per minute. + static let uplinkEventsPerMinutePerDepositor = 10 + /// Downlink mesh rebroadcasts per minute — BLE airtime is precious, + /// and bridge traffic shares the radio with everything else. + static let downlinkEventsPerMinute = 20 + static let maxPendingDownlinks = 30 + /// Accepted clock skew for a rendezvous event. + static let maxEventAgeSeconds: TimeInterval = 15 * 60 + /// Bounded loop-prevention ID caches (oldest evicted). + static let maxTrackedEventIDs = 512 + /// Keep radio-replacement aliases for every bridge row that can still + /// be visible in the bounded mesh timeline. Retiring an alias earlier + /// would let valid high-volume ingress delete otherwise visible + /// history merely to preserve the radio-wins invariant. + static let maxTrackedRadioAliases = TransportConfig.meshTimelineCap + /// Presence heartbeat cadence while the bridge is active. + static let presenceIntervalSeconds: TimeInterval = 4 * 60 + /// A rendezvous participant counts toward "via bridge" for this long + /// after their last event. + static let participantFreshnessSeconds: TimeInterval = 10 * 60 + /// Relay ingress is adversarial: bound both accepted work and the + /// people-sheet state even when an attacker rotates signing keys. + static let inboundEventsPerMinute = 600 + static let inboundEventsPerMinutePerSigner = 120 + /// Cheap pre-crypto gate for both valid and invalid ingress. Without + /// it, invalid carrier events could force unbounded Schnorr work while + /// never reaching the accepted-event limiter below. + static let signatureVerificationAttemptsPerMinute = 720 + static let maxParticipants = 128 + /// Content cap, matching the public-message pipeline's own limit. + static let maxContentBytes = 16_000 + /// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km). + static let cellPrecision = 6 + } + + struct QueuedUplink { + let depositor: PeerID + let cell: String + let event: NostrEvent + } + + /// A validated rendezvous message ready for the timeline. + struct InboundBridgeMessage { + let messageID: String + /// Unauthenticated mesh coordinates can only be used to notice that a + /// verified radio copy is already present; they never own bridge dedup. + let radioMessageIDHint: String? + let senderNickname: String + let participantNickname: String? + let senderPubkey: String + let content: String + let timestamp: Date + } + + /// A person currently visible across the bridge (fresh, not attributed + /// to the local island), for the people sheet. + struct BridgedParticipant: Identifiable, Equatable { + let pubkey: String + let nickname: String? + let lastSeen: Date + var id: String { pubkey } + /// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote + /// "anon"s stay distinguishable. + var displayName: String { + (nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4)) + } + } + + static let shared = BridgeService() + + /// The user toggle. While true this device publishes its own public mesh + /// messages to the rendezvous and subscribes to it when online. + @Published private(set) var isEnabled: Bool + /// Distinct rendezvous participants seen within the freshness window. + /// Radio-copy hints never alter signer locality: their public coordinates + /// can suppress a duplicate row but cannot authenticate a Nostr signer. + @Published private(set) var bridgedPeerCount: Int = 0 + /// The people behind the count, newest activity first. + @Published private(set) var bridgedParticipants: [BridgedParticipant] = [] + /// The rendezvous cell currently in use, when the bridge is active. + @Published private(set) var activeCell: String? + /// Per-session compose flag: while true, outgoing messages stay on the + /// radio — no rendezvous copy is composed, so no gateway can carry them. + @Published var nearbyOnly: Bool = false + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + /// Publishes a signed event to the geo relays for a cell. + var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? + /// Opens the rendezvous subscription for (cell + neighbors); events are + /// fed back via `handleRendezvousEvent`. + var openSubscription: (@MainActor ([String]) -> Void)? + /// Closes the rendezvous subscription. + var closeSubscription: (@MainActor () -> Void)? + /// Whether any Nostr relay connection is currently working. + var relaysConnected: (@MainActor () -> Bool)? + /// The local neighborhood cell from CoreLocation, if permitted. + var locationCell: (@MainActor () -> String?)? + /// Asks the location layer for a fresh one-shot fix. The bridge must + /// pump location itself: channel data otherwise only flows while some + /// other feature (channels sheet, location notes) happens to be active — + /// a field failure mode where the bridge silently never got a cell. + var requestLocationFix: (@MainActor () -> Void)? + /// A rendezvous cell advertised by a reachable mesh bridge peer's + /// announce — lets a mesh-only, location-less device still compose + /// correctly tagged events. + var meshAdvertisedCell: (@MainActor () -> String?)? + /// Sends an encoded `toBridge` carrier directed to a bridge peer. + var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)? + /// Reachable mesh peers advertising the `.bridge` capability. + var availableBridgePeers: (@MainActor () -> [PeerID])? + /// Broadcasts an encoded `fromBridge` carrier on the mesh. + var broadcastToMesh: (@MainActor (Data) -> Void)? + /// Delivers a validated inbound bridge message to the mesh timeline. + var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)? + /// Removes a previously injected bridge row when an authenticated radio + /// copy arrives later. The UI hook also discards a not-yet-flushed row. + var removeInjectedInbound: (@MainActor (String) -> Void)? + /// Exact liveness check for a bridge row in either the pending UI pipeline + /// or bounded conversation store. Alias pruning may discard proof only + /// after the corresponding row is already gone. + var isInjectedInboundPresent: (@MainActor (String) -> Bool)? + /// True when the mesh timeline already holds this message ID (the radio + /// copy) — used to skip pointless downlink airtime. + var isMessageSeenLocally: (@MainActor (String) -> Bool)? + /// Derives the unlinkable per-cell rendezvous identity. + var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)? + /// Local nickname for the `n` tag. + var myNickname: (@MainActor () -> String)? + /// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce). + var onEnabledChanged: (@MainActor (Bool) -> Void)? + /// Fired when the active rendezvous cell changes (including to nil) so + /// the announce advertisement stays current. + var onActiveCellChanged: (@MainActor (String?) -> Void)? + /// Schedules a closure after a delay; nil arms a real `Task`. Injected so + /// timers are deterministic in tests. + var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? + + // MARK: State + + /// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts. + private var meshBroadcastEventIDs: BoundedIDSet + /// Loop rule 2: event IDs this device published (own or deposited). + private var publishedEventIDs: BoundedIDSet + /// Loop rule 3: event IDs this device already rebroadcast. + private var rebroadcastEventIDs: BoundedIDSet + /// Timeline message IDs already injected (either arrival path). + private var injectedMessageIDs: BoundedIDSet + /// Signed relay/carrier events already accepted. Kept separate from the + /// loop caches so a mesh arrival can still mark loop suppression even when + /// the relay copy won the race. + private var receivedEventIDs: BoundedIDSet + /// Authenticated radio IDs observed this session. These close the + /// bridge-first race even before the UI pipeline flushes its radio row. + private var observedRadioMessageIDs: BoundedIDSet + /// event ID -> untrusted radio hint. Event IDs own bridge + /// dedup; this bounded reverse index is used only to replace bridge rows + /// after the genuine radio packet has authenticated successfully. + private var injectedRadioAliases: [String: String] = [:] + private var injectedRadioAliasOrder: [String] = [] + + /// Cells the rendezvous subscription covers (own + neighbor ring). + private(set) var subscribedCells: Set = [] + private(set) var queuedUplinks: [QueuedUplink] = [] + private var uplinkDepositTimes: [PeerID: [Date]] = [:] + private var downlinkSendTimes: [Date] = [] + private var inboundEventTimes: [Date] = [] + private var inboundEventTimesBySigner: [String: [Date]] = [:] + private var signatureVerificationTokens = Double(Limits.signatureVerificationAttemptsPerMinute) + private var signatureVerificationLastRefillAt: Date? + private var pendingDownlinks: [(event: NostrEvent, cell: String)] = [] + private var downlinkDrainScheduled = false + private var presenceTimerArmed = false + private var lastPresenceAt = Date.distantPast + + /// pubkey -> (lastSeen, last known nickname). + private var participants: [String: (lastSeen: Date, nickname: String?)] = [:] + + private let defaults: UserDefaults + private let now: () -> Date + private let verifyEventSignature: (NostrEvent) -> Bool + private static let enabledKey = "bridge.userEnabled" + + init( + defaults: UserDefaults = .standard, + now: @escaping () -> Date = Date.init, + verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() } + ) { + self.defaults = defaults + self.now = now + self.verifyEventSignature = verifyEventSignature + self.isEnabled = defaults.bool(forKey: Self.enabledKey) + self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.receivedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.observedRadioMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + } + + // MARK: - Toggle & lifecycle + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + defaults.set(enabled, forKey: Self.enabledKey) + if !enabled { + queuedUplinks.removeAll() + pendingDownlinks.removeAll() + uplinkDepositTimes.removeAll() + inboundEventTimes.removeAll() + inboundEventTimesBySigner.removeAll() + participants.removeAll() + bridgedPeerCount = 0 + bridgedParticipants = [] + } + SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session) + refreshRendezvous() + onEnabledChanged?(enabled) + } + + /// Recomputes the active cell and (re)opens or closes the subscription. + /// Call on toggle changes, location updates, and relay connectivity + /// changes; idempotent. + func refreshRendezvous() { + let cell = isEnabled ? currentCell() : nil + // No cell yet: ask for a fix — the availableChannels change re-enters + // here once it lands. + if isEnabled, cell == nil { + requestLocationFix?() + } + guard cell != activeCell else { + // The maintenance timer must run even cell-less: it is what + // retries the location fix (launch races the permission + // callback, so the first request can silently no-op). + if isEnabled { armPresenceTimerIfNeeded() } + return + } + if activeCell != nil { + closeSubscription?() + subscribedCells = [] + } + activeCell = cell + onActiveCellChanged?(cell) + guard let cell else { + if isEnabled { armPresenceTimerIfNeeded() } + return + } + // Own cell + neighbors: islands straddling a cell edge still meet. + // Publishes go to the own cell only; symmetric because both sides + // subscribe to each other's cell via the neighbor ring. + let cells = [cell] + Geohash.neighbors(of: cell) + subscribedCells = Set(cells) + openSubscription?(cells) + SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session) + publishPresence() + armPresenceTimerIfNeeded() + } + + /// The rendezvous cell: our own location when we have it, else the cell + /// a reachable bridge gateway advertises in its announce. + private func currentCell() -> String? { + if let own = locationCell?(), !own.isEmpty { + return String(own.prefix(Limits.cellPrecision)) + } + if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) { + return String(advertised.prefix(Limits.cellPrecision)) + } + return nil + } + + // One switch does the right thing: while bridging, a device with + // internet automatically serves its island (accepts deposits, carries + // remote messages onto the radio). The marginal cost over bridging + // yourself is small — the relay connections and subscription already + // exist for you — and a separate "serve others" lever proved to be a + // silent trap for mesh-only neighbors. + + // MARK: - Outgoing (sender role) + + /// Composes and ships the bridged copy of an outgoing public mesh + /// message. Call after the radio send; no-op when the bridge is off, + /// no cell is known, or the message was flagged nearby-only upstream. + /// `senderPeerID`/`timestamp` are the origin coordinates of the radio + /// send — they (with the content) derive the cross-device-stable mesh + /// message ID that receivers dedup on. + func bridgeOutgoing(content: String, senderPeerID: PeerID, timestamp: Date) { + guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return } + guard content.utf8.count <= Limits.maxContentBytes else { return } + let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) + guard let identity = try? deriveIdentity?(cell), + let event = try? NostrProtocol.createBridgeMeshEvent( + content: content, + cell: cell, + senderIdentity: identity, + nickname: myNickname?(), + meshSenderID: senderPeerID.id, + meshTimestampMs: timestampMs + ) else { + SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session) + return + } + publishedEventIDs.insert(event.id) + injectedMessageIDs.insert(event.id) // our own timeline already has it + if relaysConnected?() ?? false { + publishToRelays?(event, cell) + } else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event), + let payload = carrier.encode(), + let gateway = availableBridgePeers?().first { + if sendToBridgePeer?(payload, gateway) ?? false { + SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))…", category: .session) + } + } + } + + /// Publishes a presence heartbeat so silent participants still register + /// across the bridge. Throttled: several triggers (enable, cell change, + /// relay reconnect) can coincide, and same-second heartbeats are + /// byte-identical events anyway. + func publishPresence() { + guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return } + guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return } + lastPresenceAt = now() + guard let identity = try? deriveIdentity?(cell), + let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return } + publishedEventIDs.insert(event.id) + publishToRelays?(event, cell) + } + + /// Maintenance heartbeat while bridging: presence, participant pruning, + /// and a location retry. Runs with or without a cell — the cell-less + /// case is exactly when the location retry matters. + private func armPresenceTimerIfNeeded() { + guard isEnabled, !presenceTimerArmed else { return } + presenceTimerArmed = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.presenceTimerArmed = false + self.publishPresence() + self.pruneParticipants() + // Location refresh: migrates cells on a moving device and + // recovers a launch that raced the permission callback. + if self.activeCell == nil { + self.refreshRendezvous() + } else { + self.requestLocationFix?() + } + self.armPresenceTimerIfNeeded() + } + if let scheduleTimer { + scheduleTimer(Limits.presenceIntervalSeconds, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Subscription ingress (internet role) + + /// Entry point for every event the rendezvous subscription delivers. + /// Handles presence accounting, timeline injection, and — when acting as + /// the island's gateway — downlink rebroadcast. + func handleRendezvousEvent(_ event: NostrEvent) { + guard isEnabled else { return } + // The subscription spans our cell + neighbors; trust only the + // event's own signed `r` tag, and only within that ring. + guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1], + subscribedCells.contains(cell) else { + return + } + // Events we published come back from our own subscription; they are + // presence-neutral (we never count ourselves) and never re-injected + // or rebroadcast. Two layers: the published-ID cache (this session) + // and pubkey self-recognition — the rendezvous identity is derived + // deterministically, so even after a relaunch wipes the cache our + // own relay-backfilled events are recognized (field bug: own + // pre-restart messages re-rendered as bridged). + guard !publishedEventIDs.contains(event.id) else { return } + if isOwnRendezvousEvent(event, cell: cell) { + publishedEventIDs.insert(event.id) // never downlink it either + return + } + guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else { return } + guard receivedEventIDs.insert(event.id), allowInboundEvent(from: event.pubkey) else { return } + guard let kind = classify(event, cell: cell) else { return } + + switch kind { + case .presence: + recordParticipant(event.pubkey, nickname: nil) + case .message(let message): + let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false + if isLocalRadioCopy { + // The public m-tag proves only that identical radio content is + // already present, not that this Nostr signer authored it. + // Skip the duplicate row without changing signer locality. + SecureLogger.debug("🌉 Bridge: authenticated radio copy already present; bridge alias skipped", category: .session) + } else if inject(message) { + recordParticipant(event.pubkey, nickname: message.participantNickname) + } + // Serving duty: carry remote islands' messages onto the radio for + // mesh-only peers. Local-origin events are skipped — the island + // already heard them (loop rule 3). The drain is jitter-delayed: + // with every online bridger serving, the holdoff lets gateways + // hear each other's broadcasts and skip duplicates. + if !isLocalRadioCopy, + !meshBroadcastEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id), + !pendingDownlinks.contains(where: { $0.event.id == event.id }) { + pendingDownlinks.append((event, cell)) + if pendingDownlinks.count > Limits.maxPendingDownlinks { + pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) + } + scheduleDownlinkDrainIfNeeded(jitter: true) + } + } + } + + /// Called only after the BLE public-message signature has authenticated. + /// A bridge hint is not trusted enough to drop this radio row. Instead, + /// the radio row wins and any earlier bridge aliases are removed, which + /// gives both arrival orders one timeline row without restoring the + /// origin-spoof vulnerability. + func handleAuthenticatedRadioMessage(messageID: String) { + guard !messageID.isEmpty else { return } + observedRadioMessageIDs.insert(messageID) + + let matching = injectedRadioAliases.filter { $0.value == messageID } + guard !matching.isEmpty else { return } + let eventIDs = Set(matching.keys) + for eventID in matching.keys { + removeInjectedInbound?(eventID) + injectedRadioAliases.removeValue(forKey: eventID) + } + injectedRadioAliasOrder.removeAll { eventIDs.contains($0) } + + // A relay event can already be waiting inside the multi-gateway jitter + // window. Remove it now so it cannot consume BLE airtime after the + // authenticated radio packet proved this island already has a copy. + pendingDownlinks.removeAll { item in + guard case .message(let message)? = classify(item.event, cell: item.cell) else { return false } + return message.radioMessageIDHint == messageID + } + } + + // MARK: - Mesh carrier ingress (both roles) + + /// Entry point for received `nostrCarrier` packets with bridge + /// directions. `directedToUs` is true for `toBridge` deposits addressed + /// to this device; false for `fromBridge` broadcasts. + func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) { + switch carrier.direction { + case .toBridge: + guard directedToUs else { return } + handleUplinkDeposit(carrier, from: peerID) + case .fromBridge: + guard !directedToUs else { return } + handleDownlinkBroadcast(carrier) + case .toGateway, .fromGateway: + return // GatewayService territory; routed there by the caller. + } + } + + // MARK: - Uplink (gateway role: mesh peer -> internet) + + private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { + guard isEnabled else { return } + // Cheap structural gates before any crypto, mirroring GatewayService. + guard let event = structurallyValidEvent(from: carrier) else { + SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) + return + } + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), + !queuedUplinks.contains(where: { $0.event.id == event.id }) else { + return + } + guard allowUplinkDeposit(from: depositor) else { + SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session) + return + } + guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else { + SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) + return + } + if relaysConnected?() ?? false { + publish(event, cell: carrier.geohash) + } else { + enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event)) + } + // No local injection: the depositor's radio broadcast already carried + // the message to this island, including us. + } + + /// Publish everything queued while relays were unreachable. + func flushQueuedUplinks() { + guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } + let queued = queuedUplinks + queuedUplinks.removeAll() + for item in queued where !publishedEventIDs.contains(item.event.id) { + publish(item.event, cell: item.cell) + } + } + + private func publish(_ event: NostrEvent, cell: String) { + publishedEventIDs.insert(event.id) + publishToRelays?(event, cell) + SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session) + } + + @discardableResult + private func enqueueUplink(_ item: QueuedUplink) -> Bool { + let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count + guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { return false } + if queuedUplinks.count >= Limits.maxQueuedUplinks { + queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) + } + queuedUplinks.append(item) + return true + } + + private func allowUplinkDeposit(from depositor: PeerID) -> Bool { + let cutoff = now().addingTimeInterval(-60) + var times = uplinkDepositTimes[depositor, default: []] + times.removeAll { $0 < cutoff } + guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { + uplinkDepositTimes[depositor] = times + return false + } + times.append(now()) + uplinkDepositTimes[depositor] = times + if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { + uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } } + } + return true + } + + /// Bounds relay/carrier work independently of the downlink airtime budget. + /// A valid signature proves control of one key, not that the sender is + /// entitled to unbounded main-actor state or CPU. + private func allowInboundEvent(from signer: String) -> Bool { + let date = now() + let cutoff = date.addingTimeInterval(-60) + inboundEventTimes.removeAll { $0 < cutoff } + guard inboundEventTimes.count < Limits.inboundEventsPerMinute else { return false } + + var signerTimes = inboundEventTimesBySigner[signer, default: []] + signerTimes.removeAll { $0 < cutoff } + guard signerTimes.count < Limits.inboundEventsPerMinutePerSigner else { + inboundEventTimesBySigner[signer] = signerTimes + return false + } + + inboundEventTimes.append(date) + signerTimes.append(date) + inboundEventTimesBySigner[signer] = signerTimes + if inboundEventTimesBySigner.count > Limits.maxTrackedEventIDs { + inboundEventTimesBySigner = inboundEventTimesBySigner.filter { entry in + entry.value.contains { $0 >= cutoff } + } + } + return true + } + + /// Bounds expensive signature checks before signer identity is trusted. + /// Kept independent from accepted-event accounting so invalid events do + /// not create signer state or poison any dedup cache. + private func allowSignatureVerificationAttempt() -> Bool { + let date = now() + if let lastRefillAt = signatureVerificationLastRefillAt { + let elapsed = max(0, date.timeIntervalSince(lastRefillAt)) + let refillPerSecond = Double(Limits.signatureVerificationAttemptsPerMinute) / 60 + signatureVerificationTokens = min( + Double(Limits.signatureVerificationAttemptsPerMinute), + signatureVerificationTokens + elapsed * refillPerSecond + ) + } + signatureVerificationLastRefillAt = date + guard signatureVerificationTokens >= 1 else { return false } + signatureVerificationTokens -= 1 + return true + } + + // MARK: - Downlink (gateway role: internet -> mesh) + + private func drainPendingDownlinks() { + let cutoff = now().addingTimeInterval(-60) + downlinkSendTimes.removeAll { $0 < cutoff } + while !pendingDownlinks.isEmpty, + downlinkSendTimes.count < Limits.downlinkEventsPerMinute { + let (event, cell) = pendingDownlinks.removeFirst() + guard isFresh(event) else { continue } + // Suppression recheck at send time: another gateway may have + // broadcast this event, or the authenticated radio copy may have + // arrived, during our jitter holdoff. + guard !meshBroadcastEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id) else { continue } + if case .message(let message)? = classify(event, cell: cell), + let radioMessageID = message.radioMessageIDHint, + radioCopyAlreadyPresent(radioMessageID) { + continue + } + guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event), + let payload = carrier.encode() else { continue } + broadcastToMesh?(payload) + SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session) + // Mark-after-send (loop rule 3): a queue-overflow drop stays + // retryable on relay redelivery. + rebroadcastEventIDs.insert(event.id) + downlinkSendTimes.append(now()) + } + scheduleDownlinkDrainIfNeeded() + } + + private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) { + guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } + let delay: TimeInterval + if jitter { + // Multi-gateway suppression window: enough spread for another + // gateway's broadcast to land and mark the event mesh-carried. + delay = Double.random(in: 0.2...1.5) + } else { + let oldest = downlinkSendTimes.min() ?? now() + delay = max(0.05, 60 - now().timeIntervalSince(oldest)) + } + downlinkDrainScheduled = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.downlinkDrainScheduled = false + self.drainPendingDownlinks() + } + if let scheduleTimer { + scheduleTimer(delay, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Downlink (receiver role: carried event arrives over radio) + + private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { + // Reception is deliberately NOT gated on the toggle: it is passive + // radio, and two phones side by side should not disagree about what + // the channel said. Publishing/subscribing remain opt-in. + guard let event = structurallyValidEvent(from: carrier), + !publishedEventIDs.contains(event.id), + !isOwnRendezvousEvent(event, cell: carrier.geohash), + allowSignatureVerificationAttempt(), + verifyEventSignature(event) else { + return + } + // Mark after verification (a forged copy must not poison the cache), + // even when the relay copy won the injection race: pending gateway + // downlinks consult this cache and must stand down. + let firstMeshArrival = meshBroadcastEventIDs.insert(event.id) + guard firstMeshArrival, + receivedEventIDs.insert(event.id), + allowInboundEvent(from: event.pubkey) else { return } + guard case .message(let message)? = classify(event, cell: carrier.geohash) else { + return + } + if inject(message) { + recordParticipant(event.pubkey, nickname: message.participantNickname) + } + } + + // MARK: - Injection & participants + + @discardableResult + private func inject(_ message: InboundBridgeMessage) -> Bool { + guard injectedMessageIDs.insert(message.messageID) else { return false } + guard !(message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false) else { + return false + } + SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session) + injectInbound?(message) + if let radioMessageID = message.radioMessageIDHint { + recordRadioAlias( + eventID: message.messageID, + radioMessageID: radioMessageID + ) + } + return true + } + + private func radioCopyAlreadyPresent(_ messageID: String) -> Bool { + observedRadioMessageIDs.contains(messageID) || (isMessageSeenLocally?(messageID) ?? false) + } + + private func recordRadioAlias( + eventID: String, + radioMessageID: String + ) { + guard injectedRadioAliases[eventID] == nil else { return } + injectedRadioAliases[eventID] = radioMessageID + injectedRadioAliasOrder.append(eventID) + guard injectedRadioAliasOrder.count > Limits.maxTrackedRadioAliases, + let isInjectedInboundPresent else { return } + + // Arrival order and signed event timestamps are independent. The + // conversation cap trims by timestamp, so blindly evicting the oldest + // alias could discard the proof for a still-visible row and then + // actively delete that history. Prune only aliases whose exact row is + // already absent; temporary overflow is safer than losing radio-wins + // reconciliation for any visible bridge message. + var overflow = injectedRadioAliasOrder.count - Limits.maxTrackedRadioAliases + var retained: [String] = [] + retained.reserveCapacity(injectedRadioAliasOrder.count) + for candidate in injectedRadioAliasOrder { + if overflow > 0, !isInjectedInboundPresent(candidate) { + injectedRadioAliases.removeValue(forKey: candidate) + overflow -= 1 + } else { + retained.append(candidate) + } + } + injectedRadioAliasOrder = retained + } + + private func recordParticipant(_ pubkey: String, nickname: String?) { + let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) + participants = participants.filter { $0.value.lastSeen >= cutoff } + if participants[pubkey] == nil, participants.count >= Limits.maxParticipants, + let oldest = participants.min(by: { $0.value.lastSeen < $1.value.lastSeen })?.key { + participants.removeValue(forKey: oldest) + } + let previous = participants[pubkey] + // Presence events carry no nickname, so a known name is never + // forgotten. Radio hints deliberately do not mutate this record. + participants[pubkey] = ( + lastSeen: now(), + nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname + ) + recomputeBridgedCount() + } + + private func pruneParticipants() { + let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) + participants = participants.filter { $0.value.lastSeen >= cutoff } + recomputeBridgedCount() + } + + private func recomputeBridgedCount() { + let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) + let visible = participants + .filter { $0.value.lastSeen >= cutoff } + .map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) } + .sorted { $0.lastSeen > $1.lastSeen } + if visible.count != bridgedPeerCount { + bridgedPeerCount = visible.count + } + if visible != bridgedParticipants { + bridgedParticipants = visible + } + } + + // MARK: - Validation + + private enum RendezvousKind { + case message(InboundBridgeMessage) + case presence + } + + /// Classifies a structurally acceptable rendezvous event; nil rejects. + private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? { + guard isFresh(event), + event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }), + GatewayService.isValidGeohash(cell) else { + return nil + } + switch event.kind { + case NostrProtocol.EventKind.geohashPresence.rawValue: + return .presence + case NostrProtocol.EventKind.ephemeralEvent.rawValue: + let content = event.content + guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil } + let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1] + // The `m` tag is `[stable ID, sender ID, wire timestamp ms]` for + // radio/bridge duplicate detection. Those coordinates are public + // and not cryptographically bound to this Nostr signer, so they + // are never allowed to own bridge dedup. A copied tag can at most + // make the attacker's own event look like a radio duplicate; it + // cannot reserve the genuine signed event's timeline ID. + let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" }) + let radioMessageIDHint: String? + if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit), + let timestampMs = UInt64(m[3]) { + radioMessageIDHint = MeshMessageIdentity.stableID( + senderIDHex: m[2], + timestampMs: timestampMs, + content: content + ) + } else { + radioMessageIDHint = nil + } + let baseNickname = nickname?.trimmedOrNilIfEmpty ?? "anon" + return .message(InboundBridgeMessage( + messageID: event.id, + radioMessageIDHint: radioMessageIDHint, + senderNickname: baseNickname + "#" + String(event.pubkey.suffix(4)), + participantNickname: nickname?.trimmedOrNilIfEmpty, + senderPubkey: event.pubkey, + content: content, + timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) + )) + default: + return nil + } + } + + /// Parse + size + cell + kind + `r` tag + freshness, with NO signature + /// verification — callers dedup/rate-limit first, Schnorr-verify last. + private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, + GatewayService.isValidGeohash(carrier.geohash), + let event = carrier.event(), + classify(event, cell: carrier.geohash) != nil else { + return nil + } + return event + } + + private func isFresh(_ event: NostrEvent) -> Bool { + abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds + } + + /// True when the event was signed by this device's own derived + /// rendezvous identity for the cell. Survives relaunches (unlike the + /// published-ID cache) because the derivation is deterministic; the + /// underlying identity cache makes this cheap. + private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool { + guard let identity = try? deriveIdentity?(cell) else { return false } + return identity.publicKeyHex.lowercased() == event.pubkey.lowercased() + } +} diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift new file mode 100644 index 00000000..0c7bdc9f --- /dev/null +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -0,0 +1,468 @@ +// +// GatewayService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Policy engine for gateway mode: an opt-in "share my internet with the +/// mesh" bridge. While the toggle is on, this device advertises the +/// `.gateway` capability bit, publishes signed geohash events deposited by +/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay +/// events onto the mesh (downlink) so mesh-only peers can take part in the +/// local geohash channel. Mesh-only peers need no toggle: their uplink +/// engages automatically when relays are unreachable and a gateway peer +/// exists. +/// +/// Threat model: +/// - Keys never leave the originating device. Mesh-only senders sign events +/// locally with their per-geohash ephemeral identity; the gateway carries +/// only the finished, signed event. +/// - The gateway cannot forge or alter events: every carried event is +/// Schnorr-verified here before it is published or rebroadcast, and again +/// independently by relays and receivers. +/// - Carried contents are public geohash chat, already plaintext on Nostr, +/// so the mesh carrier adds no confidentiality loss. +/// +/// Loop-prevention rules: +/// 1. An event learned from a `fromGateway` mesh broadcast is never +/// re-published to relays, never re-uplinked, and never rebroadcast +/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot +/// echo mesh-carried traffic back out. Mesh-level propagation of the +/// original broadcast packet is the TTL relay's job, not ours. +/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and +/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so +/// repeat deposits and relay echoes are absorbed. An event this gateway +/// itself uplinked (`publishedEventIDs`) is additionally never +/// downlink-rebroadcast: it originated on this mesh, so echoing it back +/// when our own relay subscription redelivers it would double BLE airtime +/// (the device-confirmed self-echo bug). +/// 3. Uplink is only attempted for locally composed events at the send site +/// (`GeohashSubscriptionManager.sendGeohash`); events received over the +/// carrier never re-enter the uplink path. This is a call-site convention; +/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in +/// `uplinkViaMesh` enforce it defensively and are unit-tested. +/// Rules 1 and 2 are enforced here and unit-tested. +/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE +/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events +/// against their own relay subscriptions via the Nostr event-ID cache in +/// `NostrInboundPipeline`. +/// +/// All dependencies are closure-injected (repo convention) so the policy +/// layer is unit-testable without relays or radios. +@MainActor +final class GatewayService: ObservableObject { + enum Limits { + /// Uplink deposits held while relays are unreachable (CourierStore-style + /// bounded mailbag: bounded total, bounded per depositor). + static let maxQueuedUplinks = 20 + static let maxQueuedUplinksPerDepositor = 5 + /// Uplink deposits accepted per depositor per minute. + static let uplinkEventsPerMinutePerDepositor = 10 + /// Downlink mesh rebroadcasts per minute — BLE airtime is precious. + /// Beyond the budget events queue (bounded, drop-oldest) and drain on + /// a scheduled timer once the window frees (also re-driven by the next + /// inbound relay event); a quiet channel does not strand its backlog. + static let downlinkEventsPerMinute = 30 + static let maxPendingDownlinks = 30 + /// Accepted clock skew for a carried ephemeral event; anything older + /// is stale replay the relays would drop anyway. + static let maxEventAgeSeconds: TimeInterval = 15 * 60 + /// Bounded loop-prevention ID caches (oldest evicted). + static let maxTrackedEventIDs = 512 + } + + struct QueuedUplink { + let depositor: PeerID + let geohash: String + let event: NostrEvent + } + + static let shared = GatewayService() + + /// The user toggle. While true this device advertises `.gateway` and + /// bridges mesh <-> Nostr for geohash channels. + @Published private(set) var isEnabled: Bool + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + /// Publishes a verified event to the geo relays for a geohash. + var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? + /// Broadcasts an encoded `fromGateway` carrier payload on the mesh. + var broadcastToMesh: (@MainActor (Data) -> Void)? + /// Sends an encoded `toGateway` carrier payload directed to a gateway + /// peer. Returns false when the transport could not accept it. + var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)? + /// Reachable mesh peers currently advertising the `.gateway` capability. + var availableGatewayPeers: (@MainActor () -> [PeerID])? + /// Whether any Nostr relay connection is currently working. + var relaysConnected: (@MainActor () -> Bool)? + /// The geohash channel the local user is viewing, if any. + var currentGeohash: (@MainActor () -> String?)? + /// Injects a verified carried event into the same inbound pipeline as + /// relay-received events (blocking, rate limits, dedup, rendering). + var injectInbound: (@MainActor (NostrEvent) -> Void)? + /// Fired on toggle changes (advertise/withdraw the capability bit and + /// force a re-announce). + var onEnabledChanged: (@MainActor (Bool) -> Void)? + /// Schedules a downlink-drain closure to run after a delay. Injected so + /// the drain timer is deterministic in tests; nil arms a real `Task`. + var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? + + // MARK: State + + /// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts. + private var meshBroadcastEventIDs: BoundedIDSet + /// Loop rule 2 (uplink): event IDs this gateway already published. + private var publishedEventIDs: BoundedIDSet + /// Loop rule 2 (downlink): event IDs this gateway already rebroadcast. + private var rebroadcastEventIDs: BoundedIDSet + + private(set) var queuedUplinks: [QueuedUplink] = [] + private var uplinkDepositTimes: [PeerID: [Date]] = [:] + private var downlinkSendTimes: [Date] = [] + private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = [] + /// True while a drain timer is armed, so a burst schedules at most one. + private var downlinkDrainScheduled = false + + private let defaults: UserDefaults + private let now: () -> Date + private static let enabledKey = "gateway.userEnabled" + + init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { + self.defaults = defaults + self.now = now + self.isEnabled = defaults.bool(forKey: Self.enabledKey) + self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + } + + // MARK: - Toggle + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + defaults.set(enabled, forKey: Self.enabledKey) + if !enabled { + queuedUplinks.removeAll() + pendingDownlinks.removeAll() + uplinkDepositTimes.removeAll() + } + SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session) + onEnabledChanged?(enabled) + } + + // MARK: - Mesh carrier ingress (both roles) + + /// Entry point for received `nostrCarrier` packets. `directedToUs` is + /// true for packets addressed to this device (uplink deposits); false + /// for broadcasts (downlink rebroadcasts from a gateway). + func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { + guard let carrier = NostrCarrierPacket.decode(payload) else { + SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session) + return + } + switch carrier.direction { + case .toGateway: + // Uplink deposits are directed; a broadcast toGateway is malformed. + guard directedToUs else { return } + handleUplinkDeposit(carrier, from: peerID) + case .fromGateway: + // Downlink rides broadcast only; a directed fromGateway is malformed. + guard !directedToUs else { return } + handleDownlinkBroadcast(carrier) + case .toBridge, .fromBridge: + // Mesh-bridge carriers are BridgeService territory; the ingress + // router dispatches them there. + return + } + } + + // MARK: - Uplink (gateway role: mesh peer -> internet) + + private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { + guard isEnabled else { return } + // Cheap structural checks first (parse, size, geohash, kind, #g tag, + // age) — no crypto — so junk and stale replays are dropped before we + // ever pay for a MainActor Schnorr verify. + guard let event = structurallyValidEvent(from: carrier) else { + SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) + return + } + // Dedup by the carried event ID BEFORE verification. Loop rule 1: a + // fromGateway-learned event is mesh-carried and must never be + // re-published. Loop rule 2: repeat deposits of an already handled + // event are absorbed. A replay of one valid deposit is short-circuited + // here without a per-packet signature verify. + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), + !queuedUplinks.contains(where: { $0.event.id == event.id }) else { + return + } + // Consume the per-depositor rate token BEFORE the expensive verify so + // a flood of distinct forged/junk deposits is bounded by cheap work, + // not by main-actor Schnorr verifications. + guard allowUplinkDeposit(from: depositor) else { + SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session) + return + } + // Only now pay for cryptographic verification; receivers verify again. + guard event.isValidSignature() else { + SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) + return + } + + let accepted: Bool + if relaysConnected?() ?? false { + publish(event, geohash: carrier.geohash) + accepted = true + } else { + accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event)) + } + + // Only render on our own timeline what we actually accepted for + // publish or queue: a quota-dropped deposit is never published and, + // being directed, no other peer will ever see it, so showing it would + // diverge our timeline permanently from what reached the channel. + if accepted, currentGeohash?() == carrier.geohash { + injectInbound?(event) + } + } + + /// Publish everything queued while relays were unreachable. Called when + /// relay connectivity comes back. + func flushQueuedUplinks() { + guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } + let queued = queuedUplinks + queuedUplinks.removeAll() + for item in queued where !publishedEventIDs.contains(item.event.id) { + publish(item.event, geohash: item.geohash) + } + } + + private func publish(_ event: NostrEvent, geohash: String) { + publishedEventIDs.insert(event.id) + publishToRelays?(event, geohash) + SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session) + } + + /// Returns true when the item was actually stored for later publish. + @discardableResult + private func enqueueUplink(_ item: QueuedUplink) -> Bool { + let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count + guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { + SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session) + return false + } + if queuedUplinks.count >= Limits.maxQueuedUplinks { + queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) + } + queuedUplinks.append(item) + return true + } + + private func allowUplinkDeposit(from depositor: PeerID) -> Bool { + let cutoff = now().addingTimeInterval(-60) + var times = uplinkDepositTimes[depositor, default: []] + times.removeAll { $0 < cutoff } + guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { + uplinkDepositTimes[depositor] = times + return false + } + times.append(now()) + uplinkDepositTimes[depositor] = times + // Bound the tracker itself against a churn of spoofed depositors. + if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { + uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } } + } + return true + } + + // MARK: - Downlink (gateway role: internet -> mesh) + + /// Called for every event the gateway's own geohash-channel subscription + /// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on + /// the mesh, within the airtime budget. + func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) { + guard isEnabled, broadcastToMesh != nil else { return } + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Freshness + geohash gate BEFORE spending any budget. A channel + // (re)subscribe backfills up to an hour of history (limit 200), but + // every receiver's `validatedEvent` drops anything older than the + // same window — so rebroadcasting backfill would burn the whole + // per-minute budget on events no mesh peer accepts. Also require the + // event's own `#g` tag to match the carrier geohash. + guard isFresh(event), + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { + return + } + // Loop rule 1: never rebroadcast mesh-carried events back onto the + // mesh. Loop rule 2 (self-echo): never rebroadcast an event this + // gateway itself uplinked (`publishedEventIDs`) — it originated on this + // very mesh, so our own relay subscription echoing it back must not + // double the BLE airtime by pushing it out again. Loop rule 2 + // (downlink): rebroadcast each genuine inbound relay event at most once + // — but mark only AFTER it is actually sent (in `drainPendingDownlinks`), + // so an event dropped by the queue overflow stays retryable on relay + // redelivery. Guard against a redelivery re-queueing an event that is + // still waiting to be sent. + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id), + !pendingDownlinks.contains(where: { $0.event.id == event.id }) else { + return + } + // Verify before spending BLE airtime; receivers verify again. + guard event.isValidSignature() else { return } + + pendingDownlinks.append((event, geohash)) + if pendingDownlinks.count > Limits.maxPendingDownlinks { + // Bandwidth guard: drop-oldest — fresher chat is worth more. The + // dropped event is not yet in `rebroadcastEventIDs`, so a later + // relay redelivery can still carry it. + pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) + } + drainPendingDownlinks() + } + + private func drainPendingDownlinks() { + let cutoff = now().addingTimeInterval(-60) + downlinkSendTimes.removeAll { $0 < cutoff } + while !pendingDownlinks.isEmpty, + downlinkSendTimes.count < Limits.downlinkEventsPerMinute { + let (event, geohash) = pendingDownlinks.removeFirst() + // A queued event may have aged past the window while it waited; + // don't burn airtime on what receivers would now drop. + guard isFresh(event) else { continue } + guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { continue } + broadcastToMesh?(payload) + // Mark-after-send: only now is the relay event definitively + // rebroadcast (loop rule 2). + rebroadcastEventIDs.insert(event.id) + downlinkSendTimes.append(now()) + } + // Budget exhausted with events still queued: arm a timer to drain when + // the window frees, instead of stranding them until the next inbound + // relay event (which may never come on a channel that went quiet). + scheduleDownlinkDrainIfNeeded() + } + + /// Arms a single timer to drain the backlog once the per-minute window + /// frees. No-op when nothing is pending or a drain is already scheduled. + private func scheduleDownlinkDrainIfNeeded() { + guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } + // The window frees when the oldest recorded send ages out of 60s. + let oldest = downlinkSendTimes.min() ?? now() + let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) + downlinkDrainScheduled = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.downlinkDrainScheduled = false + self.drainPendingDownlinks() + } + if let scheduleDrainTimer { + scheduleDrainTimer(delay, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Downlink (receiver role: carried event arrives over mesh) + + private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { + guard let event = validatedEvent(from: carrier) else { return } + // Mark only AFTER signature verification, so a forged copy carrying a + // real event's ID cannot poison the never-republish set, and use the + // marking as dedup: the same broadcast relayed along several mesh + // paths injects once (the pipeline's Nostr event-ID cache additionally + // dedups against our own relay subscription). + guard meshBroadcastEventIDs.insert(event.id) else { return } + // Only inject events for the channel we're viewing; the inbound + // pipeline files public messages under the current geohash. + guard currentGeohash?() == carrier.geohash else { return } + injectInbound?(event) + } + + // MARK: - Uplink (sender role: mesh-only peer with no relays) + + /// Hands a locally signed event to a mesh gateway peer when we have no + /// working relay connection. Returns true when the event was sent. + /// + /// v1 is deliberately fire-and-forget: no gateway ack. The event also + /// stays in `NostrRelayManager`'s own pending queue, so if our internet + /// comes back the relays dedup the duplicate publish by event ID. + /// + /// Loop rule 3: call sites only pass freshly composed events (see + /// `GeohashSubscriptionManager.sendGeohash`); received carrier events + /// never reach this path, and the mesh-carried guard below backstops it. + func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool { + if relaysConnected?() ?? true { return false } + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id) else { + return false + } + // A single gateway is enough — relays fan out from there, and BLE + // airtime is precious. + guard let gateway = availableGatewayPeers?().first else { return false } + guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { + return false + } + guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } + SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session) + return true + } + + // MARK: - Validation + + /// Structural and cryptographic checks every carried event must pass + /// before a gateway publishes it or a receiver displays it. Ordered + /// cheap-first; Schnorr verification runs last. + private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard let event = structurallyValidEvent(from: carrier), + event.isValidSignature() else { + return nil + } + return event + } + + /// The cheap half of `validatedEvent`: parse + size + geohash + kind + + /// `#g` tag + freshness, with NO signature verification. Callers that can + /// dedup or rate-limit on the carried ID run this first so the expensive + /// Schnorr verify is reached only for events that survive the cheap gates. + private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, + Self.isValidGeohash(carrier.geohash), + let event = carrier.event(), + event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue, + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }), + isFresh(event) else { + return nil + } + return event + } + + /// True when `event.created_at` is within the accepted clock skew — the + /// SAME freshness window receivers enforce, so a gateway never spends + /// airtime on events every receiver would drop as stale. + private func isFresh(_ event: NostrEvent) -> Bool { + abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds + } + + static func isValidGeohash(_ geohash: String) -> Bool { + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count) + && geohash.allSatisfy { allowed.contains($0) } + } +} diff --git a/bitchat/Services/GeohashChatActivityTracker.swift b/bitchat/Services/GeohashChatActivityTracker.swift new file mode 100644 index 00000000..def9d59f --- /dev/null +++ b/bitchat/Services/GeohashChatActivityTracker.swift @@ -0,0 +1,116 @@ +// +// GeohashChatActivityTracker.swift +// bitchat +// +// Tracks actual chat-message activity per sampled geohash so the empty mesh +// timeline can point at a nearby channel where a conversation is happening — +// not merely where participants are present. +// This is free and unencumbered software released into the public domain. +// + +import Foundation + +/// A recent chat message observed in a sampled geohash channel. +struct GeohashChatPreview: Equatable, Sendable { + let senderName: String + let content: String + let timestamp: Date +} + +/// The liveliest nearby conversation, resolved against the user's regional +/// channels. +struct NearbyConversation: Equatable, Sendable { + let channel: GeohashChannel + /// Chat messages seen within the activity window. + let messageCount: Int + let lastMessage: GeohashChatPreview +} + +/// Records kind-20000 chat events seen by the background geohash sampling +/// subscriptions (blocked and self senders are filtered by the caller) and +/// answers "where nearby is a conversation actually happening?". +@MainActor +final class GeohashChatActivityTracker: ObservableObject { + static let shared = GeohashChatActivityTracker() + + /// How far back a message still counts as "a conversation is happening". + private let window: TimeInterval + /// Per-geohash recent message timestamps (pruned to the window). + private var messageTimes: [String: [Date]] = [:] + /// Per-geohash newest message preview. + private var lastMessages: [String: GeohashChatPreview] = [:] + private let now: () -> Date + + init( + window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds, + now: @escaping () -> Date = { Date() } + ) { + self.window = window + self.now = now + } + + func recordChatMessage( + geohash: String, + senderName: String, + content: String, + timestamp: Date + ) { + let gh = geohash.lowercased() + let clamped = min(timestamp, now()) + guard now().timeIntervalSince(clamped) < window else { return } + + var times = messageTimes[gh] ?? [] + times.append(clamped) + messageTimes[gh] = prune(times) + + if let existing = lastMessages[gh], existing.timestamp > clamped { + // Keep the newer preview. + } else { + lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped) + } + objectWillChange.send() + } + + /// Messages seen in the window for one geohash. + func messageCount(for geohash: String) -> Int { + prune(messageTimes[geohash.lowercased()] ?? []).count + } + + func lastMessage(for geohash: String) -> GeohashChatPreview? { + let gh = geohash.lowercased() + guard messageCount(for: gh) > 0 else { return nil } + return lastMessages[gh] + } + + /// The busiest channel with at least one chat message in the window. + /// Ties go to the more local (higher-precision) channel, so a lone + /// message on your block beats a lone message across the region. + func mostActiveConversation(among channels: [GeohashChannel]) -> NearbyConversation? { + var best: NearbyConversation? + for channel in channels { + let count = messageCount(for: channel.geohash) + guard count > 0, let last = lastMessage(for: channel.geohash) else { continue } + let candidate = NearbyConversation(channel: channel, messageCount: count, lastMessage: last) + if let current = best { + let better = count > current.messageCount + || (count == current.messageCount + && channel.level.precision > current.channel.level.precision) + if better { best = candidate } + } else { + best = candidate + } + } + return best + } + + func clear() { + messageTimes.removeAll() + lastMessages.removeAll() + objectWillChange.send() + } + + private func prune(_ times: [Date]) -> [Date] { + let cutoff = now().addingTimeInterval(-window) + return times.filter { $0 >= cutoff } + } +} diff --git a/bitchat/Services/GeohashParticipantTracker.swift b/bitchat/Services/GeohashParticipantTracker.swift index 0e46ad34..be1ea35e 100644 --- a/bitchat/Services/GeohashParticipantTracker.swift +++ b/bitchat/Services/GeohashParticipantTracker.swift @@ -9,12 +9,12 @@ import Foundation /// Represents a participant in a geohash channel -public struct GeoPerson: Identifiable, Equatable, Sendable { - public let id: String // pubkey hex (lowercased) - public let displayName: String - public let lastSeen: Date +struct GeoPerson: Identifiable, Equatable, Sendable { + let id: String // pubkey hex (lowercased) + let displayName: String + let lastSeen: Date - public init(id: String, displayName: String, lastSeen: Date) { + init(id: String, displayName: String, lastSeen: Date) { self.id = id self.displayName = displayName self.lastSeen = lastSeen @@ -23,7 +23,7 @@ public struct GeoPerson: Identifiable, Equatable, Sendable { /// Protocol for resolving display names and checking block status @MainActor -public protocol GeohashParticipantContext: AnyObject { +protocol GeohashParticipantContext: AnyObject { /// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4") func displayNameForPubkey(_ pubkeyHex: String) -> String /// Returns true if the pubkey is blocked @@ -32,16 +32,16 @@ public protocol GeohashParticipantContext: AnyObject { /// Tracks participants across multiple geohash channels @MainActor -public final class GeohashParticipantTracker: ObservableObject { +final class GeohashParticipantTracker: ObservableObject { /// Activity cutoff duration (defaults to 5 minutes) - public let activityCutoff: TimeInterval + let activityCutoff: TimeInterval /// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]] private var participants: [String: [String: Date]] = [:] /// Currently visible people for the active geohash - @Published public private(set) var visiblePeople: [GeoPerson] = [] + @Published private(set) var visiblePeople: [GeoPerson] = [] /// The currently active geohash (if any) private var activeGeohash: String? @@ -52,17 +52,17 @@ public final class GeohashParticipantTracker: ObservableObject { /// Timer for periodic refresh private var refreshTimer: Timer? - public init(activityCutoff: TimeInterval = -300) { // default 5 minutes + init(activityCutoff: TimeInterval = -300) { // default 5 minutes self.activityCutoff = activityCutoff } /// Configure with a context provider - public func configure(context: GeohashParticipantContext) { + func configure(context: GeohashParticipantContext) { self.context = context } /// Set the currently active geohash - public func setActiveGeohash(_ geohash: String?) { + func setActiveGeohash(_ geohash: String?) { activeGeohash = geohash if geohash == nil { visiblePeople = [] @@ -72,13 +72,13 @@ public final class GeohashParticipantTracker: ObservableObject { } /// Record activity from a participant in the current active geohash - public func recordParticipant(pubkeyHex: String) { + func recordParticipant(pubkeyHex: String) { guard let gh = activeGeohash else { return } recordParticipant(pubkeyHex: pubkeyHex, geohash: gh) } /// Record activity from a participant in a specific geohash - public func recordParticipant(pubkeyHex: String, geohash: String) { + func recordParticipant(pubkeyHex: String, geohash: String) { let key = pubkeyHex.lowercased() var map = participants[geohash] ?? [:] map[key] = Date() @@ -94,7 +94,7 @@ public final class GeohashParticipantTracker: ObservableObject { } /// Remove a participant from all geohashes (used when blocking) - public func removeParticipant(pubkeyHex: String) { + func removeParticipant(pubkeyHex: String) { let key = pubkeyHex.lowercased() for (gh, var map) in participants { map.removeValue(forKey: key) @@ -104,14 +104,14 @@ public final class GeohashParticipantTracker: ObservableObject { } /// Get participant count for a specific geohash - public func participantCount(for geohash: String) -> Int { + func participantCount(for geohash: String) -> Int { let cutoff = Date().addingTimeInterval(activityCutoff) let map = participants[geohash] ?? [:] return map.values.filter { $0 >= cutoff }.count } /// Get the visible people list for the active geohash (read-only query) - public func getVisiblePeople() -> [GeoPerson] { + func getVisiblePeople() -> [GeoPerson] { guard let gh = activeGeohash, let context = context else { return [] } let cutoff = Date().addingTimeInterval(activityCutoff) let map = (participants[gh] ?? [:]) @@ -126,12 +126,12 @@ public final class GeohashParticipantTracker: ObservableObject { } /// Refresh the visible people list - public func refresh() { + func refresh() { visiblePeople = getVisiblePeople() } /// Start the periodic refresh timer - public func startRefreshTimer(interval: TimeInterval = 30.0) { + func startRefreshTimer(interval: TimeInterval = 30.0) { stopRefreshTimer() refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor in @@ -141,19 +141,19 @@ public final class GeohashParticipantTracker: ObservableObject { } /// Stop the periodic refresh timer - public func stopRefreshTimer() { + func stopRefreshTimer() { refreshTimer?.invalidate() refreshTimer = nil } /// Clear all participant data - public func clear() { + func clear() { participants.removeAll() visiblePeople = [] } /// Clear participant data for a specific geohash - public func clear(geohash: String) { + func clear(geohash: String) { participants.removeValue(forKey: geohash) if activeGeohash == geohash { visiblePeople = [] diff --git a/bitchat/Services/Groups/GroupProtocol.swift b/bitchat/Services/Groups/GroupProtocol.swift new file mode 100644 index 00000000..8f4a09d1 --- /dev/null +++ b/bitchat/Services/Groups/GroupProtocol.swift @@ -0,0 +1,568 @@ +// +// GroupProtocol.swift +// bitchat +// +// Wire formats and crypto for private groups: creator-signed group state +// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages +// broadcast as MessageType.groupMessage (0x25). +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation + +// MARK: - Models + +/// A member of a private group as pinned in the creator-signed roster. +struct GroupMember: Codable, Equatable { + /// SHA-256 fingerprint (64 hex chars) of the member's Noise static key. + let fingerprint: String + /// The member's Ed25519 signing public key (32 bytes, from their announce). + let signingKey: Data + /// Nickname at invite time; display fallback when the peer is offline. + var nickname: String +} + +/// Creator-managed encrypted group. Metadata only — the symmetric key lives +/// in the keychain (see `GroupStore`). +struct BitchatGroup: Codable, Equatable { + static let maxMembers = 16 + static let groupIDLength = 16 + static let keyLength = 32 + + /// 16 random bytes; travels in cleartext on group message packets so + /// relays can dedup/filter without membership. + let groupID: Data + var name: String + /// Bumps on every key rotation; messages are bound to the epoch they + /// were sealed under. + var epoch: UInt32 + var members: [GroupMember] + /// Fingerprint of the creator — the only identity allowed to sign group + /// state (invites, key updates) in v1. + let creatorFingerprint: String + + /// Virtual conversation ID this group's chat is keyed under. + var peerID: PeerID { PeerID(groupID: groupID) } + + var creator: GroupMember? { + members.first { $0.fingerprint == creatorFingerprint } + } + + func isMember(fingerprint: String) -> Bool { + members.contains { $0.fingerprint == fingerprint } + } + + func member(withSigningKey signingKey: Data) -> GroupMember? { + members.first { $0.signingKey == signingKey } + } +} + +// MARK: - TLV helpers + +enum GroupTLVError: Error, Equatable { + /// A TLV value exceeded the 16-bit length field. Encoding fails instead + /// of silently truncating (which would ship a value the receiver drops). + case valueTooLong +} + +private enum GroupTLV { + /// Appends a (type, 16-bit length, value) triple. Throws rather than + /// truncating when `value` does not fit the 16-bit length field, so an + /// oversize field surfaces a send failure instead of a silently truncated + /// blob the recipient rejects during decrypt/verify. + static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws { + guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong } + out.append(type) + let length = UInt16(value.count) + out.append(UInt8((length >> 8) & 0xFF)) + out.append(UInt8(length & 0xFF)) + out.append(value) + } + + /// Iterates (type, value) pairs; returns nil on malformed framing. + static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? { + var fields: [(UInt8, Data)] = [] + var offset = data.startIndex + while offset < data.endIndex { + guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil } + let type = data[offset] + let high = Int(data[data.index(offset, offsetBy: 1)]) + let low = Int(data[data.index(offset, offsetBy: 2)]) + let length = (high << 8) | low + let valueStart = data.index(offset, offsetBy: 3) + guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } + let valueEnd = data.index(valueStart, offsetBy: length) + fields.append((type, Data(data[valueStart.. Data { + var bigEndian = epoch.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func epoch(from data: Data) -> UInt32? { + guard data.count == 4 else { return nil } + return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } + + static func timestampData(_ timestampMs: UInt64) -> Data { + var bigEndian = timestampMs.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func timestamp(from data: Data) -> UInt64? { + guard data.count == 8 else { return nil } + return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + } +} + +// MARK: - Roster wire form + +enum GroupRosterCoding { + private static let fingerprintLength = 32 + private static let signingKeyLength = 32 + private static let maxNicknameBytes = 64 + + /// Deterministic roster blob: count byte, then per member the raw 32-byte + /// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname. + /// The creator signature covers the SHA-256 of these exact bytes. + static func encode(_ members: [GroupMember]) -> Data? { + guard members.count <= BitchatGroup.maxMembers else { return nil } + var out = Data([UInt8(members.count)]) + for member in members { + guard let fingerprintData = Data(hexString: member.fingerprint), + fingerprintData.count == fingerprintLength, + member.signingKey.count == signingKeyLength else { return nil } + out.append(fingerprintData) + out.append(member.signingKey) + // Truncate on a Character boundary so the byte prefix is always + // valid UTF-8; a raw byte-prefix could split a multi-byte scalar + // and make the whole signed roster undecodable on the recipient. + let nickname = truncatedNicknameBytes(member.nickname) + out.append(UInt8(nickname.count)) + out.append(nickname) + } + return out + } + + static func decode(_ data: Data) -> [GroupMember]? { + guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil } + var members: [GroupMember] = [] + var offset = data.index(after: data.startIndex) + for _ in 0..= fixed else { return nil } + let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength) + let fingerprint = Data(data[offset..= nickLength else { return nil } + let nickEnd = data.index(nickStart, offsetBy: nickLength) + guard let nickname = String(data: Data(data[nickStart.. Data { + var candidate = nickname + while Data(candidate.utf8).count > maxNicknameBytes { + candidate.removeLast() + } + return Data(candidate.utf8) + } +} + +// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise) + +/// Creator-signed group state. The same wire form serves invites (0x06) and +/// key updates (0x07); receivers verify the creator signature — computed over +/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) — +/// against the creator's signing key pinned in the roster, and require the +/// Noise session peer to BE the creator before accepting any state. +struct GroupStatePayload: Equatable { + let groupID: Data + let name: String + /// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`. + let key: Data + let epoch: UInt32 + let members: [GroupMember] + let creatorFingerprint: String + /// Ed25519 signature by the creator. + let signature: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case name = 0x02 + case key = 0x03 + case epoch = 0x04 + case roster = 0x05 + case creatorFingerprint = 0x06 + case signature = 0x07 + } + + static let signingDomain = Data("bitchat-group-v1".utf8) + + /// The bytes the creator signs. Binding the key, roster, and name by hash + /// keeps the signed content fixed-size. The name is covered so a relay + /// that caches/replays a signed state (e.g. store-and-forward) cannot swap + /// the display name while keeping a valid creator signature. + static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data { + var content = signingDomain + content.append(groupID) + content.append(GroupTLV.epochData(epoch)) + content.append(key.sha256Hash()) + content.append(rosterBlob.sha256Hash()) + content.append(Data(name.utf8).sha256Hash()) + return content + } + + /// Builds a signed state payload. Returns nil when the roster cannot be + /// encoded (over cap, malformed member) or signing fails. + static func makeSigned( + group: BitchatGroup, + key: Data, + sign: (Data) -> Data? + ) -> GroupStatePayload? { + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil } + let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name) + guard let signature = sign(content) else { return nil } + return GroupStatePayload( + groupID: group.groupID, + name: group.name, + key: key, + epoch: group.epoch, + members: group.members, + creatorFingerprint: group.creatorFingerprint, + signature: signature + ) + } + + func encode() -> Data? { + guard let rosterBlob = GroupRosterCoding.encode(members), + let fingerprintData = Data(hexString: creatorFingerprint), + fingerprintData.count == 32 else { return nil } + var out = Data() + do { + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out) + try GroupTLV.put(FieldType.key.rawValue, key, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out) + try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out) + try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out) + } catch { + return nil + } + return out + } + + static func decode(_ data: Data) -> GroupStatePayload? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var name: String? + var key: Data? + var epoch: UInt32? + var rosterBlob: Data? + var members: [GroupMember]? + var creatorFingerprint: String? + var signature: Data? + + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .name: + name = String(data: value, encoding: .utf8) + case .key where value.count == BitchatGroup.keyLength: + key = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .roster: + rosterBlob = value + members = GroupRosterCoding.decode(value) + case .creatorFingerprint where value.count == 32: + creatorFingerprint = value.hexEncodedString() + case .signature where value.count == 64: + signature = value + default: + break // forward compatible; ignore unknown TLVs + } + } + + guard let groupID, let name, let key, let epoch, + rosterBlob != nil, let members, !members.isEmpty, + let creatorFingerprint, let signature else { return nil } + return GroupStatePayload( + groupID: groupID, + name: name, + key: key, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint, + signature: signature + ) + } + + /// Verifies the creator signature against the creator's signing key + /// pinned in the roster, and that the creator is actually in the roster. + func verifyCreatorSignature() -> Bool { + guard members.count <= BitchatGroup.maxMembers, + let creator = members.first(where: { $0.fingerprint == creatorFingerprint }), + let rosterBlob = GroupRosterCoding.encode(members) else { return false } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name) + return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey) + } + + var asGroup: BitchatGroup { + BitchatGroup( + groupID: groupID, + name: name, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint + ) + } +} + +// MARK: - Group message envelope (MessageType 0x25 payload) + +/// Cleartext framing of a group message broadcast. Only the group ID, epoch, +/// and nonce are visible to relays; everything about the message — sender, +/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext. +struct GroupMessageEnvelope: Equatable { + let groupID: Data + let epoch: UInt32 + let nonce: Data + /// ChaChaPoly ciphertext || 16-byte tag. + let ciphertext: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case epoch = 0x02 + case nonce = 0x03 + case ciphertext = 0x04 + } + + func encode() throws -> Data { + var out = Data() + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out) + try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out) + return out + } + + static func decode(_ data: Data) -> GroupMessageEnvelope? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var epoch: UInt32? + var nonce: Data? + var ciphertext: Data? + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .nonce where value.count == 12: + nonce = value + case .ciphertext where !value.isEmpty: + ciphertext = value + default: + break + } + } + guard let groupID, let epoch, let nonce, let ciphertext else { return nil } + return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext) + } +} + +/// Decrypted, signature-verified inner content of a group message. +struct GroupMessagePlaintext: Equatable { + let messageID: String + let senderSigningKey: Data + let senderNickname: String + let timestampMs: UInt64 + let content: String +} + +// MARK: - Crypto + +enum GroupCryptoError: Error, Equatable { + case malformedPayload + case signingFailed + case sealFailed + case decryptionFailed + case badSenderSignature +} + +enum GroupCrypto { + static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8) + + private enum InnerField: UInt8 { + case messageID = 0x01 + case senderSigningKey = 0x02 + case senderNickname = 0x03 + case timestamp = 0x04 + case content = 0x05 + case signature = 0x06 + } + + /// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content. + /// Covering the epoch stops a current member from re-sealing another + /// member's decrypted inner bytes under a later epoch key (the signature + /// would no longer verify at the new epoch). + static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data { + var data = messageSigningDomain + data.append(groupID) + data.append(GroupTLV.epochData(epoch)) + data.append(Data(messageID.utf8)) + data.append(GroupTLV.timestampData(timestampMs)) + data.append(Data(content.utf8)) + return data + } + + static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false } + return key.isValidSignature(signature, for: data) + } + + /// Seals a group message: builds the signed inner TLV and encrypts it with + /// the epoch key. The cleartext group ID and epoch are bound into the AEAD + /// as additional data so ciphertext cannot be replayed across groups or + /// epochs. Returns the encoded 0x25 packet payload. + static func sealMessage( + content: String, + messageID: String, + senderNickname: String, + senderSigningKey: Data, + timestampMs: UInt64, + groupID: Data, + epoch: UInt32, + key: Data, + sign: (Data) -> Data? + ) throws -> Data { + let signingContent = messageSigningContent( + groupID: groupID, + epoch: epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard let signature = sign(signingContent), signature.count == 64 else { + throw GroupCryptoError.signingFailed + } + + var inner = Data() + try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner) + try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner) + try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner) + try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner) + try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner) + try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner) + + do { + let symmetricKey = SymmetricKey(data: key) + var aad = groupID + aad.append(GroupTLV.epochData(epoch)) + let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad) + var ciphertext = sealed.ciphertext + ciphertext.append(sealed.tag) + let envelope = GroupMessageEnvelope( + groupID: groupID, + epoch: epoch, + nonce: Data(sealed.nonce), + ciphertext: ciphertext + ) + return try envelope.encode() + } catch { + throw GroupCryptoError.sealFailed + } + } + + /// Opens a group message envelope with the epoch key: decrypts, parses the + /// inner TLV, and verifies the sender's Ed25519 signature. Roster + /// membership of the sender is the CALLER's check — this function only + /// proves the payload was authored by `senderSigningKey`. + static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext { + let inner: Data + do { + let symmetricKey = SymmetricKey(data: key) + var aad = envelope.groupID + aad.append(GroupTLV.epochData(envelope.epoch)) + let nonce = try ChaChaPoly.Nonce(data: envelope.nonce) + guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed } + let tag = envelope.ciphertext.suffix(16) + let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16) + let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag) + inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad) + } catch { + throw GroupCryptoError.decryptionFailed + } + + guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload } + var messageID: String? + var senderSigningKey: Data? + var senderNickname: String? + var timestampMs: UInt64? + var content: String? + var signature: Data? + for (type, value) in fields { + switch InnerField(rawValue: type) { + case .messageID: + messageID = String(data: value, encoding: .utf8) + case .senderSigningKey where value.count == 32: + senderSigningKey = value + case .senderNickname: + senderNickname = String(data: value, encoding: .utf8) + case .timestamp: + timestampMs = GroupTLV.timestamp(from: value) + case .content: + content = String(data: value, encoding: .utf8) + case .signature where value.count == 64: + signature = value + default: + break + } + } + guard let messageID, !messageID.isEmpty, + let senderSigningKey, + let senderNickname, + let timestampMs, + let content, + let signature else { throw GroupCryptoError.malformedPayload } + + let signingContent = messageSigningContent( + groupID: envelope.groupID, + epoch: envelope.epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else { + throw GroupCryptoError.badSenderSignature + } + + return GroupMessagePlaintext( + messageID: messageID, + senderSigningKey: senderSigningKey, + senderNickname: senderNickname, + timestampMs: timestampMs, + content: content + ) + } +} diff --git a/bitchat/Services/Groups/GroupStore.swift b/bitchat/Services/Groups/GroupStore.swift new file mode 100644 index 00000000..3a45dcb7 --- /dev/null +++ b/bitchat/Services/Groups/GroupStore.swift @@ -0,0 +1,194 @@ +// +// GroupStore.swift +// bitchat +// +// Persistence for private groups: symmetric keys in the keychain, metadata +// (roster, name, epoch) as protected JSON in Application Support. Both are +// dropped by the panic wipe. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation +import Security + +@MainActor +final class GroupStore: ObservableObject { + /// All groups this device is a member of, in creation/join order. + @Published private(set) var groups: [BitchatGroup] = [] + + private let keychain: KeychainManagerProtocol + private let fileURL: URL? + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) { + self.keychain = keychain + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Reads + + func group(withID groupID: Data) -> BitchatGroup? { + groups.first { $0.groupID == groupID } + } + + func group(for peerID: PeerID) -> BitchatGroup? { + guard let groupID = peerID.groupIDData else { return nil } + return group(withID: groupID) + } + + /// Current-epoch symmetric key for the group, from the keychain. + func key(forGroupID groupID: Data) -> Data? { + keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID)) + } + + // MARK: - Mutations + + /// Creates a new group with a random 16-byte ID and 32-byte key at + /// epoch 1, with the creator as sole member. Returns nil when key + /// generation or persistence fails. + func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? { + guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength), + let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + let group = BitchatGroup( + groupID: groupID, + name: name, + epoch: 1, + members: [creator], + creatorFingerprint: creator.fingerprint + ) + guard upsert(group, key: key) else { return nil } + return group + } + + /// Inserts or replaces a group and its current key. Rejects rosters over + /// the hard cap or groups whose creator is missing from the roster. + @discardableResult + func upsert(_ group: BitchatGroup, key: Data) -> Bool { + guard group.groupID.count == BitchatGroup.groupIDLength, + key.count == BitchatGroup.keyLength, + !group.members.isEmpty, + group.members.count <= BitchatGroup.maxMembers, + group.creator != nil else { return false } + guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else { + SecureLogger.error("Failed to store group key in keychain", category: .security) + return false + } + if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) { + groups[index] = group + } else { + groups.append(group) + } + persist() + return true + } + + /// Updates the roster of an existing group without changing key or epoch + /// (creator-side invite). Enforces the member cap. + @discardableResult + func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? { + guard let index = groups.firstIndex(where: { $0.groupID == groupID }), + !members.isEmpty, + members.count <= BitchatGroup.maxMembers, + members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil } + groups[index].members = members + persist() + return groups[index] + } + + /// Rotates the group key (creator-side removal/rotation): new random key, + /// epoch + 1, and the given roster. Returns the updated group and new key. + func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? { + guard let existing = group(withID: groupID), + let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + var rotated = existing + rotated.epoch = existing.epoch &+ 1 + rotated.members = members + guard upsert(rotated, key: newKey) else { return nil } + return (rotated, newKey) + } + + func removeGroup(withID groupID: Data) { + groups.removeAll { $0.groupID == groupID } + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID)) + persist() + } + + /// Panic wipe: drop all group keys and metadata from memory and disk. + /// (The panic flow also nukes the whole keychain; deleting per-group keys + /// here keeps the store safe to wipe on its own.) + func wipe() { + for group in groups { + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID)) + } + groups.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + + // MARK: - Internals + + private static func keychainKey(for groupID: Data) -> String { + "groupKey-\(groupID.hexEncodedString())" + } + + private static func randomBytes(_ count: Int) -> Data? { + var bytes = Data(count: count) + let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in + guard let baseAddress = buffer.baseAddress else { return errSecParam } + return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress) + } + return status == errSecSuccess ? bytes : nil + } + + private func persist() { + guard let fileURL else { return } + do { + if groups.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(groups) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist group store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else { + return + } + // Only groups whose key survived in the keychain are usable. + groups = stored.filter { key(forGroupID: $0.groupID) != nil } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("groups", isDirectory: true) + .appendingPathComponent("groups.json") + } +} diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index e469f98d..98f5fa9e 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -12,10 +12,79 @@ import Foundation import Security final class KeychainManager: KeychainManagerProtocol { + /// Default keychain for components that construct their own rather than + /// having one injected. Under test this is an in-memory keychain: the + /// xctest runner's code signature changes every build, so any read of a + /// real login-keychain item triggers a macOS password prompt that + /// "Always Allow" can never satisfy — and tests must never read or + /// mutate the developer's real keychain (`SecItemCopyMatching` can also + /// hang in test environments). Production behavior is unchanged. + static func makeDefault() -> KeychainManagerProtocol { + // PreviewKeychainManager lives in _PreviewHelpers, a development + // asset excluded from archive builds — release code must not + // reference it. Tests always run Debug, so the guard is lossless. + #if DEBUG + if TestEnvironment.isRunningTests { return sharedTestKeychain } + #endif + return KeychainManager() + } + + #if DEBUG + /// One store per process, mirroring the real keychain: separate + /// default-constructed components (e.g. two NostrIdentityBridge + /// instances in BoardManager's publish and delete paths) must see each + /// other's writes, or they would derive different Nostr identities + /// under test. + private static let sharedTestKeychain = PreviewKeychainManager() + #endif + // Use consistent service name for all keychain items private let service = BitchatApp.bundleID private let appGroup = "group.\(BitchatApp.bundleID)" - + + // AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the + // device locked (identity-cache saves failed with -25308 throughout + // locked-phone testing), and a wake-on-proximity relaunch via BLE state + // restoration must be able to read the noise keys before the user + // unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly). + private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock + + init() { + #if os(iOS) + migrateAccessibilityIfNeeded() + #endif + } + + #if os(iOS) + /// One-time upgrade of items created under WhenUnlocked. New saves get + /// the right class on their own (saves are delete-then-add), but the + /// long-lived identity keys are written once and would otherwise stay + /// unreadable while the device is locked. + private func migrateAccessibilityIfNeeded() { + let flag = "keychain.accessibility.afterFirstUnlock.migrated" + guard !UserDefaults.standard.bool(forKey: flag) else { return } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + let update: [String: Any] = [ + kSecAttrAccessible as String: Self.itemAccessibility + ] + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + switch status { + case errSecSuccess, errSecItemNotFound: + // Nothing to migrate on a fresh install; both are terminal. + UserDefaults.standard.set(true, forKey: flag) + SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain) + default: + // Likely errSecInteractionNotAllowed (relaunched while locked) — + // leave the flag unset so the next launch retries. + SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain) + } + } + #endif + // MARK: - Identity Keys func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { @@ -62,7 +131,7 @@ final class KeychainManager: KeychainManagerProtocol { kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrService as String: service, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + kSecAttrAccessible as String: Self.itemAccessibility, kSecAttrLabel as String: "bitchat-\(key)" ] #if os(macOS) @@ -212,11 +281,6 @@ final class KeychainManager: KeychainManagerProtocol { // MARK: - Generic Operations - private func save(_ value: String, forKey key: String) -> Bool { - guard let data = value.data(using: .utf8) else { return false } - return saveData(data, forKey: key) - } - private func saveData(_ data: Data, forKey key: String) -> Bool { // Delete any existing item first to ensure clean state _ = delete(forKey: key) @@ -227,7 +291,7 @@ final class KeychainManager: KeychainManagerProtocol { kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrService as String: service, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + kSecAttrAccessible as String: Self.itemAccessibility, kSecAttrLabel as String: "bitchat-\(key)" ] #if os(macOS) @@ -262,11 +326,6 @@ final class KeychainManager: KeychainManagerProtocol { return false } - private func retrieve(forKey key: String) -> String? { - guard let data = retrieveData(forKey: key) else { return nil } - return String(data: data, encoding: .utf8) - } - private func retrieveData(forKey key: String) -> Data? { // Base query let base: [String: Any] = [ @@ -322,22 +381,7 @@ final class KeychainManager: KeychainManagerProtocol { } // MARK: - Cleanup - - func deleteAllPasswords() -> Bool { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword - ] - - // Add service if not empty - if !service.isEmpty { - query[kSecAttrService as String] = service - } - - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound - } - - + // Delete ALL keychain data for panic mode func deleteAllKeychainData() -> Bool { SecureLogger.warning("Panic mode - deleting all keychain data", category: .security) @@ -498,6 +542,15 @@ final class KeychainManager: KeychainManagerProtocol { /// Load data from a custom service func load(key: String, service customService: String) -> Data? { + guard case .success(let data) = loadWithResult(key: key, service: customService) else { + return nil + } + return data + } + + /// Load custom-service data without collapsing `itemNotFound` and + /// protected-data/keychain failures into the same nil result. + func loadWithResult(key: String, service customService: String) -> KeychainReadResult { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, @@ -507,9 +560,7 @@ final class KeychainManager: KeychainManagerProtocol { var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess else { return nil } - return result as? Data + return classifyReadStatus(status, data: result as? Data) } /// Delete data from a custom service @@ -522,4 +573,30 @@ final class KeychainManager: KeychainManagerProtocol { SecItemDelete(query as CFDictionary) } + + /// Delete every item stored under a custom service + func deleteAll(service customService: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: customService, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + return + } + for item in items { + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: customService + ] + if let account = item[kSecAttrAccount as String] as? String { + deleteQuery[kSecAttrAccount as String] = account + } + SecItemDelete(deleteQuery as CFDictionary) + } + } } diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 910ecced..4657a7b3 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -17,6 +17,10 @@ struct LocationNotesDependencies { var now: () -> Date // Fires when the geo relay directory refreshes; used to retry after "no relays". var relayDirectoryUpdates: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() + /// Whether any of the target relays has a live connection — distinguishes + /// "loaded, empty" from "still connecting (Tor warming up)" when EOSE + /// fires without data. Defaults to true so tests keep legacy behavior. + var anyRelayConnected: @MainActor (_ relayUrls: [String]) -> Bool = { _ in true } private static let idBridge = NostrIdentityBridge() @@ -46,7 +50,10 @@ struct LocationNotesDependencies { relayDirectoryUpdates: NotificationCenter.default .publisher(for: .geoRelayDirectoryDidRefresh) .map { _ in () } - .eraseToAnyPublisher() + .eraseToAnyPublisher(), + anyRelayConnected: { relayUrls in + NostrRelayManager.shared.isAnyRelayConnected(among: relayUrls) + } ) } @@ -57,6 +64,10 @@ final class LocationNotesManager: ObservableObject { enum State: Equatable { case idle case loading + /// The initial fetch timed out with zero target relays connected + /// (usually Tor still bootstrapping): not "empty", just not there + /// yet. Retries automatically once a relay comes up. + case connecting case ready case noRelays } @@ -67,6 +78,33 @@ final class LocationNotesManager: ObservableObject { let content: String let createdAt: Date let nickname: String? + /// The matched `g` tag: the cell the note was posted to, which can be + /// a neighbor of the subscribed geohash. + let geohash: String + /// NIP-40 expiration, when the note carries one (dead drops do). + let expiresAt: Date? + /// Carries a `["t","urgent"]` tag (parity with urgent board posts). + let isUrgent: Bool + + init( + id: String, + pubkey: String, + content: String, + createdAt: Date, + nickname: String?, + geohash: String, + expiresAt: Date? = nil, + isUrgent: Bool = false + ) { + self.id = id + self.pubkey = pubkey + self.content = content + self.createdAt = createdAt + self.nickname = nickname + self.geohash = geohash + self.expiresAt = expiresAt + self.isUrgent = isUrgent + } var displayName: String { let suffix = String(pubkey.suffix(4)) @@ -82,9 +120,13 @@ final class LocationNotesManager: ObservableObject { @Published private(set) var initialLoadComplete: Bool = false @Published private(set) var state: State = .loading @Published private(set) var errorMessage: String? + /// Public key of our per-geohash Nostr identity; identifies our own notes. + private var ownPubkey: String? private var subscriptionID: String? private var noteIDs = Set() // O(1) duplicate detection private var directoryUpdateCancellable: AnyCancellable? + private var expiryPruneTimer: Timer? + private var connectivityRetryTimer: Timer? private let dependencies: LocationNotesDependencies private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200) @@ -104,10 +146,10 @@ final class LocationNotesManager: ObservableObject { let norm = geohash.lowercased() self.geohash = norm self.dependencies = dependencies - // Validate geohash (building-level precision: 8 chars) - if !Geohash.isValidBuildingGeohash(norm) { - SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) + if !Geohash.isValidGeohash(norm) { + SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session) } + ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex subscribe() // The relay directory may load after init (remote fetch over Tor); // retry automatically instead of staying stuck on "no relays". @@ -118,30 +160,50 @@ final class LocationNotesManager: ObservableObject { self.subscribe() } } + // NIP-40 notes can expire while displayed (a 24h dead drop crossing + // its boundary); ingest-time filtering alone would keep it visible + // until the subscription is recreated. + expiryPruneTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.pruneExpiredNotes() + } + } } - func setGeohash(_ newGeohash: String) { - let norm = newGeohash.lowercased() - guard norm != geohash else { return } - // Validate geohash (building-level precision: 8 chars) - guard Geohash.isValidBuildingGeohash(norm) else { - SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) - return - } + deinit { + expiryPruneTimer?.invalidate() + connectivityRetryTimer?.invalidate() + // A live REQ must not outlive its manager: relays would keep + // streaming events nobody consumes. deinit is nonisolated, so hop to + // the main actor with just the captured closure and id. if let sub = subscriptionID { - dependencies.unsubscribe(sub) - subscriptionID = nil + let unsubscribe = dependencies.unsubscribe + Task { @MainActor in + unsubscribe(sub) + } } - // Set loading state before clearing to prevent empty state flicker - state = .loading - initialLoadComplete = false - errorMessage = nil - geohash = norm - notes.removeAll() - noteIDs.removeAll() - subscribe() } + /// Drops notes whose NIP-40 expiry has passed. Their ids stay in + /// `noteIDs` so a relay replay cannot resurrect them. + func pruneExpiredNotes() { + let now = dependencies.now() + let expired = notes.contains { note in + if let expiresAt = note.expiresAt { return expiresAt <= now } + return false + } + guard expired else { return } + notes.removeAll { note in + if let expiresAt = note.expiresAt { return expiresAt <= now } + return false + } + } + + // A manager's geohash is fixed for its lifetime: instances are pooled + // per geohash (`LocationNotesPool`), so retargeting one in place would + // corrupt the pool's keying and refcounts. Release the manager and + // acquire the new cell instead. + func refresh() { if let sub = subscriptionID { dependencies.unsubscribe(sub) @@ -163,6 +225,8 @@ final class LocationNotesManager: ObservableObject { private func subscribe() { state = .loading errorMessage = nil + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil @@ -193,14 +257,19 @@ final class LocationNotesManager: ObservableObject { guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } // Ensure matching tag - accept any of our 9 geohashes - guard event.tags.contains(where: { tag in + guard let matchedGeohash = event.tags.first(where: { tag in tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) - }) else { return } + })?[1].lowercased() else { return } guard !self.noteIDs.contains(event.id) else { return } + // NIP-40: relays are not required to enforce expiration — drop + // expired notes client-side so 24h dead drops actually vanish. + let expiresAt = Self.expirationDate(of: event) + if let expiresAt, expiresAt <= self.dependencies.now() { return } self.noteIDs.insert(event.id) let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) + let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" } + let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent) self.notes.append(note) self.notes.sort { $0.createdAt > $1.createdAt } self.enforceMemoryCap() @@ -208,14 +277,51 @@ final class LocationNotesManager: ObservableObject { }, { [weak self] in guard let self = self else { return } self.initialLoadComplete = true - if self.state != .noRelays { + guard self.state != .noRelays else { return } + // EOSE with no data and zero connected target relays means the + // 10s fallback fired while Tor was still warming up — showing + // "no notes" would be a lie. Wait visibly and retry. + if self.notes.isEmpty, !self.dependencies.anyRelayConnected(relays) { + self.state = .connecting + self.scheduleConnectivityRetry(relays: relays) + } else { self.state = .ready } }) } - /// Send a location note for the current geohash using the per-geohash identity. - func send(content: String, nickname: String) { + /// While `.connecting`, poll for a live target relay and re-subscribe as + /// soon as one appears (fresh REQ, fresh EOSE tracking). The poll dies + /// with the state: any subscribe/cancel invalidates it. + private func scheduleConnectivityRetry(relays: [String]) { + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = Timer.scheduledTimer( + withTimeInterval: TransportConfig.uiGeoNotesConnectivityRetrySeconds, + repeats: true + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.retryIfRelaysAvailable(relays: relays) + } + } + } + + func retryIfRelaysAvailable(relays: [String]) { + guard state == .connecting else { + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil + return + } + guard dependencies.anyRelayConnected(relays) else { return } + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil + SecureLogger.debug("LocationNotesManager: relay came up, retrying notes fetch for \(geohash)", category: .session) + refresh() + } + + /// Send a location note for the current geohash using the per-geohash + /// identity, optionally expiring via NIP-40 (dead drops pass 24h; the + /// composer's ∞ option passes nil) and optionally tagged urgent. + func send(content: String, nickname: String, expiresAt: Date? = nil, urgent: Bool = false) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { @@ -230,7 +336,9 @@ final class LocationNotesManager: ObservableObject { content: trimmed, geohash: geohash, senderIdentity: id, - nickname: nickname + nickname: nickname, + expiresAt: expiresAt, + urgent: urgent ) dependencies.sendEvent(event, relays) // Optimistic local-echo @@ -239,7 +347,10 @@ final class LocationNotesManager: ObservableObject { pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), - nickname: nickname + nickname: nickname, + geohash: geohash, + expiresAt: expiresAt, + isUrgent: urgent ) self.noteIDs.insert(event.id) self.notes.insert(echo, at: 0) @@ -252,6 +363,44 @@ final class LocationNotesManager: ObservableObject { } } + /// Whether the note was published by this device's identity for the + /// current geohash (and can therefore be deleted with NIP-09). + func isOwnNote(_ note: Note) -> Bool { + guard let ownPubkey else { return false } + return note.pubkey == ownPubkey + } + + /// Requests NIP-09 deletion of one of our own notes and removes it locally. + @discardableResult + func delete(note: Note) -> Bool { + guard isOwnNote(note) else { return false } + let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + state = .noRelays + errorMessage = Strings.noRelays + return false + } + do { + let identity = try dependencies.deriveIdentity(geohash) + let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity) + dependencies.sendEvent(deletion, relays) + // Keep the id in noteIDs so a relay replay can't resurrect it. + notes.removeAll { $0.id == note.id } + return true + } catch { + SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session) + return false + } + } + + /// The NIP-40 `expiration` tag as a date, if the event carries one. + static func expirationDate(of event: NostrEvent) -> Date? { + guard let tag = event.tags.first(where: { $0.count >= 2 && $0[0].lowercased() == "expiration" }), + let seconds = TimeInterval(tag[1]) + else { return nil } + return Date(timeIntervalSince1970: seconds) + } + /// Enforces defensive memory cap on notes array (keeps newest). private func enforceMemoryCap() { if notes.count > maxNotesInMemory { @@ -261,12 +410,50 @@ final class LocationNotesManager: ObservableObject { } } - /// Explicitly cancel subscription and release resources. + /// One-shot dead-drop publish without holding a subscription: pins a + /// note to `geohash` that expires via NIP-40. Returns false when no geo + /// relays are known or signing fails. + @MainActor + static func postDrop( + content: String, + nickname: String, + geohash: String, + expiry: TimeInterval = TransportConfig.locationDropExpirySeconds, + dependencies: LocationNotesDependencies = .live + ) -> Bool { + guard let trimmed = content.trimmedOrNilIfEmpty else { return false } + let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + SecureLogger.warning("LocationNotesManager: drop blocked, no geo relays for geohash=\(geohash)", category: .session) + return false + } + do { + let identity = try dependencies.deriveIdentity(geohash) + let event = try NostrProtocol.createGeohashTextNote( + content: trimmed, + geohash: geohash, + senderIdentity: identity, + nickname: nickname, + expiresAt: dependencies.now().addingTimeInterval(expiry) + ) + dependencies.sendEvent(event, relays) + return true + } catch { + SecureLogger.error("LocationNotesManager: failed to post drop: \(error)", category: .session) + return false + } + } + + /// Explicitly cancel the subscription. The prune timer stays alive (it + /// holds only a weak self) so a reused instance — the notices sheet + /// cancels on tab switch and refreshes on return — keeps pruning. func cancel() { if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil } + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil state = .idle errorMessage = nil } diff --git a/bitchat/Services/LocationNotesPool.swift b/bitchat/Services/LocationNotesPool.swift new file mode 100644 index 00000000..9a6f2ec5 --- /dev/null +++ b/bitchat/Services/LocationNotesPool.swift @@ -0,0 +1,61 @@ +// +// LocationNotesPool.swift +// bitchat +// +// Refcounted pool of LocationNotesManager instances keyed by geohash, so +// surfaces watching the same place (the nearby-notes counter and the notices +// sheet's geo tab) share one relay subscription instead of opening two +// identical 9-cell REQs. +// This is free and unencumbered software released into the public domain. +// + +import Foundation + +@MainActor +final class LocationNotesPool { + static let shared = LocationNotesPool() + + private var entries: [String: (manager: LocationNotesManager, refs: Int)] = [:] + private let makeManager: @MainActor (String) -> LocationNotesManager + + /// The factory is injectable so tests can pool managers built over stub + /// dependencies; live use derives one real manager per geohash. + init(makeManager: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesManager(geohash: $0) }) { + self.makeManager = makeManager + } + + /// Returns the shared manager for `geohash` (case-insensitive), creating + /// it on first acquire and reviving a cancelled one on re-acquire. + /// Callers must never `cancel` a pooled manager — release it and acquire + /// the new geohash instead. + func acquire(_ geohash: String) -> LocationNotesManager { + let key = geohash.lowercased() + if let entry = entries[key] { + entries[key] = (entry.manager, entry.refs + 1) + if entry.manager.state == .idle { + entry.manager.refresh() + } + return entry.manager + } + let manager = makeManager(key) + entries[key] = (manager, 1) + return manager + } + + /// Balances `acquire`: the last release cancels the subscription and + /// drops the entry. Releasing an instance the pool doesn't own (a + /// test-injected manager) degrades to a plain `cancel()`. + func release(_ manager: LocationNotesManager?) { + guard let manager else { return } + guard let entry = entries[manager.geohash], entry.manager === manager else { + manager.cancel() + return + } + if entry.refs <= 1 { + entries[manager.geohash] = nil + manager.cancel() + } else { + entries[manager.geohash] = (entry.manager, entry.refs - 1) + } + } +} diff --git a/bitchat/Services/LocationNotesSettings.swift b/bitchat/Services/LocationNotesSettings.swift new file mode 100644 index 00000000..5bda810f --- /dev/null +++ b/bitchat/Services/LocationNotesSettings.swift @@ -0,0 +1,29 @@ +// +// LocationNotesSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// User preference for location notes (dead drops): leaving notes pinned to +/// nearby places with /drop and surfacing notes others left here. On by +/// default, but everything it powers additionally requires location +/// permission — the toggle in app info is the kill switch. +enum LocationNotesSettings { + private static let enabledKey = "locationNotes.enabled" + + /// Fired on every toggle write so live consumers (the nearby-notes + /// counter) can drop or restart their relay subscription immediately. + static let didChangeNotification = Notification.Name("bitchat.locationNotesSettingsDidChange") + + static var enabled: Bool { + get { UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true } + set { + UserDefaults.standard.set(newValue, forKey: enabledKey) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } + } +} diff --git a/bitchat/Services/LocationStateManager.swift b/bitchat/Services/LocationStateManager.swift index aca9fb87..5f6ac61a 100644 --- a/bitchat/Services/LocationStateManager.swift +++ b/bitchat/Services/LocationStateManager.swift @@ -101,9 +101,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private let cl: LocationStateManaging private let geocoder: LocationStateGeocoding - private var lastLocation: CLLocation? private var refreshTimer: Timer? - private var isGeocoding: Bool = false // MARK: - Persistence Keys @@ -268,7 +266,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } } - func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { + func beginLiveRefresh(interval _: TimeInterval = TransportConfig.locationLiveRefreshInterval) { guard permissionState == .authorized else { return } refreshTimer?.invalidate() refreshTimer = nil @@ -369,23 +367,22 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - updatePermissionState(from: status) - if case .authorized = permissionState { + let newState = updatePermissionState(from: status) + if newState == .authorized { requestOneShotLocation() } } @available(iOS 14.0, macOS 11.0, *) func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - updatePermissionState(from: manager.authorizationStatus) - if case .authorized = permissionState { + let newState = updatePermissionState(from: manager.authorizationStatus) + if newState == .authorized { requestOneShotLocation() } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } - lastLocation = loc computeChannels(from: loc.coordinate) reverseGeocodeLocation(loc) } @@ -396,7 +393,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl // MARK: - Private Helpers (Permission) - private func updatePermissionState(from status: CLAuthorizationStatus) { + @discardableResult + private func updatePermissionState(from status: CLAuthorizationStatus) -> PermissionState { let newState: PermissionState switch status { case .notDetermined: newState = .notDetermined @@ -405,7 +403,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized @unknown default: newState = .restricted } + // Do not rely on a mounted SwiftUI consumer to stop high-accuracy + // updates. Authorization can change while the app is backgrounded, + // and stopping here also closes the gap before the published state is + // delivered on the main actor. + if newState != .authorized { + endLiveRefresh() + } Task { @MainActor in self.permissionState = newState } + return newState } // MARK: - Private Helpers (Channel Computation) @@ -441,10 +447,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private func reverseGeocodeLocation(_ location: CLLocation) { geocoder.cancelGeocode() - isGeocoding = true geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in guard let self = self else { return } - self.isGeocoding = false if let pm = placemarks?.first { let names = self.locationNamesByLevel(from: pm) Task { @MainActor in self.locationNames = names } @@ -632,15 +636,5 @@ extension LocationStateManager { func toggle(_ geohash: String) { toggleBookmark(geohash) } - - /// Backward compatibility: add bookmark (was GeohashBookmarksStore.add) - func add(_ geohash: String) { - addBookmark(geohash) - } - - /// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove) - func remove(_ geohash: String) { - removeBookmark(geohash) - } } #endif diff --git a/bitchat/Services/MeshEchoSettings.swift b/bitchat/Services/MeshEchoSettings.swift new file mode 100644 index 00000000..347c178a --- /dev/null +++ b/bitchat/Services/MeshEchoSettings.swift @@ -0,0 +1,27 @@ +// +// MeshEchoSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Watermark for "heard here earlier" echoes: clearing the mesh timeline +/// (triple-tap or /clear) records the moment, and the next launch only +/// re-seeds archived messages heard after it. The archive itself is left +/// alone — the device keeps carrying those messages for peers; the user +/// just doesn't want to see them again. +enum MeshEchoSettings { + private static let clearedThroughKey = "meshEchoes.clearedThrough" + + static var clearedThrough: Date? { + get { UserDefaults.standard.object(forKey: clearedThroughKey) as? Date } + set { UserDefaults.standard.set(newValue, forKey: clearedThroughKey) } + } + + static func reset() { + UserDefaults.standard.removeObject(forKey: clearedThroughKey) + } +} diff --git a/bitchat/Services/MeshSightingsTracker.swift b/bitchat/Services/MeshSightingsTracker.swift new file mode 100644 index 00000000..12bd7255 --- /dev/null +++ b/bitchat/Services/MeshSightingsTracker.swift @@ -0,0 +1,120 @@ +// +// MeshSightingsTracker.swift +// bitchat +// +// Privacy-preserving daily tally of mesh peers that came within radio range, +// so an empty timeline can say "3 devices passed within range today" instead +// of feeling dead. Stores only a per-day salted hash per peer plus a count — +// no identities, no history beyond today. +// This is free and unencumbered software released into the public domain. +// + +import BitFoundation +import CryptoKit +import Foundation + +@MainActor +final class MeshSightingsTracker: ObservableObject { + static let shared = MeshSightingsTracker() + + private enum Keys { + static let dayKey = "meshSightings.dayKey" + static let salt = "meshSightings.salt" + static let hashes = "meshSightings.hashes" + static let lastSeenAt = "meshSightings.lastSeenAt" + } + + /// Distinct devices seen within range today (rotating peer IDs may count + /// a long-lived neighbor more than once across rotations; that is fine + /// for an ambient stat). + @Published private(set) var todayCount: Int = 0 + @Published private(set) var lastSightingAt: Date? + + private let defaults: UserDefaults + private let now: () -> Date + private var seenHashes: Set = [] + + init(defaults: UserDefaults = .standard, now: @escaping () -> Date = { Date() }) { + self.defaults = defaults + self.now = now + restore() + } + + func recordSighting(peerID: PeerID) { + rollOverIfNeeded() + let hash = saltedHash(peerID.id) + let seenAt = now() + lastSightingAt = seenAt + defaults.set(seenAt, forKey: Keys.lastSeenAt) + guard seenHashes.insert(hash).inserted else { return } + todayCount = seenHashes.count + defaults.set(Array(seenHashes), forKey: Keys.hashes) + } + + /// Re-evaluates the day boundary for the UI. `recordSighting` handles + /// rollover when peers are seen, but an idle app open across midnight + /// would otherwise keep showing yesterday's tally until the next sighting; + /// the empty-state view calls this on its periodic refresh tick. + func refreshForDisplay() { + rollOverIfNeeded() + } + + func clear() { + seenHashes.removeAll() + todayCount = 0 + lastSightingAt = nil + defaults.removeObject(forKey: Keys.dayKey) + defaults.removeObject(forKey: Keys.salt) + defaults.removeObject(forKey: Keys.hashes) + defaults.removeObject(forKey: Keys.lastSeenAt) + } + + private func restore() { + rollOverIfNeeded() + seenHashes = Set(defaults.stringArray(forKey: Keys.hashes) ?? []) + todayCount = seenHashes.count + lastSightingAt = defaults.object(forKey: Keys.lastSeenAt) as? Date + } + + /// Resets the tally when the local calendar day changes; the salt rotates + /// with it so hashes from different days can never be correlated. + private func rollOverIfNeeded() { + let today = Self.dayKey(for: now()) + guard defaults.string(forKey: Keys.dayKey) != today else { return } + defaults.set(today, forKey: Keys.dayKey) + defaults.set(Self.randomSalt(), forKey: Keys.salt) + defaults.removeObject(forKey: Keys.hashes) + defaults.removeObject(forKey: Keys.lastSeenAt) + seenHashes.removeAll() + todayCount = 0 + lastSightingAt = nil + } + + private func saltedHash(_ value: String) -> String { + let salt = defaults.data(forKey: Keys.salt) ?? { + let fresh = Self.randomSalt() + defaults.set(fresh, forKey: Keys.salt) + return fresh + }() + var digest = SHA256() + digest.update(data: salt) + digest.update(data: Data(value.utf8)) + return digest.finalize().map { String(format: "%02x", $0) }.joined() + } + + private static let dayKeyFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static func dayKey(for date: Date) -> String { + dayKeyFormatter.string(from: date) + } + + private static func randomSalt() -> Data { + Data((0..<16).map { _ in UInt8.random(in: .min ... .max) }) + } +} diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift index fdd8749f..5596b5b1 100644 --- a/bitchat/Services/MeshTopologyTracker.swift +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -10,6 +10,10 @@ final class MeshTopologyTracker { private var claims: [RoutingID: Set] = [:] // Last time we received an update from a node private var lastSeen: [RoutingID: Date] = [:] + // Highest protocol version observed from each node's decoded packets. + // Nodes absent from this map are assumed v1-only and are never used as + // hops (or targets) for version-gated routes. + private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:] // Maximum age for topology claims to be considered fresh for routing // Routes computed using stale topology can fail when the network has changed @@ -19,48 +23,75 @@ final class MeshTopologyTracker { queue.sync(flags: .barrier) { self.claims.removeAll() self.lastSeen.removeAll() + self.observedVersions.removeAll() } } /// Update the topology with a node's self-reported neighbor list - func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { + func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) { guard let source = sanitize(sourceData) else { return } // Sanitize neighbors and exclude self-loops let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source]) - + queue.sync(flags: .barrier) { self.claims[source] = validNeighbors - self.lastSeen[source] = Date() + self.lastSeen[source] = now } } + /// Record the protocol version observed on a decoded packet from a node. + /// Only versions above the v1 baseline are stored; the highest wins. + func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) { + guard version > 1, let peer = sanitize(peerData) else { return } + queue.sync(flags: .barrier) { + let current = self.observedVersions[peer]?.version ?? 1 + self.observedVersions[peer] = (version: max(version, current), seenAt: now) + } + } + + /// Raw directed neighbor claims, for diagnostics (topology map, /trace). + /// Callers treat the claims as advisory: announces cap `directNeighbors` + /// at 10, so an edge may be claimed by only one of its endpoints. + func adjacencySnapshot() -> [Data: Set] { + queue.sync { claims } + } + func removePeer(_ data: Data?) { guard let peer = sanitize(data) else { return } queue.sync(flags: .barrier) { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) + self.observedVersions.removeValue(forKey: peer) } } - + /// Prune nodes that haven't updated their topology in `age` seconds - func prune(olderThan age: TimeInterval) { - let deadline = Date().addingTimeInterval(-age) + func prune(olderThan age: TimeInterval, now: Date = Date()) { + let deadline = now.addingTimeInterval(-age) queue.sync(flags: .barrier) { let stale = self.lastSeen.filter { $0.value < deadline } for (peer, _) in stale { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) } + self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline } } } - func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? { + /// BFS over confirmed, fresh edges. When `requiringVersion` is set, every + /// node on the path except the source (i.e. all intermediate hops and the + /// target) must have been observed speaking at least that protocol + /// version — a v1-only hop cannot decode a v2 routed packet. + func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? { guard let source = sanitize(start), let target = sanitize(goal) else { return nil } if source == target { return [] } // Direct connection, no intermediate hops return queue.sync { - let now = Date() let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold) + func meetsRequiredVersion(_ peer: RoutingID) -> Bool { + guard let requiringVersion else { return true } + return (observedVersions[peer]?.version ?? 1) >= requiringVersion + } // BFS var visited: Set = [source] @@ -86,6 +117,10 @@ final class MeshTopologyTracker { for neighbor in neighbors { if visited.contains(neighbor) { continue } + // Version gate: skip nodes not known to speak the + // required protocol version. + guard meetsRequiredVersion(neighbor) else { continue } + // CONFIRMED EDGE CHECK: // 'last' claims 'neighbor' (checked above) // Does 'neighbor' claim 'last'? diff --git a/bitchat/Services/MessageDeduplicationService.swift b/bitchat/Services/MessageDeduplicationService.swift index f84c1f47..66d7e57b 100644 --- a/bitchat/Services/MessageDeduplicationService.swift +++ b/bitchat/Services/MessageDeduplicationService.swift @@ -172,17 +172,37 @@ final class MessageDeduplicationService { /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) private let nostrAckCache: LRUDeduplicationCache + /// Optional cross-launch persistence for the Nostr event cache. NIP-59 + /// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and + /// relays redeliver the same events on every launch; without this record + /// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers + /// that don't opt in) keeps the cache purely in-memory. + private let nostrEventStore: NostrProcessedEventStore? + private let nostrEventCapacity: Int + private var persistScheduled = false + private var pendingPersistIDs: [String] = [] + /// Creates a new deduplication service with specified capacities. /// - Parameters: /// - contentCapacity: Max entries for content cache /// - nostrEventCapacity: Max entries for Nostr event cache + /// - nostrEventStore: Optional disk store preloading and persisting + /// processed Nostr event IDs across launches init( contentCapacity: Int = TransportConfig.contentLRUCap, - nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap + nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap, + nostrEventStore: NostrProcessedEventStore? = nil ) { self.contentCache = LRUDeduplicationCache(capacity: contentCapacity) self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity) self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity) + self.nostrEventStore = nostrEventStore + self.nostrEventCapacity = nostrEventCapacity + if let nostrEventStore { + for eventID in nostrEventStore.load() { + nostrEventCache.record(eventID, value: true) + } + } } // MARK: - Content Deduplication @@ -226,6 +246,16 @@ final class MessageDeduplicationService { ContentNormalizer.normalizedKey(content) } + /// Removes the near-duplicate marker for a row that is being replaced, + /// not merely deleted. Bridge-first/radio-second reconciliation needs the + /// authenticated radio copy to pass the next pipeline flush after its + /// unauthenticated bridge alias is removed. + func forgetContent(_ content: String, ifRecordedAt timestamp: Date) { + let key = ContentNormalizer.normalizedKey(content) + guard contentCache.value(for: key) == timestamp else { return } + contentCache.remove(key) + } + // MARK: - Nostr Event Deduplication /// Checks if a Nostr event has already been processed. @@ -239,6 +269,26 @@ final class MessageDeduplicationService { /// - Parameter eventId: The event ID func recordNostrEvent(_ eventId: String) { nostrEventCache.record(eventId, value: true) + if nostrEventStore != nil { + pendingPersistIDs.append(eventId) + schedulePersistIfNeeded() + } + } + + /// Debounced persistence: bursts of inbound events (reconnect redelivery) + /// collapse into one append. Append-merge rather than snapshot, so a + /// transient in-memory clear between flushes can't shrink the disk record. + private func schedulePersistIfNeeded() { + guard let nostrEventStore, !persistScheduled else { return } + persistScheduled = true + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard let self else { return } + self.persistScheduled = false + let newIDs = self.pendingPersistIDs + self.pendingPersistIDs.removeAll() + nostrEventStore.append(newIDs, cap: self.nostrEventCapacity) + } } // MARK: - Nostr ACK Deduplication @@ -263,14 +313,20 @@ final class MessageDeduplicationService { // MARK: - Clear - /// Clears all caches + /// Clears all caches. This is the wipe/panic path: the persisted + /// gift-wrap record goes with everything else. func clearAll() { contentCache.clear() nostrEventCache.clear() nostrAckCache.clear() + pendingPersistIDs.removeAll() + nostrEventStore?.wipe() } - /// Clears only the Nostr caches (events and ACKs) + /// Clears only the in-memory Nostr caches (events and ACKs). Runs on + /// every geohash channel switch, so the disk record deliberately + /// survives — wiping it here would forfeit cross-launch gift-wrap dedup + /// each time the user changes channels (flagged by Codex on #1398). func clearNostrCaches() { nostrEventCache.clear() nostrAckCache.clear() diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 71abcbe4..4bd4b073 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -70,10 +70,6 @@ final class MessageFormattingEngine { static let quickCashuPresence: NSRegularExpression = { try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) }() - - static let simplifyHTTPURL: NSRegularExpression = { - try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive]) - }() } // MARK: - Match Types @@ -195,7 +191,7 @@ final class MessageFormattingEngine { // MARK: - Private Helpers - private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString { + private static func formatSystemMessage(_ message: BitchatMessage, isDark _: Bool) -> AttributedString { var result = AttributedString() let content = AttributedString("* \(message.content) *") @@ -414,7 +410,7 @@ final class MessageFormattingEngine { return AttributedString(text).mergingAttributes(style) } - private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString { + private static func formatMatch(_ text: String, type: MatchType, baseColor _: Color, isSelf _: Bool) -> AttributedString { var style = AttributeContainer() switch type { diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index af0b3291..fbc6d694 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -2,11 +2,43 @@ import BitLogger import BitFoundation import Foundation +/// Trust and identity lookups the router needs to pick couriers. Backed by +/// the favorites store in production; injectable for tests. +struct CourierDirectory { + /// Noise static key for a peer we can address while they're offline. + var noiseKey: (PeerID) -> Data? + /// Whether a peer (by Noise static key) is a mutual favorite — the + /// preferred courier tier. Verified non-favorites are the fallback tier, + /// read off the transport snapshot. + var isTrustedCourier: (Data) -> Bool + + @MainActor + static func favoritesBacked() -> CourierDirectory { + CourierDirectory( + noiseKey: { peerID in + // Offline favorites are addressed by the full 64-hex + // noise-key ID, which carries the key itself; the favorites + // lookup only resolves short 16-hex IDs. + peerID.noiseKey + ?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey + }, + isTrustedCourier: { noiseKey in + FavoritesPersistenceService.shared.isMutualFavorite(noiseKey) + } + ) + } +} + /// Routes messages using available transports (Mesh, Nostr, etc.) @MainActor final class MessageRouter { + typealias QueuedMessage = MessageOutboxStore.QueuedMessage + private let transports: [Transport] private let now: () -> Date + private let courierDirectory: CourierDirectory + private let outboxStore: MessageOutboxStore? + private let metrics: StoreAndForwardMetrics? /// Invoked whenever a retained private message is dropped without a /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) @@ -14,15 +46,66 @@ final class MessageRouter { /// stale "sending/sent" state forever. var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? - // Outbox entry with timestamp for TTL-based eviction - private struct QueuedMessage { - let content: String - let nickname: String - let messageID: String - let timestamp: Date - var sendAttempts: Int = 0 + /// Invoked when a message with no reachable transport was handed to at + /// least one courier (a connected peer who will physically carry the + /// sealed envelope). Delivery stays best-effort: the outbox retains the + /// message until an ack arrives. + var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)? + + /// Parallel deposit into the internet bridge: park a sealed copy on + /// relays as a courier drop, so delivery stops requiring a physical + /// courier encounter. No-op unless the bridge is enabled. Runs alongside + /// (not instead of) mesh couriers; receivers dedup by message ID. + /// Completion is true only after at least one default relay explicitly + /// accepts the event, so a socket write followed by rejection cannot + /// falsely show the sender's message as "carried". + var bridgeCourierDeposit: (( + _ content: String, + _ messageID: String, + _ recipientNoiseKey: Data, + _ completion: @escaping @MainActor (Bool) -> Void + ) -> Void)? + + /// Re-attempts bridge drops for retained messages whose recipient no + /// transport can promptly reach anymore. Covers sends that raced the BLE + /// reachability retention window: a peer stays "reachable" for a minute + /// after its radio disappears, so the original send trusted the mesh and + /// skipped the deposit — and nothing else ever retried (field-found). + /// Safe to call often: the drop layer dedups by message ID. + func retryBridgeCourierDeposits() { + guard bridgeCourierDeposit != nil else { return } + for (peerID, queue) in outbox { + guard let recipientKey = courierDirectory.noiseKey(peerID) else { continue } + let promptlyDeliverable = transports.contains { + $0.isPeerReachable(peerID) && $0.canDeliverPromptly(to: peerID) + } + guard !promptlyDeliverable else { continue } + for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds { + requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey) + } + } } + /// Arms the periodic sweep behind `retryBridgeCourierDeposits`. Called + /// once by the bootstrapper after the deposit closure is wired; separate + /// from init so tests drive the retry directly. + func startBridgeDepositSweep(interval: TimeInterval = 120) { + bridgeSweepTask?.cancel() + bridgeSweepTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + // Expire stale outbox entries in-session too — otherwise a DM + // to a peer that never reconnects sits on "sending" until the + // next relaunch instead of surfacing as failed. + self?.cleanupExpiredMessages() + self?.retryBridgeCourierDeposits() + } + } + } + + private var bridgeSweepTask: Task? + private var bridgeDepositsInFlight = Set() + private var outbox: [PeerID: [QueuedMessage]] = [:] // Outbox limits to prevent unbounded memory growth @@ -31,10 +114,25 @@ final class MessageRouter { // Bound resends of messages sent on a weak reachability signal that never // get a delivery ack (e.g. peer on an old client that doesn't ack). private static let maxSendAttempts = 8 + // Redundant couriers improve delivery odds; receivers dedup by message ID. + private static let maxCouriersPerMessage = 3 - init(transports: [Transport], now: @escaping () -> Date = Date.init) { + init( + transports: [Transport], + now: @escaping () -> Date = Date.init, + courierDirectory: CourierDirectory? = nil, + outboxStore: MessageOutboxStore? = nil, + metrics: StoreAndForwardMetrics? = nil + ) { self.transports = transports self.now = now + self.courierDirectory = courierDirectory ?? .favoritesBacked() + self.outboxStore = outboxStore + self.metrics = metrics + self.outbox = outboxStore?.load() ?? [:] + outboxStore?.setRecoveryHandler { [weak self] recovered in + self?.mergeRecoveredOutbox(recovered) + } // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( @@ -51,7 +149,7 @@ final class MessageRouter { } // Handle key updates if let newKey = note.userInfo?["peerPublicKey"] as? Data, - let _ = note.userInfo?["isKeyUpdate"] as? Bool { + note.userInfo?["isKeyUpdate"] is Bool { let peerID = PeerID(publicKey: newKey) Task { @MainActor in self.flushOutbox(for: peerID) @@ -73,14 +171,38 @@ final class MessageRouter { // MARK: - Message Sending func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - if let transport = connectedTransport(for: peerID) { - // A live link is a strong delivery signal; trust it outright. + if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { + // A live link that can complete an encrypted delivery is a + // strong delivery signal; trust it outright. SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) return } let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1) + if let transport = connectedTransport(for: peerID) { + // "Connected" without an established secure session is forgeable: + // link bindings heal on signature-verified "direct" announces, but + // directness rides on the unsigned TTL, so a replayed announce can + // bind an absent peer's ID to the replayer's link — where the send + // stalls on a handshake the replayer can never complete. Send now + // (a genuine link finishes the handshake and delivers), but retain + // a copy and hand a sealed copy to couriers so nothing is silently + // lost; receivers dedup resends by message ID. + // + // Deliberate metadata tradeoff: every pre-handshake first DM to a + // connected peer hands nearby verified peers a sealed copy, so + // they learn a DM to this recipient exists (never its content — + // the envelope is opaque). Accepted for delivery robustness; the + // deposit is cleared on ack. Don't "optimize" the courier call + // away. + SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + enqueue(message, for: peerID) + attemptCourierDeposit(messageID: messageID, for: peerID) + return + } + if let transport = reachableTransport(for: peerID) { // Reachability without a connection is a freshness heuristic (e.g. // the mesh retention window), so the send can silently go nowhere. @@ -89,52 +211,254 @@ final class MessageRouter { SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) enqueue(message, for: peerID) + // "Reachable" without prompt delivery means the send only joined + // a queue (Nostr with relays down): also hand a sealed copy to + // any connected couriers rather than waiting for internet that + // may never come. Double delivery is harmless — receivers dedup + // by message ID, and delivered/read acks never downgrade. + if !transport.canDeliverPromptly(to: peerID) { + attemptCourierDeposit(messageID: messageID, for: peerID) + } } else { var unsent = message unsent.sendAttempts = 0 enqueue(unsent, for: peerID) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) + attemptCourierDeposit(messageID: messageID, for: peerID) } } + // MARK: - Couriers + + /// Last resort when no transport can deliver promptly — the peer is + /// unreachable, or only reachable through a send queue waiting on + /// internet: seal the message to their known static key and hand it to + /// connected couriers who may physically encounter them. Mutual favorites + /// are preferred; signature-verified strangers fill remaining slots so a + /// crowd without favorites can still carry mail (envelopes are opaque + /// either way). The queued copy stays retained, so direct delivery still + /// wins if the peer reappears first (receivers dedup by message ID). + private func attemptCourierDeposit(messageID: String, for peerID: PeerID) { + guard let recipientKey = courierDirectory.noiseKey(peerID), + let entry = queuedMessage(messageID, for: peerID) else { return } + // The bridge drop needs no connected courier — only the recipient + // key — so it runs before the courier-slot bookkeeping. + requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey) + let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count + guard remainingSlots > 0 else { return } + + for transport in transports { + let couriers = eligibleCouriers( + on: transport, + recipientKey: recipientKey, + excluding: entry.depositedCourierKeys, + limit: remainingSlots + ) + guard !couriers.isEmpty else { continue } + if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) { + SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session) + recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey)) + onMessageCarried?(messageID, peerID) + return + } + } + } + + /// A courier candidate just connected: hand them any queued mail they are + /// not already carrying. This is what turns couriering from "a favorite + /// happened to be around at send time" into eventual spread — deposits + /// retry as eligible peers appear, until each message rides with + /// `maxCouriersPerMessage` distinct couriers or expires. + func courierBecameAvailable(_ peerID: PeerID) { + for transport in transports { + guard transport.isPeerConnected(peerID), + let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }), + let courierKey = snapshot.noisePublicKey, + courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue } + + let currentDate = now() + for (recipient, queue) in outbox { + // Mail *to* this peer flushes directly on connect. + guard recipient != peerID, + let recipientKey = courierDirectory.noiseKey(recipient), + recipientKey != courierKey else { continue } + for message in queue { + guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage, + !message.depositedCourierKeys.contains(courierKey), + currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue } + if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) { + SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session) + recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey]) + onMessageCarried?(message.messageID, recipient) + } + } + } + return + } + } + + private struct CourierCandidate { + let peerID: PeerID + let noiseKey: Data + } + + private func eligibleCouriers( + on transport: Transport, + recipientKey: Data, + excluding excludedKeys: Set, + limit: Int + ) -> [CourierCandidate] { + guard limit > 0 else { return [] } + let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in + guard snapshot.isConnected, + let key = snapshot.noisePublicKey, + key != recipientKey, + !excludedKeys.contains(key) else { return nil } + let isFavorite = courierDirectory.isTrustedCourier(key) + guard isFavorite || snapshot.isVerified else { return nil } + return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite) + } + return candidates + .sorted { $0.isFavorite && !$1.isFavorite } + .prefix(limit) + .map(\.0) + } + + private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? { + outbox[peerID]?.first { $0.messageID == messageID } + } + + private func requestBridgeCourierDeposit( + _ message: QueuedMessage, + for peerID: PeerID, + recipientKey: Data + ) { + guard let bridgeCourierDeposit, + bridgeDepositsInFlight.insert(message.messageID).inserted else { return } + bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in + guard let self else { return } + self.bridgeDepositsInFlight.remove(message.messageID) + // A direct delivery may have cleared the outbox while the relay + // relay confirmation was in flight; do not regress its UI state. + guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return } + self.onMessageCarried?(message.messageID, peerID) + } + } + + private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) { + metrics?.record(.courierDeposited) + guard var queue = outbox[peerID], + let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return } + queue[index].depositedCourierKeys.formUnion(courierKeys) + outbox[peerID] = queue + persistOutbox() + } + + // MARK: - Outbox Management + /// A delivery or read ack confirms receipt; stop retaining the message. func markDelivered(_ messageID: String) { + var cleared = false for (peerID, queue) in outbox { let filtered = queue.filter { $0.messageID != messageID } guard filtered.count != queue.count else { continue } outbox[peerID] = filtered.isEmpty ? nil : filtered + cleared = true } + // The durable snapshot may still be hidden by protected data. Record + // the ack even when this cold-load view cannot find the message, then + // persist the current view so the store retains a removal tombstone. + outboxStore?.recordRemoval(messageID: messageID) + if cleared { + metrics?.record(.outboxDelivered) + } + persistOutbox() } private func enqueue(_ message: QueuedMessage, for peerID: PeerID) { + var message = message var queue = outbox[peerID] ?? [] - // Re-sending an already-queued ID replaces the entry (keeps attempt count fresh) - queue.removeAll { $0.messageID == message.messageID } + // Re-sending an already-queued ID replaces the entry (keeps attempt + // count fresh) but must not forget which couriers already carry it, + // or the replacement re-burns the same courier slots. + if let existing = queue.firstIndex(where: { $0.messageID == message.messageID }) { + message.depositedCourierKeys.formUnion(queue[existing].depositedCourierKeys) + queue.remove(at: existing) + } queue.append(message) // Enforce per-peer size limit with FIFO eviction if queue.count > Self.maxMessagesPerPeer { let evicted = queue.removeFirst() SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session) - onMessageDropped?(evicted.messageID, peerID) + dropMessage(evicted.messageID, for: peerID) } outbox[peerID] = queue + metrics?.record(.outboxQueued) + persistOutbox() } - func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + private func dropMessage(_ messageID: String, for peerID: PeerID) { + metrics?.record(.outboxDropped) + onMessageDropped?(messageID, peerID) + } + + private func persistOutbox() { + outboxStore?.save(outbox) + } + + /// A cold BLE restoration can launch before protected files are readable. + /// The store initially returns an empty snapshot in that case, then calls + /// back after first unlock. Merge by message ID instead of replacing work + /// accepted during the locked wake, persist the union, and immediately + /// resume normal delivery attempts. + private func mergeRecoveredOutbox(_ recovered: MessageOutboxStore.Snapshot) { + for (peerID, recoveredQueue) in recovered { + var queue = outbox[peerID] ?? [] + for var recoveredMessage in recoveredQueue { + if let index = queue.firstIndex(where: { $0.messageID == recoveredMessage.messageID }) { + recoveredMessage.sendAttempts = max(recoveredMessage.sendAttempts, queue[index].sendAttempts) + recoveredMessage.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys) + queue[index] = recoveredMessage + } else { + queue.append(recoveredMessage) + } + } + queue.sort { $0.timestamp < $1.timestamp } + if queue.count > Self.maxMessagesPerPeer { + let overflow = queue.count - Self.maxMessagesPerPeer + for dropped in queue.prefix(overflow) { + dropMessage(dropped.messageID, for: peerID) + } + queue.removeFirst(overflow) + } + outbox[peerID] = queue + } + persistOutbox() + flushAllOutbox() + retryBridgeCourierDeposits() + } + + /// Panic wipe: forget queued mail on disk and in memory. + func wipeOutbox() { + outbox.removeAll() + outboxStore?.wipe() + } + + /// Returns true only when the receipt was handed to a reachable transport. + /// A false result means it was dropped (no route) and must NOT be recorded + /// as sent, or the sender's message would stay unread forever — the receipt + /// is retried on the next read scan (chat open / foreground / reconnect). + @discardableResult + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) transport.sendReadReceipt(receipt, to: peerID) + return true } else if !transports.isEmpty { - SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session) - } - } - - func sendDeliveryAck(_ messageID: String, to peerID: PeerID) { - if let transport = reachableTransport(for: peerID) { - SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - transport.sendDeliveryAck(for: messageID, to: peerID) + SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))… — leaving unsent for retry", category: .session) } + return false } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { @@ -145,8 +469,6 @@ final class MessageRouter { } } - // MARK: - Outbox Management - func flushOutbox(for peerID: PeerID) { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) @@ -158,24 +480,43 @@ final class MessageRouter { // Skip expired messages (TTL exceeded) if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) - onMessageDropped?(message.messageID, peerID) + dropMessage(message.messageID, for: peerID) continue } - if let transport = connectedTransport(for: peerID) { - // Live link: send and stop retaining. + if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { + // Live link with a secure session: send and stop retaining. SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) + metrics?.record(.outboxResent) + } else if let transport = connectedTransport(for: peerID) { + // "Connected" without a secure session — possibly a stolen + // binding from a replayed announce: send (a genuine link + // finishes the handshake and delivers) but keep retaining + // until an ack clears it. These flushes do NOT count toward + // the attempt-cap drop: the message was transmitted over a + // live link, so a peer whose handshake stalls across + // reconnect flapping must not burn through the cap and lose + // the store-and-forward copy this retention exists to + // preserve. Retention stays bounded by the 24h outbox TTL + // and the per-peer FIFO cap. + SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) + transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) + metrics?.record(.outboxResent) + remaining.append(message) } else if let transport = reachableTransport(for: peerID) { - // Weak signal: send but keep retaining until an ack clears it, - // bounded by attempt count for peers that never ack. + // Reachability without a connection is a freshness heuristic, + // so the send can silently go nowhere: send but keep retaining + // until an ack clears it, bounded by attempt count for peers + // that never ack. guard message.sendAttempts < Self.maxSendAttempts else { SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) - onMessageDropped?(message.messageID, peerID) + dropMessage(message.messageID, for: peerID) continue } SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) + metrics?.record(.outboxResent) var retained = message retained.sendAttempts += 1 remaining.append(retained) @@ -189,6 +530,7 @@ final class MessageRouter { } else { outbox[peerID] = remaining } + persistOutbox() } func flushAllOutbox() { @@ -198,6 +540,7 @@ final class MessageRouter { /// Periodically clean up expired messages from all outboxes func cleanupExpiredMessages() { let now = now() + var droppedAny = false for peerID in Array(outbox.keys) { var expiredMessageIDs: [String] = [] outbox[peerID]?.removeAll { message in @@ -210,8 +553,12 @@ final class MessageRouter { } for messageID in expiredMessageIDs { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - onMessageDropped?(messageID, peerID) + dropMessage(messageID, for: peerID) + droppedAny = true } } + if droppedAny { + persistOutbox() + } } } diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index 1f68af8d..b8f5a7ba 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -25,14 +25,20 @@ extension NostrRelayManager: NetworkActivationRelayControlling {} extension TorURLSession: NetworkActivationProxyControlling {} /// Coordinates when the app is allowed to start Tor and connect to Nostr relays. -/// Policy: permit start when either location permissions are authorized OR -/// there exists at least one mutual favorite. Otherwise, do not start. +/// Policy: permit start when (location permissions are authorized OR there +/// exists at least one mutual favorite) AND the device has a usable network +/// path. When there is provably no network at all we do not bootstrap Tor or +/// spin relay reconnects — that only wastes battery on a mesh-only/offline +/// device. BLE mesh is entirely independent of this gate. @MainActor final class NetworkActivationService: ObservableObject { static let shared = NetworkActivationService() @Published private(set) var activationAllowed: Bool = false @Published private(set) var userTorEnabled: Bool = true + /// Coarse, debounced network reachability. `false` only when the OS reports + /// no usable interface at all. Surfaced for UI ("offline" vs "connecting"). + @Published private(set) var isNetworkReachable: Bool = true private var cancellables = Set() private var started = false @@ -43,6 +49,7 @@ final class NetworkActivationService: ObservableObject { private let mutualFavoritesPublisher: AnyPublisher, Never> private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set + private let reachabilityMonitor: NetworkReachabilityMonitoring private let torController: NetworkActivationTorControlling // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared // (via its live dependencies), so capturing NostrRelayManager.shared here would @@ -58,6 +65,7 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } + reachabilityMonitor = NWPathReachabilityMonitor() torController = TorManager.shared relayControllerProvider = { NostrRelayManager.shared } proxyController = TorURLSession.shared @@ -70,6 +78,7 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher: AnyPublisher, Never>, permissionProvider: @escaping () -> LocationChannelManager.PermissionState, mutualFavoritesProvider: @escaping () -> Set, + reachabilityMonitor: NetworkReachabilityMonitoring, torController: NetworkActivationTorControlling, relayController: NetworkActivationRelayControlling, proxyController: NetworkActivationProxyControlling, @@ -80,6 +89,7 @@ final class NetworkActivationService: ObservableObject { self.mutualFavoritesPublisher = mutualFavoritesPublisher self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider + self.reachabilityMonitor = reachabilityMonitor self.torController = torController self.relayControllerProvider = { relayController } self.proxyController = proxyController @@ -96,8 +106,12 @@ final class NetworkActivationService: ObservableObject { userTorEnabled = true } + // Begin (idempotent) reachability monitoring and seed initial state. + reachabilityMonitor.start() + isNetworkReachable = reachabilityMonitor.isReachable + // Initial compute - let allowed = basePolicyAllowed() + let allowed = effectiveAllowed() activationAllowed = allowed torAutoStartDesired = allowed && userTorEnabled torController.setAutoStartAllowed(torAutoStartDesired) @@ -123,6 +137,21 @@ final class NetworkActivationService: ObservableObject { self?.reevaluate() } .store(in: &cancellables) + + // React to network reachability changes (debounced, unsatisfied-only). + reachabilityMonitor.reachabilityPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] reachable in + guard let self else { return } + guard reachable != self.isNetworkReachable else { return } + self.isNetworkReachable = reachable + SecureLogger.info( + "NetworkActivationService: isNetworkReachable -> \(reachable)", + category: .session + ) + self.reevaluate() + } + .store(in: &cancellables) } func setUserTorEnabled(_ enabled: Bool) { @@ -138,7 +167,7 @@ final class NetworkActivationService: ObservableObject { } private func reevaluate() { - let allowed = basePolicyAllowed() + let allowed = effectiveAllowed() let torDesired = allowed && userTorEnabled let statusChanged = allowed != activationAllowed let torChanged = torDesired != torAutoStartDesired @@ -163,12 +192,20 @@ final class NetworkActivationService: ObservableObject { } } + /// Base policy: who is allowed to use the network at all (permission or a + /// mutual favorite), ignoring current link state. private func basePolicyAllowed() -> Bool { let permOK = permissionProvider() == .authorized let hasMutual = !mutualFavoritesProvider().isEmpty return permOK || hasMutual } + /// Effective gate: base policy AND a usable network path. When there is + /// provably no network, Tor bootstrap and relay reconnects are suppressed. + private func effectiveAllowed() -> Bool { + basePolicyAllowed() && reachabilityMonitor.isReachable + } + private func applyTorState(torDesired: Bool) { proxyController.setProxyMode(useTor: torDesired) if torDesired { diff --git a/bitchat/Services/NetworkReachabilityMonitor.swift b/bitchat/Services/NetworkReachabilityMonitor.swift new file mode 100644 index 00000000..53bb0677 --- /dev/null +++ b/bitchat/Services/NetworkReachabilityMonitor.swift @@ -0,0 +1,179 @@ +import Foundation +import Combine +import BitLogger +#if canImport(Network) +import Network +#endif + +/// Coarse, conservative network-reachability signal used to gate Tor bootstrap +/// and Nostr relay connections. +/// +/// Policy (deliberately conservative): +/// - Reports `false` only when the OS says there is *no* usable interface at +/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays +/// `true` because Tor tolerates intermittent connectivity, and tearing down +/// on the first hiccup would cost more battery/latency than it saves. +/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping +/// does not thrash Tor/relay startup. +/// - Starts optimistic (`true`) so nothing is ever suppressed before the first +/// path evaluation arrives. +/// +/// BLE mesh must never consult this monitor — the mesh works fully offline. +@MainActor +protocol NetworkReachabilityMonitoring: AnyObject { + /// Current debounced coarse reachability. + var isReachable: Bool { get } + /// Emits the debounced reachability whenever it changes (main-actor). + var reachabilityPublisher: AnyPublisher { get } + /// Begin monitoring. Idempotent. + func start() +} + +/// Pure debounce/decision logic for reachability, split out so it can be +/// unit-tested without the Network framework or real timers. +/// +/// A candidate state only becomes the committed state once it has been stable +/// (uninterrupted) for `interval`. Any observation matching the committed state +/// cancels a pending opposite change, which is what makes flapping a no-op. +struct ReachabilityDebounce { + let interval: TimeInterval + private(set) var committed: Bool + private var pending: (value: Bool, since: Date)? + + init(interval: TimeInterval, initial: Bool) { + self.interval = interval + self.committed = initial + } + + /// Whether a change is currently waiting out the debounce window. + var hasPendingChange: Bool { pending != nil } + + /// Time left before the pending change may commit, or `nil` when nothing + /// is pending. Lets callers schedule a flush at the true deadline instead + /// of a full interval from "now" (duplicate observations must not push + /// the deadline out). + func pendingRemaining(at now: Date) -> TimeInterval? { + guard let pending else { return nil } + return max(0, interval - now.timeIntervalSince(pending.since)) + } + + /// Feed a raw observation. Returns the new committed value if it changed, + /// otherwise `nil`. + mutating func observe(reachable: Bool, at now: Date) -> Bool? { + if reachable == committed { + // Already in this state — cancel any pending opposite change. + pending = nil + return nil + } + // Differs from committed: (re)arm the pending change, preserving the + // timestamp if we're already waiting on this same target value. + if pending?.value != reachable { + pending = (reachable, now) + } + return commitIfAged(at: now) + } + + /// Called from a timer to commit a pending change once it has aged past + /// `interval`. Returns the new committed value if it changed, else `nil`. + mutating func flush(at now: Date) -> Bool? { + commitIfAged(at: now) + } + + private mutating func commitIfAged(at now: Date) -> Bool? { + guard let pending else { return nil } + guard now.timeIntervalSince(pending.since) >= interval else { return nil } + committed = pending.value + self.pending = nil + return committed + } +} + +/// Always-reachable stub. Used as the default in tests and as the fallback on +/// platforms without the Network framework, so reachability never suppresses +/// startup by itself. +@MainActor +final class AlwaysReachableMonitor: NetworkReachabilityMonitoring { + var isReachable: Bool { true } + var reachabilityPublisher: AnyPublisher { + Empty(completeImmediately: false).eraseToAnyPublisher() + } + func start() {} +} + +/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the +/// background path callback hops here before touching the debounce. +@MainActor +final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { + private let subject: CurrentValueSubject + private var debounce: ReachabilityDebounce + private var flushWorkItem: DispatchWorkItem? + private var started = false + private let now: () -> Date + + #if canImport(Network) + private var monitor: NWPathMonitor? + private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability") + #endif + + init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) { + self.now = now + self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true) + self.subject = CurrentValueSubject(true) + } + + var isReachable: Bool { subject.value } + + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + + func start() { + guard !started else { return } + started = true + #if canImport(Network) + let monitor = NWPathMonitor() + self.monitor = monitor + monitor.pathUpdateHandler = { [weak self] path in + // Conservative: only "no interface at all" counts as unreachable. + let reachable = path.status != .unsatisfied + Task { @MainActor in + self?.ingest(reachable: reachable) + } + } + monitor.start(queue: monitorQueue) + #else + // No Network framework: never suppress startup. + #endif + } + + /// Feed an observation into the debounce and publish committed changes. + /// Exposed internally so higher layers/tests could drive it if needed. + func ingest(reachable: Bool) { + flushWorkItem?.cancel() + flushWorkItem = nil + if let committed = debounce.observe(reachable: reachable, at: now()) { + publish(committed) + } else if debounce.hasPendingChange { + scheduleFlush() + } + } + + private func scheduleFlush() { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if let committed = self.debounce.flush(at: self.now()) { + self.publish(committed) + } + } + flushWorkItem = work + // Fire at the pending change's real deadline (pending.since + interval): + // duplicate path updates re-enter here and must not restart the window. + let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + + private func publish(_ reachable: Bool) { + SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session) + subject.send(reachable) + } +} diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 70e43c0d..3e0aae6b 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -153,11 +153,11 @@ enum EncryptionStatus: Equatable { final class NoiseEncryptionService { // Static identity key (persistent across sessions) private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey - public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey + let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey // Ed25519 signing key (persistent across sessions) private let signingKey: Curve25519.Signing.PrivateKey - public let signingPublicKey: Curve25519.Signing.PublicKey + let signingPublicKey: Curve25519.Signing.PublicKey // Session manager private let sessionManager: NoiseSessionManager @@ -172,6 +172,10 @@ final class NoiseEncryptionService { // Security components private let rateLimiter = NoiseRateLimiter() private let keychain: KeychainManagerProtocol + + // One-time prekeys for forward-secret courier sealing (lazy generation + // inside the store; the batch is minted on first bundle build). + private let localPrekeys: LocalPrekeyStore // Session maintenance private var rekeyTimer: Timer? @@ -200,6 +204,7 @@ final class NoiseEncryptionService { init(keychain: KeychainManagerProtocol) { self.keychain = keychain + self.localPrekeys = LocalPrekeyStore(keychain: keychain) // BCH-01-009: Load or create static identity key with proper error handling let loadedKey: Curve25519.KeyAgreement.PrivateKey @@ -369,13 +374,154 @@ final class NoiseEncryptionService { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation } - + + // MARK: - Courier Envelopes (one-way Noise X) + + /// Domain separation for courier envelopes so X-pattern transcripts can + /// never be confused with interactive XX handshakes. + private static let courierPrologue = Data("bitchat-courier-v1".utf8) + + /// Encrypt a payload to a peer's known static key without an interactive + /// handshake (Noise X pattern). Used for store-and-forward envelopes + /// carried by couriers while the recipient is offline. + /// - Warning: One-way messages have no forward secrecy: a later compromise + /// of the recipient's static key exposes envelopes captured in transit. + /// Use established sessions whenever the peer is reachable. + func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data { + let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey) + let handshake = NoiseHandshakeState( + role: .initiator, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + remoteStaticKey: remoteKey, + prologue: Self.courierPrologue + ) + return try handshake.writeMessage(payload: payload) + } + + /// Decrypt a courier envelope addressed to our static key. Returns the + /// payload and the sender's authenticated static public key (the `ss` + /// DH in the X pattern binds the sender's identity to the ciphertext). + func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) { + let handshake = NoiseHandshakeState( + role: .responder, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + prologue: Self.courierPrologue + ) + let payload = try handshake.readMessage(envelopeCiphertext) + guard let senderKey = handshake.getRemoteStaticPublicKey() else { + throw NoiseError.missingKeys + } + return (payload: payload, senderStaticKey: senderKey.rawRepresentation) + } + + // MARK: - One-Time Prekey Envelopes (forward-secret Noise X) + + /// Domain separation for prekey-sealed envelopes: distinct from both the + /// interactive XX transcripts and static-sealed courier envelopes, and + /// bound to the specific prekey ID so a ciphertext cannot be replayed + /// against a different prekey. + private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8) + + private static func prekeyPrologue(for prekeyID: UInt32) -> Data { + var prologue = prekeyProloguePrefix + var big = prekeyID.bigEndian + withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) } + return prologue + } + + /// Encrypt a payload to one of the recipient's gossiped one-time prekeys + /// (Noise X where the responder static is the prekey, not the identity + /// key). Unlike `sealCourierPayload`, this is forward secret: once the + /// recipient consumes the prekey and its grace window lapses, the private + /// key is deleted and captured ciphertext becomes undecryptable even if + /// the recipient's identity key is later compromised. The initiator's + /// static still rides inside (encrypted), so the recipient authenticates + /// the sender exactly as with static-sealed envelopes. + func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data { + let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey) + let handshake = NoiseHandshakeState( + role: .initiator, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + remoteStaticKey: remoteKey, + prologue: Self.prekeyPrologue(for: recipientPrekey.id) + ) + return try handshake.writeMessage(payload: payload) + } + + /// Decrypt an envelope sealed to one of our one-time prekeys. On success + /// the prekey is marked consumed (its private key survives a 48h grace + /// window for spray-and-wait redeliveries, then is deleted for good). + /// Returns the payload, the sender's authenticated static key (same + /// contract as `openCourierPayload`), and whether this open actually + /// retired the prekey — false for a redelivery of already-consumed mail — + /// so the caller can re-gossip the shrunken bundle only when it changed. + func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) { + guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else { + throw NoiseEncryptionError.unknownPrekey + } + let handshake = NoiseHandshakeState( + role: .responder, + pattern: .X, + keychain: keychain, + localStaticKey: prekeyPrivate, + prologue: Self.prekeyPrologue(for: prekeyID) + ) + let payload = try handshake.readMessage(envelopeCiphertext) + guard let senderKey = handshake.getRemoteStaticPublicKey() else { + throw NoiseError.missingKeys + } + let consumedPrekey = localPrekeys.markConsumed(prekeyID) + return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey) + } + + /// Current signed prekey bundle for gossip, minting the initial batch on + /// first use. Nil only when signing fails. + func currentPrekeyBundle() -> PrekeyBundle? { + let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys() + guard !prekeys.isEmpty else { return nil } + let unsigned = PrekeyBundle( + noiseStaticPublicKey: getStaticPublicKeyData(), + prekeys: prekeys, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + guard let signature = signData(unsigned.signableBytes()) else { return nil } + return PrekeyBundle( + noiseStaticPublicKey: unsigned.noiseStaticPublicKey, + prekeys: prekeys, + generatedAt: generatedAt, + signature: signature + ) + } + + /// Verify a peer's bundle signature against their announce-bound Ed25519 + /// signing key. + func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool { + verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey) + } + + /// Prune dead prekeys and top the batch back up when consumption runs it + /// low. Returns true when the published bundle changed and should be + /// re-gossiped. + @discardableResult + func replenishPrekeysIfNeeded() -> Bool { + localPrekeys.replenishIfNeeded() + } + /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { // Clear from keychain let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) + // One-time prekey privates go with the identity they were bound to. + localPrekeys.wipe() SecureLogger.warning("Panic mode activated - identity cleared", category: .security) // Stop rekey timer stopRekeyTimer() @@ -428,7 +574,7 @@ final class NoiseEncryptionService { private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { var out = Data() // context - let context = "bitchat-announce-v1".data(using: .utf8) ?? Data() + let context = Data("bitchat-announce-v1".utf8) out.append(UInt8(min(context.count, 255))) out.append(context.prefix(255)) // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) @@ -444,7 +590,7 @@ final class NoiseEncryptionService { out.append(ed32) if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } // nickname length + bytes - let nickData = nickname.data(using: .utf8) ?? Data() + let nickData = Data(nickname.utf8) out.append(UInt8(min(nickData.count, 255))) out.append(nickData.prefix(255)) // timestamp @@ -769,4 +915,7 @@ struct NoiseMessage: Codable { enum NoiseEncryptionError: Error { case handshakeRequired case sessionNotEstablished + /// Envelope references a prekey ID we don't hold (never ours, already + /// deleted after its grace window, or wiped in a panic). + case unknownPrekey } diff --git a/bitchat/Services/NostrProcessedEventStore.swift b/bitchat/Services/NostrProcessedEventStore.swift new file mode 100644 index 00000000..167d29d9 --- /dev/null +++ b/bitchat/Services/NostrProcessedEventStore.swift @@ -0,0 +1,106 @@ +// +// NostrProcessedEventStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes +/// gift-wrap timestamps, so DM subscriptions must look back generously (24h) +/// and relays redeliver the same events on every launch — without a +/// cross-launch record, each relaunch reprocesses old PMs and acks +/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise). +/// +/// Contents are event IDs already visible to every relay, so +/// until-first-unlock file protection is the right at-rest posture — the +/// file must also load during a locked-background restoration relaunch. +/// Wiped on panic via the dedup service's clear paths. +final class NostrProcessedEventStore { + private let fileURL: URL? + // All file access is serialized here: appends are read-modify-write, and + // overlapping debounced flushes would otherwise race and drop IDs. + private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility) + + init(fileURL: URL? = nil) { + self.fileURL = fileURL ?? Self.defaultFileURL() + } + + /// Processed event IDs, oldest first (insertion order). + func load() -> [String] { + ioQueue.sync { loadLocked() } + } + + /// Merge new IDs onto the persisted record, oldest-first, trimming from + /// the front past `cap`. Append-merge (not snapshot-overwrite) so the + /// in-memory cache being cleared transiently (channel switches) can + /// never shrink the on-disk record. + func append(_ newIDs: [String], cap: Int) { + guard !newIDs.isEmpty else { return } + ioQueue.async { [self] in + var merged = loadLocked() + var known = Set(merged) + for id in newIDs where !known.contains(id) { + merged.append(id) + known.insert(id) + } + if merged.count > cap { + merged.removeFirst(merged.count - cap) + } + saveLocked(merged) + } + } + + func wipe() { + ioQueue.async { [self] in + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } + } + + private func loadLocked() -> [String] { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let ids = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + return ids + } + + private func saveLocked(_ eventIDs: [String]) { + guard let fileURL else { return } + guard !eventIDs.isEmpty else { + try? FileManager.default.removeItem(at: fileURL) + return + } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(eventIDs) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session) + } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("nostr", isDirectory: true) + .appendingPathComponent("processed-events.json") + } +} diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 380593fb..2d1c380b 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -13,8 +13,42 @@ final class NostrTransport: Transport, @unchecked Sendable { let currentIdentity: @MainActor () throws -> NostrIdentity? let registerPendingGiftWrap: @MainActor (String) -> Void let sendEvent: @MainActor (NostrEvent) -> Void - let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void + /// Emits whether a relay that carries private messages is up + /// (fail-closed behind Tor). A connected geohash/custom relay alone + /// doesn't count: DM sends target the default relay set and would + /// still queue. + let relayConnectivity: @MainActor () -> AnyPublisher + /// Paces outbound acks. Defaults to an isolated pacer so tests don't + /// serialize behind each other; `live` passes the process-wide one. + let ackPacer: AckPacer + init( + notificationCenter: NotificationCenter, + loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship], + favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?, + favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?, + currentIdentity: @escaping @MainActor () throws -> NostrIdentity?, + registerPendingGiftWrap: @escaping @MainActor (String) -> Void, + sendEvent: @escaping @MainActor (NostrEvent) -> Void, + scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void, + relayConnectivity: @escaping @MainActor () -> AnyPublisher, + ackPacer: AckPacer? = nil + ) { + self.notificationCenter = notificationCenter + self.loadFavorites = loadFavorites + self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey + self.favoriteStatusForPeerID = favoriteStatusForPeerID + self.currentIdentity = currentIdentity + self.registerPendingGiftWrap = registerPendingGiftWrap + self.sendEvent = sendEvent + self.relayConnectivity = relayConnectivity + // Default pacer drives its throttle through the same injected + // scheduler, so tests that step scheduleAfter manually keep + // control of the ack cadence. + self.ackPacer = ackPacer ?? AckPacer(scheduleAfter: scheduleAfter) + } + + @MainActor static func live(idBridge: NostrIdentityBridge) -> Dependencies { Dependencies( notificationCenter: .default, @@ -26,7 +60,9 @@ final class NostrTransport: Transport, @unchecked Sendable { sendEvent: { NostrRelayManager.shared.sendEvent($0) }, scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) - } + }, + relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }, + ackPacer: NostrTransport.sharedAckPacer ) } } @@ -34,31 +70,81 @@ final class NostrTransport: Transport, @unchecked Sendable { // Provide BLE short peer ID for BitChat embedding var senderPeerID = PeerID(str: "") - // Throttle READ receipts to avoid relay rate limits - private struct QueuedRead { - let receipt: ReadReceipt - let peerID: PeerID + // Throttle outbound acks — READ receipts and DELIVERED acks, direct and + // geohash — to avoid relay rate limits. Reconnect redelivery produces a + // burst of acks at once: 8 DELIVERED in under a second tripped damus's + // "noting too much" during July 2026 device testing. + private enum QueuedAck { + case readDirect(ReadReceipt, PeerID) + case deliveredDirect(messageID: String, peerID: PeerID) + case deliveredGeohash(messageID: String, recipientHex: String, identity: NostrIdentity) + case readGeohash(messageID: String, recipientHex: String, identity: NostrIdentity) } - private var readQueue: [QueuedRead] = [] - private var isSendingReadAcks = false - private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval - private let keychain: KeychainManagerProtocol - private let idBridge: NostrIdentityBridge + + /// Ack pacing shared across transport instances. Geohash acks are sent + /// through short-lived transports created per ack + /// (`makeGeohashNostrTransport()`), so a per-instance queue would only + /// ever hold one item and never pace a burst (flagged by Codex on + /// #1398). Production wires `sharedAckPacer` via `Dependencies.live`; + /// tests get an isolated instance per `Dependencies` by default. + /// @unchecked Sendable: all mutable state (`pending`, `isSending`) is + /// confined to the serial `queue`; the class is only touched via + /// `enqueue` and the scheduler callback, both of which hop onto it. + final class AckPacer: @unchecked Sendable { + typealias Scheduler = @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void + + private let queue = DispatchQueue(label: "chat.bitchat.nostr-ack-pacer") + private var pending: [() -> Void] = [] + private var isSending = false + private let interval: TimeInterval = TransportConfig.nostrReadAckInterval + private let scheduleAfter: Scheduler + + init(scheduleAfter: @escaping Scheduler = { delay, action in + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action) + }) { + self.scheduleAfter = scheduleAfter + } + + func enqueue(_ send: @escaping () -> Void) { + queue.async { + self.pending.append(send) + self.processNext() + } + } + + /// Must be called on `queue`. + private func processNext() { + guard !isSending, !pending.isEmpty else { return } + isSending = true + let send = pending.removeFirst() + send() + scheduleAfter(interval) { [weak self] in + guard let self else { return } + self.queue.async { + self.isSending = false + self.processNext() + } + } + } + } + static let sharedAckPacer = AckPacer() private let dependencies: Dependencies private var favoriteStatusObserver: NSObjectProtocol? // Reachability Cache (thread-safe) private var reachablePeers: Set = [] + // Mirror of the relay manager's connection state, cached here because + // canDeliverPromptly is called synchronously off the main actor. + private var relaysConnected = false + private var relayConnectivityCancellable: AnyCancellable? private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) @MainActor init( - keychain: KeychainManagerProtocol, + keychain _: KeychainManagerProtocol, idBridge: NostrIdentityBridge, dependencies: Dependencies? = nil ) { - self.keychain = keychain - self.idBridge = idBridge self.dependencies = dependencies ?? .live(idBridge: idBridge) setupObservers() @@ -72,6 +158,12 @@ final class NostrTransport: Transport, @unchecked Sendable { queue.sync(flags: .barrier) { self.reachablePeers = Set(reachable) } + + relayConnectivityCancellable = self.dependencies.relayConnectivity() + .sink { [weak self] connected in + guard let self else { return } + self.queue.async(flags: .barrier) { self.relaysConnected = connected } + } } deinit { @@ -109,9 +201,6 @@ final class NostrTransport: Transport, @unchecked Sendable { weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? - var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { - Just([]).eraseToAnyPublisher() - } func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] } var myPeerID: PeerID { senderPeerID } @@ -125,19 +214,32 @@ final class NostrTransport: Transport, @unchecked Sendable { func isPeerConnected(_ peerID: PeerID) -> Bool { false } func isPeerReachable(_ peerID: PeerID) -> Bool { - queue.sync { - // Check if exact match + // Callers address peers by either the short 16-hex ID or the full + // 64-hex noise key (offline favorites), so compare in short form. + let short = peerID.toShort() + return queue.sync { if reachablePeers.contains(peerID) { return true } - // Check for short ID match - if peerID.isShort { - return reachablePeers.contains(where: { $0.toShort() == peerID }) - } - return false + return reachablePeers.contains(where: { $0.toShort() == short }) } } - + + func canDeliverPromptly(to peerID: PeerID) -> Bool { + // A known npub makes a peer "reachable", but with no relay + // connection a send only joins the local queue. Answering honestly + // here lets the router hand a sealed copy to a courier in parallel + // instead of waiting for internet that may never come. + isPeerReachable(peerID) && queue.sync { relaysConnected } + } + + func canDeliverSecurely(to peerID: PeerID) -> Bool { + // Nostr has no link bindings to forge; a known recipient key plus a + // connected relay is the strongest delivery signal it has. The router + // already retains + couriers for Nostr sends, so keep that behavior. + canDeliverPromptly(to: peerID) + } + func peerNickname(peerID: PeerID) -> String? { nil } - func getPeerNicknames() -> [PeerID : String] { [:] } + func getPeerNicknames() -> [PeerID: String] { [:] } func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } @@ -164,12 +266,14 @@ final class NostrTransport: Transport, @unchecked Sendable { } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - // Enqueue and process with throttling to avoid relay rate limits - // Use barrier to synchronize access to readQueue - queue.async(flags: .barrier) { - self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) - self.processReadQueueIfNeeded() - } + enqueueAck(.readDirect(receipt, peerID)) + } + + /// Enqueue an ack for paced sending. Captures self strongly on purpose: + /// geohash acks ride throwaway transport instances that must stay alive + /// until their ack leaves the queue. + private func enqueueAck(_ ack: QueuedAck) { + dependencies.ackPacer.enqueue { self.sendAckItem(ack) } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { @@ -189,17 +293,7 @@ final class NostrTransport: Transport, @unchecked Sendable { func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { - Task { @MainActor in - guard let recipientNpub = resolveRecipientNpub(for: peerID), - let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? dependencies.currentIdentity() else { return } - SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session) - guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { - SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) - return - } - sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) - } + enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID)) } } @@ -209,19 +303,11 @@ extension NostrTransport { // MARK: Geohash ACK helpers func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - Task { @MainActor in - SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session) - guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) - } + enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity)) } func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - Task { @MainActor in - SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session) - guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) - } + enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity)) } // MARK: Geohash DMs (per-geohash identity) @@ -267,36 +353,42 @@ extension NostrTransport { dependencies.sendEvent(event) } - /// Must be called within a barrier on `queue` - private func processReadQueueIfNeeded() { - guard !isSendingReadAcks else { return } - guard !readQueue.isEmpty else { return } - isSendingReadAcks = true - let item = readQueue.removeFirst() - sendReadAckItem(item) - } - /// Sends a single read ack item (called after extraction from queue within barrier) - private func sendReadAckItem(_ item: QueuedRead) { + /// Sends a single ack item (invoked by the pacer, one per interval) + private func sendAckItem(_ item: QueuedAck) { Task { @MainActor in - defer { scheduleNextReadAck() } - guard let recipientNpub = resolveRecipientNpub(for: item.peerID), - let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? dependencies.currentIdentity() else { return } - SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session) - guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { - SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) - return - } - sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) - } - } + switch item { + case .readDirect(let receipt, let peerID): + guard let recipientNpub = resolveRecipientNpub(for: peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? dependencies.currentIdentity() else { return } + SecureLogger.debug("NostrTransport: preparing READ ack id=\(receipt.originalMessageID.prefix(8))…", category: .session) + guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { + SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) + return + } + sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) - private func scheduleNextReadAck() { - dependencies.scheduleAfter(readAckInterval) { [weak self] in - self?.queue.async(flags: .barrier) { [weak self] in - self?.isSendingReadAcks = false - self?.processReadQueueIfNeeded() + case .deliveredDirect(let messageID, let peerID): + guard let recipientNpub = resolveRecipientNpub(for: peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? dependencies.currentIdentity() else { return } + SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session) + guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { + SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) + return + } + sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) + + case .deliveredGeohash(let messageID, let recipientHex, let identity): + SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session) + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) + + case .readGeohash(let messageID, let recipientHex, let identity): + SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session) + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) } } } diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index d51efa86..4b437798 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -26,6 +26,10 @@ protocol NotificationRequestDelivering { func add(_ request: UNNotificationRequest) } +protocol NotificationCategoryRegistering { + func setCategories(_ categories: Set) +} + private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing { private let center: UNUserNotificationCenter @@ -55,6 +59,18 @@ private final class NotificationCenterRequestDelivererAdapter: NotificationReque } } +private final class NotificationCenterCategoryRegistrarAdapter: NotificationCategoryRegistering { + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter) { + self.center = center + } + + func setCategories(_ categories: Set) { + center.setNotificationCategories(categories) + } +} + private struct NoopNotificationAuthorizer: NotificationAuthorizing { func requestAuthorization( options: UNAuthorizationOptions, @@ -68,12 +84,21 @@ private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering { func add(_ request: UNNotificationRequest) {} } +private struct NoopNotificationCategoryRegistrar: NotificationCategoryRegistering { + func setCategories(_ categories: Set) {} +} + final class NotificationService { static let shared = NotificationService() + /// Category for the "bitchatters nearby" notification, carrying the wave quick action. + static let nearbyCategoryID = "chat.bitchat.category.nearby" + static let waveActionID = "chat.bitchat.action.wave" + private let isRunningTestsProvider: () -> Bool private let authorizer: NotificationAuthorizing private let requestDeliverer: NotificationRequestDelivering + private let categoryRegistrar: NotificationCategoryRegistering /// Returns true if running in test environment (XCTest, Swift Testing, or CI) private var isRunningTests: Bool { @@ -92,26 +117,31 @@ final class NotificationService { if isRunningTestsProvider() { self.authorizer = NoopNotificationAuthorizer() self.requestDeliverer = NoopNotificationRequestDeliverer() + self.categoryRegistrar = NoopNotificationCategoryRegistrar() } else { let center = UNUserNotificationCenter.current() self.authorizer = NotificationCenterAuthorizerAdapter(center: center) self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center) + self.categoryRegistrar = NotificationCenterCategoryRegistrarAdapter(center: center) } } internal init( isRunningTestsProvider: @escaping () -> Bool, authorizer: NotificationAuthorizing, - requestDeliverer: NotificationRequestDelivering + requestDeliverer: NotificationRequestDelivering, + categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar() ) { self.isRunningTestsProvider = isRunningTestsProvider self.authorizer = authorizer self.requestDeliverer = requestDeliverer + self.categoryRegistrar = categoryRegistrar } func requestAuthorization() { guard !isRunningTests else { return } - authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + registerCategories() + authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in if granted { // Permission granted } else { @@ -119,13 +149,29 @@ final class NotificationService { } } } + + private func registerCategories() { + let wave = UNNotificationAction( + identifier: Self.waveActionID, + title: String(localized: "notification.action.wave", comment: "Title of the notification action button that sends a friendly wave back to a nearby person"), + options: [] + ) + let nearby = UNNotificationCategory( + identifier: Self.nearbyCategoryID, + actions: [wave], + intentIdentifiers: [], + options: [] + ) + categoryRegistrar.setCategories([nearby]) + } func sendLocalNotification( title: String, body: String, identifier: String, userInfo: [String: Any]? = nil, - interruptionLevel: UNNotificationInterruptionLevel = .active + interruptionLevel: UNNotificationInterruptionLevel = .active, + categoryIdentifier: String? = nil ) { guard !isRunningTests else { return } let content = UNMutableNotificationContent() @@ -133,6 +179,9 @@ final class NotificationService { content.body = body content.sound = .default content.interruptionLevel = interruptionLevel + if let categoryIdentifier = categoryIdentifier { + content.categoryIdentifier = categoryIdentifier + } if let userInfo = userInfo { content.userInfo = userInfo @@ -183,7 +232,8 @@ final class NotificationService { title: title, body: body, identifier: identifier, - interruptionLevel: .timeSensitive + interruptionLevel: .timeSensitive, + categoryIdentifier: Self.nearbyCategoryID ) } } diff --git a/bitchat/Services/Prekeys/LocalPrekeyStore.swift b/bitchat/Services/Prekeys/LocalPrekeyStore.swift new file mode 100644 index 00000000..26f9ec67 --- /dev/null +++ b/bitchat/Services/Prekeys/LocalPrekeyStore.swift @@ -0,0 +1,228 @@ +// +// LocalPrekeyStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CryptoKit +import Foundation + +/// Owns this device's one-time Curve25519 prekey private keys. +/// +/// Privates persist in the Keychain (single blob, same protection class as +/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the +/// gossiped bundle; when consumption drops the unconsumed count below +/// `replenishThreshold`, the batch tops back up and the bundle's +/// `generatedAt` bumps so peers replace their cached copy. +/// +/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext +/// (or a re-seal of the same message to the same prekey ID) can arrive via +/// several couriers days apart. A consumed prekey's private key is therefore +/// retained for `consumedGraceSeconds` after first use and only then deleted. +/// Tradeoff: during the grace window a compromise of the device still exposes +/// mail sealed to that prekey — the forward-secrecy clock starts at deletion, +/// not at first open. Refusing new ciphertexts while accepting redeliveries +/// is not possible (the recipient cannot distinguish them), so the window is +/// kept short and fixed. +final class LocalPrekeyStore { + struct Record: Codable { + let id: UInt32 + let privateKey: Data + let createdAt: Date + var consumedAt: Date? + } + + private struct Persisted: Codable { + var records: [Record] + var nextID: UInt32 + var generatedAt: UInt64 + } + + enum Policy { + static let batchSize = PrekeyBundle.maxPrekeys + static let replenishThreshold = 3 + /// How long a consumed prekey private survives for duplicate courier + /// deliveries of mail sealed to it. + static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60 + /// Unconsumed prekeys older than this are rotated out: no honest + /// sender seals to a bundle that stale (see + /// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`). + static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60 + } + + private static let keychainKey = "prekeysV1" + + private let keychain: KeychainManagerProtocol + private let now: () -> Date + private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local") + + // Guarded by `queue`. + private var records: [Record] = [] + private var nextID: UInt32 = 0 + private var generatedAt: UInt64 = 0 + private var loaded = false + + init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) { + self.keychain = keychain + self.now = now + } + + // MARK: - Bundle contents (public prekeys) + + /// Unconsumed public prekeys for the gossiped bundle, generating the + /// initial batch on first use. Sorted by ID for canonical signing bytes. + func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) { + queue.sync { + loadLocked() + _ = replenishLocked() + let prekeys = records + .filter { $0.consumedAt == nil } + .sorted { $0.id < $1.id } + .compactMap { record -> PrekeyBundle.Prekey? in + guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil } + return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation) + } + return (prekeys, generatedAt) + } + } + + // MARK: - Opening (private prekeys) + + /// Private key for a prekey ID: unconsumed, or consumed within the + /// redelivery grace window. + func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? { + queue.sync { + loadLocked() + let date = now() + guard let record = records.first(where: { $0.id == id }) else { return nil } + if let consumedAt = record.consumedAt, + date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds { + return nil + } + return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) + } + } + + /// Marks a prekey consumed (starts its grace clock). Idempotent: a + /// redelivery within the grace window does not restart the clock. + /// + /// Returns true when this call actually retired a prekey, i.e. the + /// published bundle shrank. Consuming a prekey drops it from + /// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too: + /// otherwise peers that cached the old bundle reject the same-`generatedAt` + /// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed + /// ID, and their mail starts failing `unknownPrekey` once the 48h grace + /// lapses. The caller re-gossips on a true result. + @discardableResult + func markConsumed(_ id: UInt32) -> Bool { + queue.sync { + loadLocked() + guard let index = records.firstIndex(where: { $0.id == id }), + records[index].consumedAt == nil else { return false } + records[index].consumedAt = now() + advanceGeneratedAtLocked() + persistLocked() + return true + } + } + + /// Prunes dead prekeys and tops the unconsumed batch back up when it runs + /// low. Returns true when the published bundle changed (caller should + /// re-gossip). + @discardableResult + func replenishIfNeeded() -> Bool { + queue.sync { + loadLocked() + return replenishLocked() + } + } + + var unconsumedCount: Int { + queue.sync { + loadLocked() + return records.filter { $0.consumedAt == nil }.count + } + } + + /// Panic wipe: drop all prekey privates from memory and the Keychain. + func wipe() { + queue.sync { + records.removeAll() + nextID = 0 + generatedAt = 0 + loaded = true + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey) + } + } + + // MARK: - Internals (call only on `queue`) + + private func replenishLocked() -> Bool { + let date = now() + + // Consumed prekeys past the grace window are gone for good; stale + // unconsumed ones rotate out (their bundle is too old to seal to). + let recordsBefore = records.count + let unconsumedBefore = records.filter { $0.consumedAt == nil }.count + records.removeAll { record in + if let consumedAt = record.consumedAt { + return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds + } + return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds + } + // Only a change to the *unconsumed* set alters the published bundle; + // grace-expired consumed keys were never in it. + let unconsumed = records.filter { $0.consumedAt == nil }.count + var bundleChanged = unconsumed != unconsumedBefore + + if unconsumed < Policy.replenishThreshold { + for _ in unconsumed.. +// + +import BitFoundation +import BitLogger +import Foundation + +/// Signature-verified one-time prekey bundles received from other peers. +/// +/// One bundle per Noise static key: a newer `generatedAt` replaces the cached +/// copy, keeping the IDs we already sealed with marked used so a prekey is +/// never reused across messages. Assignments are remembered per message ID so +/// deposit retries of the same message re-use its prekey (and its budget) +/// instead of burning a fresh one per courier. +/// +/// Only public key material lives here; it persists to disk so a sender can +/// prekey-seal for recipients met long ago. Included in the panic wipe. +final class PrekeyBundleStore { + struct StoredBundle: Codable { + // noiseKey is read in loadFromDisk (dictionary keying), but the + // Periphery indexer intermittently misses that read and flaked CI + // with "assign-only" — even past its baselined USR. Covered + // deterministically by retain_codable_properties in .periphery.yml + // (an in-source ignore can't work: strict mode flags it as + // superfluous on the runs where the indexer gets it right). + let noiseKey: Data + var generatedAt: UInt64 + var prekeyIDs: [UInt32] + var prekeyPublicKeys: [Data] + /// IDs this device already sealed with (never reused). + var usedIDs: Set + /// messageID → prekey ID, so re-deposits of one message share one prekey. + var assignments: [String: UInt32] + var updatedAt: Date + } + + enum Limits { + static let maxPeers = 200 + /// Don't seal to bundles older than this: the owner may have rotated + /// the unconsumed keys out (see `LocalPrekeyStore.Policy`). + static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60 + } + + static let shared = PrekeyBundleStore() + + private var bundles: [Data: StoredBundle] = [:] + private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles") + private let fileURL: URL? + private let maxPeers: Int + private let now: () -> Date + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init( + persistsToDisk: Bool = true, + fileURL: URL? = nil, + maxPeers: Int = Limits.maxPeers, + now: @escaping () -> Date = Date.init + ) { + self.now = now + self.maxPeers = maxPeers + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Ingest + + /// Stores a bundle whose signature the caller has already verified + /// against the owner's announce-bound signing key. Returns false when an + /// equal-or-newer bundle is already cached (nothing changed). + @discardableResult + func ingest(_ bundle: PrekeyBundle) -> Bool { + guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength, + !bundle.prekeys.isEmpty else { return false } + return queue.sync { + if let existing = bundles[bundle.noiseStaticPublicKey], + existing.generatedAt >= bundle.generatedAt { + return false + } + let previous = bundles[bundle.noiseStaticPublicKey] + let newIDs = Set(bundle.prekeys.map(\.id)) + // Keep consumption state for IDs the fresh bundle still offers + // (a top-up keeps the owner's unconsumed keys); drop the rest. + let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs) + let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) } + bundles[bundle.noiseStaticPublicKey] = StoredBundle( + noiseKey: bundle.noiseStaticPublicKey, + generatedAt: bundle.generatedAt, + prekeyIDs: bundle.prekeys.map(\.id), + prekeyPublicKeys: bundle.prekeys.map(\.publicKey), + usedIDs: carriedUsed, + assignments: carriedAssignments, + updatedAt: now() + ) + enforceCapLocked() + persistLocked() + return true + } + } + + // MARK: - Sealing support + + /// Whether an unexpired bundle with sealable prekeys is cached for a peer. + func hasUsableBundle(for noiseKey: Data) -> Bool { + queue.sync { + guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false } + return bundle.usedIDs.count < bundle.prekeyIDs.count + } + } + + /// The prekey to seal a message with: the message's existing assignment if + /// any (re-deposits reuse it), else the lowest unused ID, which is then + /// marked used. Nil when no fresh bundle is cached or all its prekeys are + /// spent — callers fall back to static sealing. + func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { + queue.sync { + guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil } + + if let assigned = bundle.assignments[messageID], + let index = bundle.prekeyIDs.firstIndex(of: assigned) { + return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index]) + } + + guard let index = bundle.prekeyIDs.indices + .filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) }) + .min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else { + return nil + } + let id = bundle.prekeyIDs[index] + bundle.usedIDs.insert(id) + bundle.assignments[messageID] = id + bundle.updatedAt = now() + bundles[recipientNoiseKey] = bundle + persistLocked() + return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index]) + } + } + + // MARK: - Maintenance + + /// Panic wipe: drop all cached bundles from memory and disk. + func wipe() { + queue.sync { + bundles.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + } + + // MARK: - Internals (call only on `queue`) + + private func isFreshLocked(_ bundle: StoredBundle) -> Bool { + let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000 + return ageSeconds <= Limits.maxBundleAgeForSealingSeconds + } + + private func enforceCapLocked() { + while bundles.count > maxPeers { + guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return } + bundles.removeValue(forKey: victim.key) + } + } + + private func persistLocked() { + guard let fileURL else { return } + do { + if bundles.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(Array(bundles.values)) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security) + } + } + + private func loadFromDisk() { + guard let fileURL else { return } + queue.sync { + guard let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else { + return + } + for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count { + bundles[bundle.noiseKey] = bundle + } + } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("prekeys", isDirectory: true) + .appendingPathComponent("bundles.json") + } +} diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 4633698e..977bdcb3 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -28,7 +28,6 @@ final class PrivateChatManager: ObservableObject { @Published private(set) var selectedPeer: PeerID? = nil private var selectedPeerMirrorCancellable: AnyCancellable? = nil - private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // Made accessible for ChatViewModel weak var meshService: Transport? @@ -209,7 +208,7 @@ final class PrivateChatManager: ObservableObject { case .read, .delivered: externalReceipts.insert(message.id) sentReadReceipts.insert(message.id) - case .failed, .partiallyDelivered, .sending, .sent: + case .failed, .partiallyDelivered, .sending, .sent, .carried: break } } @@ -219,18 +218,13 @@ final class PrivateChatManager: ObservableObject { /// Start a private chat with a peer. Selection is mutated through the /// store's intent (the store owns it); the manager keeps its side - /// effects (fingerprint tracking, read receipts, unread clearing). + /// effects (read receipts, unread clearing). @MainActor func startChat(with peerID: PeerID) { // Also creates the conversation if needed and updates the derived // `selectedConversationID`; `selectedPeer` mirrors the change. conversationStore?.setSelectedPrivatePeer(peerID) - // Store fingerprint for persistence across reconnections - if let fingerprint = meshService?.getFingerprint(for: peerID) { - selectedPeerFingerprint = fingerprint - } - // Mark messages as read markAsRead(from: peerID) } @@ -239,15 +233,8 @@ final class PrivateChatManager: ObservableObject { /// channel's conversation). func endChat() { conversationStore?.setSelectedPrivatePeer(nil) - selectedPeerFingerprint = nil } - /// No-op since the `ConversationStore` cutover: the store maintains - /// chronological order and dedups by message ID on every insert, so the - /// per-append re-sort/dedup sweep this performed is no longer needed. - /// Kept only for API compatibility until step 5 removes the callers. - func sanitizeChat(for peerID: PeerID) {} - /// Mark messages from a peer as read @MainActor func markAsRead(from peerID: PeerID) { @@ -269,8 +256,6 @@ final class PrivateChatManager: ObservableObject { return } - sentReadReceipts.insert(message.id) - // Create read receipt using the simplified method let receipt = ReadReceipt( originalMessageID: message.id, @@ -281,11 +266,21 @@ final class PrivateChatManager: ObservableObject { // Route via MessageRouter to avoid handshakeRequired spam when session isn't established if let router = messageRouter { SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) - Task { @MainActor in - router.sendReadReceipt(receipt, to: senderPeerID) + let messageID = message.id + // Claim the receipt synchronously so a second read scan in the + // same runloop pass (chat open triggers two) can't route a + // duplicate; release the claim on a failed route (no reachable + // transport) so a later read scan retries instead of permanently + // losing the receipt. + sentReadReceipts.insert(messageID) + Task { @MainActor [weak self] in + if !router.sendReadReceipt(receipt, to: senderPeerID) { + self?.sentReadReceipts.remove(messageID) + } } } else { - // Fallback: preserve previous behavior + // Fallback: preserve previous behavior (best-effort mesh send). + sentReadReceipts.insert(message.id) meshService?.sendReadReceipt(receipt, to: senderPeerID) } } diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index 37fa8584..7dbcf4ab 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -12,16 +12,25 @@ struct RelayController { static func decide(ttl: UInt8, senderIsSelf: Bool, recipientIsSelf: Bool = false, - isEncrypted: Bool, + isEncrypted _: Bool, isDirectedEncrypted: Bool, isFragment: Bool, isDirectedFragment: Bool, isHandshake: Bool, isAnnounce: Bool, + isRequestSync: Bool = false, + isUrgentBoardPost: Bool = false, + isVoiceFrame: Bool = false, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) + // REQUEST_SYNC is link-local: never relay it, even when a peer crafts + // one with TTL headroom to turn every reachable node into a responder. + if isRequestSync { + return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) + } + // Suppress obvious non-relays if ttlCap <= 1 || senderIsSelf || recipientIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) @@ -38,7 +47,11 @@ struct RelayController { return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) } - if isFragment { + // Live voice floods with the fragment policy: the dense clamp + // contains the sustained ~15 pkt/s per-talker stream, and the tight + // jitter window keeps per-hop latency inside the receiver's ~350 ms + // jitter buffer across multi-hop paths. + if isFragment || isVoiceFrame { // Dense graphs clamp harder to contain full-fanout fragment floods; // sparse graphs get full depth so media reaches as far as text. let fragmentCap = degree >= highDegreeThreshold @@ -57,7 +70,7 @@ struct RelayController { // - Dense graphs: keep lower but still allow multi-hop bridging // - Thin chains (degree <= 2): every hop counts and flood cost is // minimal, so relay at full incoming depth - // - Announces get a bit more headroom + // - Announces (and urgent board posts) get a bit more headroom let ttlLimit: UInt8 = { if degree >= highDegreeThreshold { return max(UInt8(2), min(ttlCap, UInt8(5))) @@ -65,7 +78,7 @@ struct RelayController { if degree <= 2 { return ttlCap } - let preferred = UInt8(isAnnounce ? 7 : 6) + let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6) return max(UInt8(2), min(ttlCap, preferred)) }() let newTTL = ttlLimit &- 1 diff --git a/bitchat/Services/TransferProgressManager.swift b/bitchat/Services/TransferProgressManager.swift index 3ab721ba..4c6a34d8 100644 --- a/bitchat/Services/TransferProgressManager.swift +++ b/bitchat/Services/TransferProgressManager.swift @@ -49,12 +49,6 @@ final class TransferProgressManager { } } - func reset(id: String) { - queue.async(flags: .barrier) { [weak self] in - self?.states.removeValue(forKey: id) - } - } - func snapshot(id: String) -> (sent: Int, total: Int)? { var result: (sent: Int, total: Int)? queue.sync { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 36ca832b..c7094bc1 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -11,12 +11,70 @@ struct TransportPeerSnapshot: Equatable, Hashable { let isConnected: Bool let noisePublicKey: Data? let lastSeen: Date + /// Whether the peer's announce was signature-verified (courier tier gate). + let isVerified: Bool + + init( + peerID: PeerID, + nickname: String, + isConnected: Bool, + noisePublicKey: Data?, + lastSeen: Date, + isVerified: Bool = false + ) { + self.peerID = peerID + self.nickname = nickname + self.isConnected = isConnected + self.noisePublicKey = noisePublicKey + self.lastSeen = lastSeen + self.isVerified = isVerified + } +} + +/// Outcome of a `/ping` probe over the mesh. +struct MeshPingResult: Equatable { + /// Round-trip time in milliseconds. + let rttMs: Int + /// Total hops to the peer (1 = directly connected), derived from the + /// pong's TTL decrements; nil when the reply carried inconsistent TTLs. + let hops: Int? +} + +/// Undirected mesh link between two peers, normalized so `(a, b)` and +/// `(b, a)` collapse to one edge. +struct MeshTopologyEdge: Hashable { + let a: PeerID + let b: PeerID + + init(_ first: PeerID, _ second: PeerID) { + if first < second { + a = first + b = second + } else { + a = second + b = first + } + } +} + +/// Point-in-time view of the mesh graph learned from gossiped announces +/// (each announce carries up to 10 `directNeighbors`). +struct MeshTopologySnapshot: Equatable { + let localPeerID: PeerID + let nodes: [PeerID] + let edges: [MeshTopologyEdge] } enum TransportEvent: @unchecked Sendable { case messageReceived(BitchatMessage) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) + /// Encrypted group broadcast (MessageType 0x25). Opaque here — the group + /// coordinator decrypts and authenticates against the roster. + case groupMessageReceived(payload: Data, timestamp: Date) + /// Public live-voice burst packet (MessageType 0x29), already + /// signature-verified against the claimed sender. + case publicVoiceFrameReceived(peerID: PeerID, nickname: String, payload: Data, timestamp: Date) case peerConnected(PeerID) case peerDisconnected(PeerID) case peerListUpdated([PeerID]) @@ -38,7 +96,6 @@ protocol Transport: AnyObject { var peerEventsDelegate: TransportPeerEventsDelegate? { get set } // Peer snapshots (for non-UI services) - var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get } func currentPeerSnapshots() -> [TransportPeerSnapshot] // Identity @@ -54,6 +111,19 @@ protocol Transport: AnyObject { // Connectivity and peers func isPeerConnected(_ peerID: PeerID) -> Bool func isPeerReachable(_ peerID: PeerID) -> Bool + /// Whether a send to this peer is likely to leave the device promptly. + /// Distinct from reachability: Nostr claims any favorite with a known + /// npub as reachable even with no relay connection, where a send only + /// joins a queue waiting for internet that may never come. + func canDeliverPromptly(to peerID: PeerID) -> Bool + /// Whether a send to this peer can complete an end-to-end encrypted + /// delivery right now (e.g. an established Noise session). Distinct from + /// connectivity: a "connected" link binding alone is forgeable — link + /// bindings heal on signature-verified "direct" announces, but directness + /// rides on the unsigned TTL, so a replayed announce can wear an absent + /// peer's ID on the replayer's link. Routers must not trust a connected + /// link outright without this. + func canDeliverSecurely(to peerID: PeerID) -> Bool func peerNickname(peerID: PeerID) -> String? func getPeerNicknames() -> [PeerID: String] @@ -95,16 +165,100 @@ protocol Transport: AnyObject { func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func cancelTransfer(_ transferId: String) + // Live voice / push-to-talk (mesh transports only): one encoded + // `VoiceBurstPacket`, fire-and-forget inside the Noise session. Frames are + // only useful now — transports drop them (never queue) when no + // established session exists. + func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) + // Public-mesh counterpart: signed ephemeral broadcast, never synced. + func sendVoiceFrameBroadcast(_ burstContent: Data) + + // Courier store-and-forward (mesh transports only): seal a message to the + // recipient's static key and hand it to connected couriers for physical + // delivery while the recipient is offline. Returns false when the + // transport cannot courier (no connected courier, or unsupported). + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool + + // Private groups (mesh transports only): creator-signed state travels + // 1:1 over Noise sessions; group messages flood like public broadcasts. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) + func broadcastGroupMessage(_ envelope: Data) + + // Bulletin board (mesh transports only): broadcast a pre-signed board + // payload (post or tombstone) so it spreads over relay and gossip sync. + func sendBoardPayload(_ payload: Data) + + // Mesh diagnostics (optional for transports). Defaults are inert so + // queue-backed transports (e.g. NostrTransport) stay untouched. + /// Sends a directed ping probe; the completion fires exactly once on the + /// main actor with the measured result, or nil on timeout/unsupported. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) + /// Estimated intermediate hops toward `peerID` from gossiped topology + /// ([] = direct link, nil = no known path). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? + /// Current mesh graph for the topology map; nil when unsupported. + func currentMeshTopology() -> MeshTopologySnapshot? + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + // Vouching / transitive verification (optional for transports) + /// Capabilities the peer advertised in its last verified announce; + /// empty for peers that predate the capabilities TLV. + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities + /// Sends an encoded vouch-attestation batch inside the Noise session. + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) + /// Appends a peer-authenticated observer. Unlike + /// `installNoiseSessionCallbacks` this never touches the (single-slot) + /// handshake-required callback, so secondary features can observe + /// session establishment without disturbing the primary registration. + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) + // Pending file management (BCH-01-002: files held in memory until user accepts) func acceptPendingFile(id: String) -> URL? func declinePendingFile(id: String) + + // Store-and-forward archive (mesh transports only): the public messages + // this device is carrying for gossip sync, decoded for display as + // "heard here earlier" timeline echoes. + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) + /// Drops any carried public messages from a (newly blocked) sender so + /// they can't resurface as archived echoes on a later launch. + func purgeArchivedPublicMessages(from peerID: PeerID) +} + +/// A carried public mesh message from the store-and-forward window, decoded +/// for display. `packetIdHex` is stable across launches so echo rows keep a +/// deterministic message ID. +struct ArchivedPublicMessage { + let packetIdHex: String + let senderPeerID: PeerID + let senderNickname: String + let content: String + let timestamp: Date +} + +extension BitchatMessage { + /// Echo rows are minted locally with this prefix (packet-id derived, so + /// stable across launches); the timeline dims them. + static let archivedEchoIDPrefix = "echo-" + + var isArchivedEcho: Bool { + id.hasPrefix(Self.archivedEchoIDPrefix) + } } extension Transport { + // Reachability implies prompt delivery for transports that hand packets + // straight to the radio; queue-backed transports override this. + func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) } + + // Transports without a forgeable link-binding layer (everything but the + // BLE mesh) have no stronger delivery signal than prompt delivery. + func canDeliverSecurely(to peerID: PeerID) -> Bool { canDeliverPromptly(to: peerID) } + // Noise identity hooks default to inert for transports that do not carry // Noise sessions (e.g. NostrTransport). func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } @@ -120,6 +274,24 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} + func broadcastGroupMessage(_ envelope: Data) {} + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } + func sendBoardPayload(_ payload: Data) {} + func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {} + func sendVoiceFrameBroadcast(_ burstContent: Data) {} + + // Mesh diagnostics are mesh-transport-only; other transports report + // "no reply"/"no path" rather than pretending to measure anything. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + Task { @MainActor in completion(nil) } + } + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil } + func currentMeshTopology() -> MeshTopologySnapshot? { nil } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} @@ -130,10 +302,16 @@ extension Transport { func acceptPendingFile(id: String) -> URL? { nil } func declinePendingFile(id: String) {} + + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { + Task { @MainActor in completion([]) } + } + + func purgeArchivedPublicMessages(from peerID: PeerID) {} } protocol TransportPeerEventsDelegate: AnyObject { - @MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) + @MainActor func didUpdatePeerSnapshots(_: [TransportPeerSnapshot]) } extension BitchatDelegate { @@ -152,6 +330,10 @@ extension BitchatDelegate { ) case let .noisePayloadReceived(peerID, type, payload, timestamp): didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp) + case let .groupMessageReceived(payload, timestamp): + didReceiveGroupMessage(payload: payload, timestamp: timestamp) + case let .publicVoiceFrameReceived(peerID, nickname, payload, timestamp): + didReceivePublicVoiceFrame(from: peerID, nickname: nickname, payload: payload, timestamp: timestamp) case .peerConnected(let peerID): didConnectToPeer(peerID) case .peerDisconnected(let peerID): diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 4aae7294..f8e4f8c3 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -16,6 +16,32 @@ enum TransportConfig { static let bleFragmentRelayTtlCap: UInt8 = 7 static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs + // Live voice (push-to-talk) + // Burst-content budget per voice packet. Sized so the Noise ciphertext + // (content + 1 type byte + 16 tag bytes) stays within MessagePadding's + // 256-byte bucket and the whole directed packet (16 header + 8 sender + + // 8 recipient + 256 payload = 288 bytes) rides one BLE frame — live audio + // must never enter the fragment scheduler, which caps concurrent + // transfers at 2 and would let voice starve file sends. + static let pttMaxBurstContentBytes: Int = 210 + static let pttJitterBufferSeconds: TimeInterval = 0.35 // buffered audio before live playback starts + static let pttJitterDeadlineSeconds: TimeInterval = 0.5 // start anyway after this wall-clock wait + static let pttBurstEndTimeoutSeconds: TimeInterval = 3.0 // no frames -> burst considered ended + static let pttMaxConcurrentAssemblies: Int = 8 // concurrent inbound bursts cap + static let pttMaxBurstBytes: Int = 384 * 1024 // 120s at ~2KB/s + generous slack + static let pttFinishedBurstRegistrySeconds: TimeInterval = 600 // window to absorb the finalized note + // Inbound flood guard: a real burst arrives at ~2KB/s; allow 3x plus a + // small settling allowance before dropping a sender's frames. + static let pttInboundMaxBytesPerSecond: Int = 6_000 + // Public bursts are live-only traffic: frames older than this are relay + // stragglers or replays, not audio anyone should start hearing. + static let pttPublicFrameMaxAgeSeconds: TimeInterval = 30 + + // Mesh diagnostics (/ping) + static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window + static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)... + static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification) + // UI / Storage Caps static let privateChatCap: Int = 1337 static let meshTimelineCap: Int = 1337 @@ -66,8 +92,7 @@ enum TransportConfig { // UI thresholds static let uiProcessedNostrEventsCap: Int = 2000 - static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 - + // UI rate limiters (token buckets) static let uiSenderRateBucketCapacity: Double = 5 static let uiSenderRateBucketRefillPerSec: Double = 1.0 @@ -76,17 +101,13 @@ enum TransportConfig { // UI sleeps/delays static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 - static let uiStartupShortSleepNs: UInt64 = 200_000_000 static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0 static let uiAsyncShortSleepNs: UInt64 = 100_000_000 - static let uiAsyncMediumSleepNs: UInt64 = 500_000_000 static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1 static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5 static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15 static let uiScrollThrottleSeconds: TimeInterval = 0.5 - static let uiAnimationShortSeconds: TimeInterval = 0.15 static let uiAnimationMediumSeconds: TimeInterval = 0.2 - static let uiAnimationSidebarSeconds: TimeInterval = 0.25 static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0 @@ -110,6 +131,10 @@ enum TransportConfig { static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 45.0 // unknown/unverified static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 + // At most one rotation rebind per link per window: TTL is not signed, so + // a replayed announce can forge "direct", and without a cooldown two + // identities could fight over a link in a rebind flip-flop. + static let bleLinkRebindCooldownSeconds: TimeInterval = 60.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 static let bleRecentPacketWindowSeconds: TimeInterval = 30.0 static let bleRecentPacketWindowMaxCount: Int = 100 @@ -147,10 +172,17 @@ enum TransportConfig { static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 static let nostrGeohashSampleLimit: Int = 100 static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400 - - // Nostr helpers - static let nostrShortKeyDisplayLength: Int = 8 - static let nostrConvKeyPrefixLength: Int = 16 + // A sampled chat message this recent means "a conversation is happening + // there" for the empty-timeline nearby-activity hint. + static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900 + // Startup delay before reading the gossip archive for "heard here + // earlier" echoes; covers the archive's async disk restore. + static let uiArchivedEchoLoadDelaySeconds: TimeInterval = 1.5 + // Dead drops: location notes left via /drop expire after this long. + static let locationDropExpirySeconds: TimeInterval = 24 * 60 * 60 + // Poll cadence while geo notes wait for a relay connection (Tor warming + // up); re-subscribes as soon as one comes up. + static let uiGeoNotesConnectivityRetrySeconds: TimeInterval = 3.0 // Message deduplication static let messageDedupMaxAgeSeconds: TimeInterval = 300 @@ -183,6 +215,9 @@ enum TransportConfig { // Fallback deadline for treating a subscription's initial fetch as complete // when a relay never sends EOSE (generous to cover Tor circuit setup). static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0 + // A bridge drop is durable only after NIP-20 OK. Relays that omit OK must + // not pin the router's in-flight state indefinitely. + static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0 // After this long, a relay marked permanently failed gets another chance. static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0 @@ -208,6 +243,25 @@ enum TransportConfig { static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown + // Source routing (v2 directed packets) + // Longest path we will originate, in intermediate hops between us and the + // recipient. Keep small: every hop must be a fresh, confirmed, v2-capable + // node, and long stale paths fail more often than floods. + static let bleSourceRouteMaxIntermediateHops: Int = 4 + // A routed send with no inbound traffic from the recipient within this + // window counts as a route failure. + static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0 + // After a route failure, directed sends to that recipient flood instead + // of routing until this lapses. + static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0 + + // Targeted fragment resync (REQUEST_SYNC fragmentIdFilter) + // A broadcast reassembly with no new fragment for this long is stalled + // and triggers a targeted REQUEST_SYNC naming its fragment stream. + static let bleFragmentResyncStallSeconds: TimeInterval = 5.0 + // Minimum spacing between targeted resync requests for the same stream. + static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0 + // Store-and-forward for directed packets at relays. Spooled packets retry // on each maintenance flush until the window lapses; a longer window lets // brief link gaps (walking between rooms, reconnect churn) heal themselves. @@ -218,6 +272,16 @@ enum TransportConfig { static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9 static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0 + // Background wake-on-proximity (iOS). Pending connects issued on + // backgrounding never expire at the OS level: the Bluetooth controller + // completes them whenever the peer reappears in range and relaunches the + // app via state restoration. Entries older than the BLE address-rotation + // window no longer map to a reachable address, so the cache prunes them. + static let bleRecentPeripheralCacheCap: Int = 16 + static let bleRecentPeripheralMaxAgeSeconds: TimeInterval = 15 * 60 + // Central slots kept free for connects driven by live background discovery + static let bleBackgroundPendingConnectSlotReserve: Int = 2 + // Weak-link cooldown after connection timeouts static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkRSSICutoff: Int = -90 @@ -234,12 +298,7 @@ enum TransportConfig { static let uiVeryLongTokenThreshold: Int = 512 static let uiLongMessageLineLimit: Int = 30 static let uiFingerprintSampleCount: Int = 3 - - // UI swipe/gesture thresholds - static let uiBackSwipeTranslationLarge: CGFloat = 50 - static let uiBackSwipeTranslationSmall: CGFloat = 30 - static let uiBackSwipeVelocityThreshold: CGFloat = 300 - + // UI color tuning static let uiColorHueAvoidanceDelta: Double = 0.05 static let uiColorHueOffset: Double = 0.12 @@ -262,7 +321,14 @@ enum TransportConfig { static let syncSeenCapacity: Int = 1000 static let syncGCSMaxBytes: Int = 400 static let syncGCSTargetFpr: Double = 0.01 + // Fragments and file transfers keep the short window; whole public + // messages get hours so a phone walking between partitions carries the + // room's recent history with it (see syncPublicMessageMaxAgeSeconds). static let syncMaxMessageAgeSeconds: TimeInterval = 900 + // How far back public broadcast messages stay sync-able. Must not exceed + // the receive-side acceptance window (BLEPublicMessagePolicy uses this + // same constant) or served packets would be dropped as stale. + static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60 static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0 static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0 static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0 @@ -271,4 +337,30 @@ enum TransportConfig { static let syncFragmentIntervalSeconds: TimeInterval = 30.0 static let syncFileTransferIntervalSeconds: TimeInterval = 60.0 static let syncMessageIntervalSeconds: TimeInterval = 15.0 + static let syncResponseRateLimitMaxResponses: Int = 8 + static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 + + // Courier store-and-forward + // Initial spray-and-wait budget per deposited envelope: each courier may + // hand half its remaining copies to another courier on encounter, so a + // message diffuses through a moving crowd instead of riding one person. + static let courierInitialCopies: UInt8 = 4 + // Cooldown between speculative multi-hop handovers of the same envelope + // toward a recipient heard only via relayed announces. + static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60 + // Recently opened courier inner message IDs kept for receiver-side dedup + // (redundant copies ride distinct seals, so only the inner ID matches). + static let courierOpenedMessageIDCap: Int = 512 + + // One-time prekey bundles (forward-secret courier sealing) + // Own gossip-sync round for bundles: modest cadence, bounded peer count, + // and a long freshness window so bundles persist mesh-wide while their + // owners are away. + static let syncPrekeyBundleCapacity: Int = 200 + static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0 + static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 + // Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on + // announces, keep it alive in peers' gossip stores; changed bundles are + // sent immediately. + static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60 } diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 3d180c68..f54523ec 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -70,7 +70,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } // TransportPeerEventsDelegate - func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) { + func didUpdatePeerSnapshots(_: [TransportPeerSnapshot]) { updatePeers() } @@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { var enrichedPeers: [BitchatPeer] = [] var connected: Set = [] var addedPeerIDs: Set = [] - + var meshNoiseKeys: Set = [] + // Phase 1: Add all mesh peers (connected and reachable) for peerInfo in meshPeers { let peerID = peerInfo.peerID guard peerID != meshService.myPeerID else { continue } // Never add self - + let peer = buildPeerFromMesh( peerInfo: peerInfo, favorites: favorites, meshAttached: hasAnyConnected ) - + enrichedPeers.append(peer) if peer.isConnected { connected.insert(peerID) } addedPeerIDs.insert(peerID) - + // Update fingerprint cache if let publicKey = peerInfo.noisePublicKey { + meshNoiseKeys.insert(publicKey) fingerprintCache[peerID] = publicKey.sha256Fingerprint() } } - - // Phase 2: Add offline favorites that we actively favorite + + // Phase 2: Add offline favorites that we actively favorite. + // Mesh rows use the short 16-hex peer ID while favorites are keyed by + // the full 32-byte noise key, so dedup must compare noise keys — a + // PeerID comparison between the two forms can never match. for (favoriteKey, favorite) in favorites where favorite.isFavorite { + if meshNoiseKeys.contains(favoriteKey) { continue } + let peerID = PeerID(hexData: favoriteKey) - - // Skip if already added (connected peer) if addedPeerIDs.contains(peerID) { continue } - - // Skip if connected under different ID but same nickname - let isConnectedByNickname = enrichedPeers.contains { - $0.nickname == favorite.peerNickname && $0.isConnected - } - if isConnectedByNickname { continue } - + let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) enrichedPeers.append(peer) addedPeerIDs.insert(peerID) - + // Update fingerprint cache fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() } @@ -257,7 +256,35 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { return false } - + + /// Block or unblock a mesh peer by its stable Noise identity. + /// + /// The block is keyed by the peer's fingerprint, resolved from `peerID` + /// (cache / mesh session / known-peer Noise key). This works even when the + /// peer is offline — including offline favorites — so the exact tapped peer + /// is (un)blocked unambiguously instead of being re-resolved by a + /// display-name string that two peers could share. + /// - Returns: the resolved fingerprint, or `nil` if the identity is unknown. + @discardableResult + func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? { + guard let fingerprint = getFingerprint(for: peerID) else { + SecureLogger.warning( + "⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)", + category: .session + ) + return nil + } + identityManager.setBlocked(fingerprint, isBlocked: blocked) + if blocked { + // Purge while the fingerprint↔peerID mapping is still known: the + // archived-echo seed filter can't resolve offline strangers, so + // scrub their carried messages now rather than at relaunch. + meshService.purgeArchivedPublicMessages(from: peerID) + } + updatePeers() + return fingerprint + } + /// Toggle favorite status func toggleFavorite(_ peerID: PeerID) { guard let peer = getPeer(by: peerID) else { @@ -346,12 +373,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } // MARK: - Compatibility Methods (for easy migration) - - var allPeers: [BitchatPeer] { peers } - var connectedPeers: Set { connectedPeerIDs } - var favoritePeers: Set { - Set(favorites.compactMap { getFingerprint(for: $0.peerID) }) - } + var blockedUsers: Set { Set(peers.compactMap { peer in isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil diff --git a/bitchat/Sync/GCSFilter.swift b/bitchat/Sync/GCSFilter.swift index 7d76c23c..8b36b376 100644 --- a/bitchat/Sync/GCSFilter.swift +++ b/bitchat/Sync/GCSFilter.swift @@ -10,7 +10,13 @@ import CryptoKit // - 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< Params { let p = deriveP(targetFpr: targetFpr) guard !ids.isEmpty else { - return Params(p: p, m: 1, data: Data()) + return Params(p: p, m: 1, data: Data(), includedCount: 0) } let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) - let selected = Array(ids.prefix(cap)) - let range = max(1, hashRange(count: selected.count, p: p)) + // Modulus is fixed to the initial candidate count so `m` stays stable + // as the tail is trimmed to fit the byte budget below. + let range = max(1, hashRange(count: min(ids.count, cap), p: p)) let modulo = UInt64(range) - var mapped = selected - .map { h64($0) } - .map { mapHash($0, modulo: modulo) } - .sorted() - mapped = normalizeMappedValues(mapped, modulo: modulo) - - if mapped.isEmpty { - return Params(p: p, m: range, data: Data()) + // Encode the first `count` inputs (input order). The caller passes IDs + // newest-first, so trimming from the tail drops the oldest — which is + // what lets a since-cursor stay exact: the surviving set is always a + // contiguous newest-prefix, never a hash-order-arbitrary subset. + func encodeFirst(_ count: Int) -> Data { + var mapped = ids.prefix(count) + .map { h64($0) } + .map { mapHash($0, modulo: modulo) } + .sorted() + mapped = normalizeMappedValues(mapped, modulo: modulo) + return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p) } - var encoded = encode(sorted: mapped, p: p) - var trimmedCount = mapped.count - - while encoded.count > maxBytes && trimmedCount > 0 { - if trimmedCount == 1 { - mapped.removeAll() - encoded = Data() - break - } - trimmedCount = max(1, (trimmedCount * 9) / 10) - mapped = Array(mapped.prefix(trimmedCount)) - encoded = encode(sorted: mapped, p: p) + var count = min(ids.count, cap) + var encoded = encodeFirst(count) + while encoded.count > maxBytes && count > 1 { + count = max(1, (count * 9) / 10) + encoded = encodeFirst(count) + } + // A single element that still overflows can't be represented. + if encoded.count > maxBytes { + return Params(p: p, m: range, data: Data(), includedCount: 0) } - return Params(p: p, m: range, data: encoded) + return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count) } static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { diff --git a/bitchat/Sync/GossipMessageArchive.swift b/bitchat/Sync/GossipMessageArchive.swift new file mode 100644 index 00000000..dd81983d --- /dev/null +++ b/bitchat/Sync/GossipMessageArchive.swift @@ -0,0 +1,80 @@ +// +// GossipMessageArchive.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Disk persistence for the gossip-sync public message store, so the recent +/// public history a device carries survives app restarts. This is what lets +/// a phone act as a town crier: walk between two mesh partitions (or relaunch +/// hours later) and sync the room's backlog to whoever missed it. +/// +/// Contents are signed public broadcasts — already visible to anyone in radio +/// range — so file protection (no additional sealing) is the right at-rest +/// posture. Wiped on panic. +final class GossipMessageArchive { + private let fileURL: URL? + + init(fileURL: URL? = nil) { + self.fileURL = fileURL ?? Self.defaultFileURL() + } + + /// Raw binary packets, decoded and freshness-filtered by the caller. + func load() -> [Data] { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let packets = try? JSONDecoder().decode([Data].self, from: data) else { + return [] + } + return packets + } + + func save(_ packets: [Data]) { + guard let fileURL else { return } + guard !packets.isEmpty else { + try? FileManager.default.removeItem(at: fileURL) + return + } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(packets) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync) + } + } + + func wipe() { + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + /// Panic-wipe hook for callers that don't hold the live instance. + static func wipeDefault() { + GossipMessageArchive().wipe() + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("sync", isDirectory: true) + .appendingPathComponent("public-messages.json") + } +} diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 3c8472d2..62f6394e 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -64,41 +64,82 @@ final class GossipSyncManager { var seenCapacity: Int = 1000 // max packets per sync (cap across types) var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsTargetFpr: Double = 0.01 // 1% - var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages + var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces + // Whole public messages stay sync-able much longer so devices carry + // the room's recent history between partitions and across restarts. + var publicMessageMaxAgeSeconds: TimeInterval = 900 var maintenanceIntervalSeconds: TimeInterval = 30.0 var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0 var stalePeerTimeoutSeconds: TimeInterval = 60.0 var fragmentCapacity: Int = 600 var fileTransferCapacity: Int = 200 + var groupMessageCapacity: Int = 200 var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 + // Board posts are few but long-lived (days, until each post's own + // expiry), so they get a slow round with their own capacity instead + // of competing with the 15-minute message window. + var boardCapacity: Int = 200 + var boardSyncIntervalSeconds: TimeInterval = 60.0 + var responseRateLimitMaxResponses: Int = 8 + var responseRateLimitWindowSeconds: TimeInterval = 30.0 + // Prekey bundles: one per peer, own sync round, long freshness so + // bundles persist mesh-wide while their owners are offline. + var prekeyBundleCapacity: Int = 200 + var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0 + var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 } private let myPeerID: PeerID private let config: Config private let requestSyncManager: RequestSyncManager + private let archive: GossipMessageArchive? weak var delegate: Delegate? + /// Source of raw signed board packets (posts + tombstones). The board + /// store is the single owner of board retention (expiry, tombstones, + /// caps, persistence), so sync rounds query it instead of keeping a + /// second copy here. Must be thread-safe; set before `start()`. + var boardPacketsProvider: (() -> [BitchatPacket])? + // Storage: broadcast packets by type, and latest announce per sender private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() - private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] + private var groupMessages = PacketStore() + private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] + // Latest verified prekey bundle per owner. Unlike announces, bundles are + // NOT dropped on leave/stale peer: their whole purpose is reaching a + // sender while the owner is away. + private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] + private var archiveDirty = false // Timer private var periodicTimer: DispatchSourceTimer? private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private var lastStalePeerCleanup: Date = .distantPast private var syncSchedules: [SyncSchedule] = [] + private var responseRateLimiter: SyncResponseRateLimiter - init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { + init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) { self.myPeerID = myPeerID self.config = config self.requestSyncManager = requestSyncManager + self.archive = archive + self.responseRateLimiter = SyncResponseRateLimiter( + maxResponses: config.responseRateLimitMaxResponses, + window: config.responseRateLimitWindowSeconds + ) var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { - schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) + // Group messages ride the public-message cadence; old clients + // ignore the extended bit and answer with announces/messages only. + var messageTypes: SyncTypeFlags = .publicMessages + if config.groupMessageCapacity > 0 { + messageTypes.formUnion(.groupMessage) + } + schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) } if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast)) @@ -106,7 +147,19 @@ final class GossipSyncManager { if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) } + if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 { + schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast)) + } + if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 { + schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast)) + } syncSchedules = schedules + + if archive != nil { + queue.async { [weak self] in + self?.restoreArchivedMessages() + } + } } func start() { @@ -130,12 +183,21 @@ final class GossipSyncManager { guard let self = self else { return } var types: SyncTypeFlags = .publicMessages + if self.config.groupMessageCapacity > 0 { + types.formUnion(.groupMessage) + } if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 { types.formUnion(.fragment) } if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 { types.formUnion(.fileTransfer) } + if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 { + types.formUnion(.prekeyBundle) + } + if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil { + types.formUnion(.board) + } self.sendRequestSync(to: peerID, types: types) } } @@ -146,10 +208,23 @@ final class GossipSyncManager { } } - // Helper to check if a packet is within the age threshold + // Helper to check if a packet is within the age threshold. Whole public + // messages get the long town-crier window; fragments, file transfers and + // announces keep the short one. private func isPacketFresh(_ packet: BitchatPacket) -> Bool { + // Group messages share the whole-message window: members off the mesh + // for a while should backfill their crew's history like public chat. + let maxAgeSeconds: TimeInterval + switch packet.type { + case MessageType.message.rawValue, MessageType.groupMessage.rawValue: + maxAgeSeconds = config.publicMessageMaxAgeSeconds + case MessageType.prekeyBundle.rawValue: + maxAgeSeconds = config.prekeyBundleMaxAgeSeconds + default: + maxAgeSeconds = config.maxMessageAgeSeconds + } let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000) + let ageThresholdMs = UInt64(maxAgeSeconds * 1000) // If current time is less than threshold, accept all (handle clock issues gracefully) guard nowMs >= ageThresholdMs else { return true } @@ -182,14 +257,14 @@ final class GossipSyncManager { removeState(for: sender) return } - let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let sender = PeerID(hexData: packet.senderID) - latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) + latestAnnouncementByPeer[sender] = packet case .message: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) + archiveDirty = true case .fragment: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } @@ -200,6 +275,36 @@ final class GossipSyncManager { guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) + case .groupMessage: + // Opaque ciphertext to non-members; carried and served like any + // other broadcast so members get backfill from any relay. + guard isBroadcastRecipient else { return } + guard isPacketFresh(packet) else { return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity)) + case .prekeyBundle: + // Callers only feed verified bundles here (own bundles at send + // time, peers' after signature verification), so gossip never + // spreads a bundle this node couldn't attribute. + guard isBroadcastRecipient else { return } + guard isPacketFresh(packet) else { return } + // Key by the bundle's authenticated identity (its noise static key), + // NOT the unauthenticated packet senderID. Otherwise one valid + // bundle re-broadcast under many fabricated sender IDs would create + // one cache entry each and exhaust the per-owner cap, starving + // legitimate bundles. One owner ⇒ at most one entry. + guard let bundle = PrekeyBundle.decode(packet.payload) else { return } + let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) + if let existing = latestPrekeyBundleByPeer[owner], + existing.packet.timestamp >= packet.timestamp { + return + } + // Bounded owner count; replacing a known owner's bundle is always + // allowed so the cap can't block refreshes. + guard latestPrekeyBundleByPeer[owner] != nil + || latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet) default: break } @@ -208,7 +313,7 @@ final class GossipSyncManager { private func sendPeriodicSync(for types: SyncTypeFlags) { // Unicast sync to connected peers to allow RSR attribution if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { - SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync) + SecureLogger.debug("Sending periodic sync (\(types.logDescription)) to \(connectedPeers.count) connected peers", category: .sync) for peerID in connectedPeers { sendRequestSync(to: peerID, types: types) } @@ -233,11 +338,29 @@ final class GossipSyncManager { delegate?.sendPacket(signed) } - private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) { + /// Targeted fragment recovery: ask connected peers for the specific + /// fragment streams whose reassembly has stalled, instead of waiting on + /// the next periodic GCS fragment round to cover them. + func requestMissingFragments(fragmentIDs: [Data]) { + queue.async { [weak self] in + self?._requestMissingFragments(fragmentIDs) + } + } + + private func _requestMissingFragments(_ fragmentIDs: [Data]) { + guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return } + guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return } + SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync) + for peerID in connectedPeers { + sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter) + } + } + + private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) { // Register the request for RSR validation requestSyncManager.registerRequest(to: peerID) - - let payload = buildGcsPayload(for: types) + + let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter) var recipient = Data() var temp = peerID.id while temp.count >= 2 && recipient.count < 8 { @@ -265,7 +388,17 @@ final class GossipSyncManager { } private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { + // A response can replay the whole store, so bound how often one peer + // can trigger a diff pass regardless of how fast it asks. + guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { + SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) + return + } let requestedTypes = (request.types ?? .publicMessages) + // The requester's filter only covers packets at or after this cursor; + // older packets are outside the filter but not missing, and without + // the cursor they would be re-sent every round. + let since = request.sinceTimestamp // Decode GCS into sorted set and prepare membership checker let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) func mightContain(_ id: Data) -> Bool { @@ -273,11 +406,13 @@ final class GossipSyncManager { return GCSFilter.contains(sortedValues: sorted, candidate: bucket) } + // Announces are exempt from the since-cursor: they carry the signing + // keys needed to verify everything else, and there is at most one per + // peer, so the resend cost is negligible. if requestedTypes.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer { - let (idHex, pkt) = pair + for (_, pkt) in latestAnnouncementByPeer { guard isPacketFresh(pkt) else { continue } - let idBytes = Data(hexString: idHex) ?? Data() + let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 @@ -290,6 +425,7 @@ final class GossipSyncManager { if requestedTypes.contains(.message) { let toSendMsgs = messages.allPackets(isFresh: isPacketFresh) for pkt in toSendMsgs { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -301,8 +437,19 @@ final class GossipSyncManager { } if requestedTypes.contains(.fragment) { + // A fragment-ID filter narrows the diff to exactly the named + // fragment streams (targeted resync for stalled reassemblies) + // and bypasses the since-cursor for them; the GCS filter still + // excludes the pieces the requester already holds. Fragment + // payloads start with the 8-byte stream ID. + let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter) let frags = fragments.allPackets(isFresh: isPacketFresh) for pkt in frags { + if let fragmentIdFilter { + guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue } + } else if let since, pkt.timestamp < since { + continue + } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -316,6 +463,53 @@ final class GossipSyncManager { if requestedTypes.contains(.fileTransfer) { let files = fileTransfers.allPackets(isFresh: isPacketFresh) for pkt in files { + if let since, pkt.timestamp < since { continue } + let idBytes = PacketIdUtil.computeId(pkt) + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + } + } + } + + if requestedTypes.contains(.groupMessage) { + let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh) + for pkt in groupPkts { + if let since, pkt.timestamp < since { continue } + let idBytes = PacketIdUtil.computeId(pkt) + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + } + } + } + // Like announces, prekey bundles are exempt from the since-cursor: + // there is at most one per owner (newer replaces older), so the + // resend cost is bounded and a joining peer must be able to learn + // bundles generated long before it arrived. + if requestedTypes.contains(.prekeyBundle) { + for (_, pair) in latestPrekeyBundleByPeer { + let (idHex, pkt) = pair + guard isPacketFresh(pkt) else { continue } + let idBytes = Data(hexString: idHex) ?? Data() + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + } + } + } + if requestedTypes.contains(.boardPost) { + // The board store already filters to live posts and tombstones; + // no freshness window applies (posts sync until their own expiry). + let boardPackets = boardPacketsProvider?() ?? [] + for pkt in boardPackets { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -328,11 +522,11 @@ final class GossipSyncManager { } // Build REQUEST_SYNC payload using current candidates and GCS params - private func buildGcsPayload(for types: SyncTypeFlags) -> Data { + private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) { - candidates.append(pair.packet) + for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) { + candidates.append(pkt) } } if types.contains(.message) { @@ -344,9 +538,20 @@ final class GossipSyncManager { if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } + if types.contains(.groupMessage) { + candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh)) + } + if types.contains(.prekeyBundle) { + for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) { + candidates.append(pair.packet) + } + } + if types.contains(.boardPost) { + candidates.append(contentsOf: boardPacketsProvider?() ?? []) + } if candidates.isEmpty { let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) return req.encode() } @@ -360,49 +565,126 @@ final class GossipSyncManager { cap = max(1, config.fragmentCapacity) } else if types == .fileTransfer { cap = max(1, config.fileTransferCapacity) + } else if types == .prekeyBundle { + cap = max(1, config.prekeyBundleCapacity) + } else if types == .board { + cap = max(1, config.boardCapacity) } else { cap = max(1, config.seenCapacity) } let takeN = min(candidates.count, min(nMax, cap)) if takeN <= 0 { - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) return req.encode() } - let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) } + let included = Array(candidates.prefix(takeN)) + let ids: [Data] = included.map { PacketIdUtil.computeId($0) } let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr) - let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types) + // When the filter can't cover every candidate — either the store + // exceeds `takeN` or the encoder trimmed the tail to fit the byte + // budget — tell the responder how far back the filter actually + // reaches. `includedCount` counts inputs in newest-first order, so the + // covered set is a contiguous newest-prefix and the oldest included + // timestamp is an exact cursor. Packets older than it are outside the + // filter but not missing; without the cursor the responder would + // re-send that entire tail every round. + let covered = params.includedCount + let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0) + ? included[covered - 1].timestamp + : nil + let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) return req.encode() } // Periodic cleanup of expired messages and announcements private func cleanupExpiredMessages() { // Remove expired announcements - latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in - isPacketFresh(pair.packet) + latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in + isPacketFresh(pkt) } + let messageCountBefore = messages.packets.count messages.removeExpired(isFresh: isPacketFresh) + if messages.packets.count != messageCountBefore { + archiveDirty = true + } fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) + groupMessages.removeExpired(isFresh: isPacketFresh) + latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in + isPacketFresh(pair.packet) + } + } + + // MARK: - Archive (public message persistence) + + /// Rebuild the public message store from disk on launch, dropping + /// anything that aged out while the app was dead. + private func restoreArchivedMessages() { + guard let archive else { return } + var restored = 0 + for data in archive.load() { + guard let packet = BitchatPacket.from(data), + packet.type == MessageType.message.rawValue, + isPacketFresh(packet) else { continue } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) + restored += 1 + } + if restored > 0 { + SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync) + archiveDirty = true + } + } + + private func persistArchiveIfDirty() { + guard archiveDirty, let archive else { return } + archiveDirty = false + let packets = messages.allPackets(isFresh: isPacketFresh) + .compactMap { $0.toBinaryData(padding: false) } + archive.save(packets) + } + + /// Flush the archive outside the maintenance cadence (app backgrounding). + func persistNow() { + queue.async { [weak self] in + self?.persistArchiveIfDirty() + } + } + + /// Snapshot of the carried public-message packets (fresh window only), + /// for the "heard here earlier" timeline echoes. Completion runs on the + /// sync queue. + func collectPublicMessagePackets(completion: @escaping ([BitchatPacket]) -> Void) { + queue.async { [weak self] in + guard let self else { + completion([]) + return + } + completion(self.messages.allPackets(isFresh: self.isPacketFresh)) + } } private func performPeriodicMaintenance(now: Date = Date()) { cleanupExpiredMessages() cleanupStaleAnnouncementsIfNeeded(now: now) + persistArchiveIfDirty() requestSyncManager.cleanup() // Cleanup expired sync requests + responseRateLimiter.prune(now: now) - var dueTypes: SyncTypeFlags = [] + // One request per due schedule rather than a union filter: each type + // group gets the full GCS capacity and its own since-cursor, so heavy + // fragment traffic can't crowd messages out of the filter. for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } + // No board source wired up means nothing to offer or store; + // skip the round entirely. + if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue } if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now - dueTypes.formUnion(syncSchedules[index].types) + sendPeriodicSync(for: syncSchedules[index].types) } } - - if !dueTypes.isEmpty { - sendPeriodicSync(for: dueTypes) - } } private func cleanupStaleAnnouncementsIfNeeded(now: Date) { @@ -418,8 +700,8 @@ final class GossipSyncManager { let nowMs = UInt64(now.timeIntervalSince1970 * 1000) guard nowMs >= timeoutMs else { return } let cutoff = nowMs - timeoutMs - let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in - pair.packet.timestamp < cutoff ? peerID : nil + let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in + pkt.timestamp < cutoff ? peerID : nil } guard !stalePeerIDs.isEmpty else { return } for peerKey in stalePeerIDs { @@ -434,11 +716,35 @@ final class GossipSyncManager { } } + /// Block-time hygiene: drop the carried public messages from a blocked + /// sender and persist immediately, so nothing of theirs can resurface as + /// an archived echo on the next launch. Narrower than `removeState(for:)` + /// — the peer's announcement and in-flight fragments are untouched. + func removePublicMessages(from peerID: PeerID) { + queue.async { [weak self] in + guard let self else { return } + let countBefore = self.messages.packets.count + self.messages.remove { PeerID(hexData: $0.senderID) == peerID } + guard self.messages.packets.count != countBefore else { return } + self.archiveDirty = true + // Persist now rather than waiting for maintenance: a relaunch in + // the gap would restore the purged messages from disk. + self.persistArchiveIfDirty() + } + } + private func removeState(for peerID: PeerID) { + // Deliberately keeps the peer's prekey bundle: bundles exist to reach + // owners who left the mesh, and they age out on their own schedule. _ = latestAnnouncementByPeer.removeValue(forKey: peerID) + let messageCountBefore = messages.packets.count messages.remove { PeerID(hexData: $0.senderID) == peerID } + if messages.packets.count != messageCountBefore { + archiveDirty = true + } fragments.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } + groupMessages.remove { PeerID(hexData: $0.senderID) == peerID } } } @@ -456,6 +762,12 @@ extension GossipSyncManager { } } + func _hasPrekeyBundle(for peerID: PeerID) -> Bool { + queue.sync { + latestPrekeyBundleByPeer[peerID] != nil + } + } + func _messageCount(for peerID: PeerID) -> Int { queue.sync { messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count diff --git a/bitchat/Sync/SyncResponseRateLimiter.swift b/bitchat/Sync/SyncResponseRateLimiter.swift new file mode 100644 index 00000000..a6ffc6a1 --- /dev/null +++ b/bitchat/Sync/SyncResponseRateLimiter.swift @@ -0,0 +1,42 @@ +import BitFoundation +import Foundation + +/// Sliding-window limiter for REQUEST_SYNC responses. +/// +/// A single sync response can replay the entire gossip store, so a peer that +/// requests in a tight loop must not be able to drain the airtime and battery +/// of everyone in radio range. Legitimate peers send at most a few requests +/// per maintenance tick (one per type schedule, plus the initial sync). +struct SyncResponseRateLimiter { + private let maxResponses: Int + private let window: TimeInterval + private var history: [PeerID: [Date]] = [:] + + init(maxResponses: Int, window: TimeInterval) { + self.maxResponses = max(1, maxResponses) + self.window = max(0, window) + } + + /// Returns true (and records the response) if the peer is under its + /// response budget for the current window. + mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool { + let cutoff = now.addingTimeInterval(-window) + var recent = (history[peerID] ?? []).filter { $0 >= cutoff } + guard recent.count < maxResponses else { + history[peerID] = recent + return false + } + recent.append(now) + history[peerID] = recent + return true + } + + /// Drops history outside the window so departed peers don't accumulate. + mutating func prune(now: Date) { + let cutoff = now.addingTimeInterval(-window) + history = history.compactMapValues { dates in + let recent = dates.filter { $0 >= cutoff } + return recent.isEmpty ? nil : recent + } + } +} diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 19261314..c2c96a1c 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet { let rawValue: UInt64 init(rawValue: UInt64) { - self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes + // Drop any bit that doesn't map to a known message type. Wire data can + // carry up to 8 bytes of flags; without this mask, bits with no type + // (a truncated/garbled field, or a type a newer peer added) would live + // in the set as phantom membership that no `contains` check matches and + // `toData` re-serializes — a meaningless "accepted but does nothing" + // state. Masking here keeps every instance normalized at the source. + self.rawValue = rawValue & SyncTypeFlags.knownTypeMask } + /// Union of every bit that maps to a message type. Derived from the + /// bit↔type table so it tracks automatically when a type is added. + private static let knownTypeMask: UInt64 = { + var mask: UInt64 = 0 + for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil { + mask |= (1 << UInt64(bit)) + } + return mask + }() + private static func bitIndex(for type: MessageType) -> Int? { switch type { case .announce: return 0 @@ -20,6 +36,32 @@ struct SyncTypeFlags: OptionSet { case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 + case .boardPost: return 8 + // Extended bits are compat-safe by construction: `toData()` encodes + // the bitfield little-endian with trailing zero bytes trimmed (bit 10 + // widens the wire form from 1 to 2 bytes inside the length-prefixed + // REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while + // `type(forBit:)` maps unknown bits to nil — so old clients simply + // ignore the group bit and answer with the types they know. + case .groupMessage: return 10 + // Courier envelopes are directed deposits between trusted peers and + // must never spread via gossip sync. + case .courierEnvelope: return nil + // Ping/pong are ephemeral directed probes; replaying them via gossip + // sync would only produce stale, unanswerable echoes. + case .ping, .pong: return nil + // Gateway carriers are ephemeral live traffic (uplinks are directed, + // downlinks are rate-budgeted rebroadcasts); replaying them via sync + // would waste airtime and extend their lifetime. + case .nostrCarrier: return nil + // Live voice is only useful now; replaying stale audio frames via + // sync would waste airtime (receivers drop them as stale anyway). + case .voiceFrame: return nil + // Prekey bundles gossip like board posts. The bitfield is a + // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits + // ignored by `type(forBit:)`), so bits 8+ need no format change: old + // clients decode the wider flags and simply never match the new bits. + case .prekeyBundle: return 9 } } @@ -33,6 +75,12 @@ struct SyncTypeFlags: OptionSet { case 5: return .fragment case 6: return .requestSync case 7: return .fileTransfer + // Bit 8 spills the encoded bitfield into a second byte. Decoders since + // type-aware sync (#853) accept 1-8 bytes and map unknown bits to no + // known type, so old clients ignore board rounds instead of choking. + case 8: return .boardPost + case 9: return .prekeyBundle + case 10: return .groupMessage default: return nil } @@ -42,6 +90,9 @@ struct SyncTypeFlags: OptionSet { static let message = SyncTypeFlags(messageTypes: [.message]) static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) + static let board = SyncTypeFlags(messageTypes: [.boardPost]) + static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle]) + static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) @@ -67,6 +118,15 @@ struct SyncTypeFlags: OptionSet { SyncTypeFlags(rawValue: rawValue & other.rawValue) } + /// Compact form for logs, e.g. "message+fragment". Without this, the + /// per-schedule periodic sync rounds log identical lines and read as + /// duplicated sends (misdiagnosed twice during July 2026 device testing). + var logDescription: String { + let types = toMessageTypes() + guard !types.isEmpty else { return "none" } + return types.map { String(describing: $0) }.joined(separator: "+") + } + func toMessageTypes() -> [MessageType] { guard rawValue != 0 else { return [] } var types: [MessageType] = [] diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index 00ea0eb5..215384fa 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -52,18 +52,6 @@ final class MessageDeduplicator { return false } - /// Record an ID with a specific timestamp (for content key tracking) - func record(_ id: String, timestamp: Date) { - lock.lock() - defer { lock.unlock() } - - if lookup[id] == nil { - entries.append(Entry(id: id, timestamp: timestamp)) - } - lookup[id] = timestamp - trimIfNeeded() - } - /// Add an ID without checking (for announce-back tracking) func markProcessed(_ id: String) { lock.lock() @@ -83,13 +71,6 @@ final class MessageDeduplicator { return lookup[id] != nil } - /// Get timestamp for an ID (for content deduplication time-window checks) - func timestampFor(_ id: String) -> Date? { - lock.lock() - defer { lock.unlock() } - return lookup[id] - } - private func trimIfNeeded() { let activeCount = entries.count - head guard activeCount > maxCount else { return } diff --git a/bitchat/Utils/PeerDisplayNameResolver.swift b/bitchat/Utils/PeerDisplayNameResolver.swift index 77a89ec5..a92953d3 100644 --- a/bitchat/Utils/PeerDisplayNameResolver.swift +++ b/bitchat/Utils/PeerDisplayNameResolver.swift @@ -27,4 +27,3 @@ struct PeerDisplayNameResolver { return result } } - diff --git a/bitchat/Utils/Theme.swift b/bitchat/Utils/Theme.swift index 40fe035f..ec0283e0 100644 --- a/bitchat/Utils/Theme.swift +++ b/bitchat/Utils/Theme.swift @@ -96,7 +96,7 @@ struct ThemePalette { ) } - static func liquidGlass(_ colorScheme: ColorScheme) -> ThemePalette { + static func liquidGlass(_: ColorScheme) -> ThemePalette { ThemePalette( background: systemBackground, primary: .primary, diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift new file mode 100644 index 00000000..6e146fa5 --- /dev/null +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -0,0 +1,607 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatGroupCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. Group chats are keyed like direct chats +/// (virtual "group_" peer IDs), so the conversation intents below reuse the +/// private-chat store operations. +@MainActor +protocol ChatGroupContext: AnyObject { + // MARK: Identity & state + var nickname: String { get } + var myPeerID: PeerID { get } + var selectedPrivateChatPeer: PeerID? { get } + var groupStore: GroupStore { get } + + /// Fingerprint of our own Noise static identity key. + func myNoiseFingerprint() -> String + /// Our Ed25519 signing public key. + func mySigningPublicKey() -> Data + /// Signs `data` with our Noise signing key. + func signWithNoiseKey(_ data: Data) -> Data? + + // MARK: Peers + func getPeerIDForNickname(_ nickname: String) -> PeerID? + func isPeerConnected(_ peerID: PeerID) -> Bool + func peerNickname(for peerID: PeerID) -> String? + /// The peer's Noise fingerprint from the live session/registry. + func meshFingerprint(for peerID: PeerID) -> String? + /// The peer's persisted crypto identity (fingerprint + signing key), if + /// the identity store has a signature-verified announce for them. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? + /// The connected short peer ID whose fingerprint matches, if any. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? + /// Whether the user has blocked the identity with this Noise fingerprint. + func isFingerprintBlocked(_ fingerprint: String) -> Bool + + // MARK: Transport + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) + func broadcastGroupMessagePayload(_ payload: Data) + + // MARK: Conversation intents (group chats are direct-keyed) + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool + func markPrivateChatUnread(_ peerID: PeerID) + func removePrivateChat(_ peerID: PeerID) + func startPrivateChat(with peerID: PeerID) + func endPrivateChat() + func addSystemMessage(_ content: String) + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) + func notifyUIChanged() + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) +} + +extension ChatViewModel: ChatGroupContext { + // `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`, + // `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`, + // `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`, + // `removePrivateChat(_:)`, `startPrivateChat(with:)`, + // `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`, + // `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)` + // are shared requirements with the other contexts or satisfied by + // existing `ChatViewModel` members. The members below flatten nested + // service accesses into intent-named calls. + + func myNoiseFingerprint() -> String { + meshService.noiseIdentityFingerprint() + } + + func mySigningPublicKey() -> Data { + meshService.noiseSigningPublicKeyData() + } + + func signWithNoiseKey(_ data: Data) -> Data? { + meshService.noiseSignData(data) + } + + func meshFingerprint(for peerID: PeerID) -> String? { + meshService.getFingerprint(for: peerID) + } + + /// The persisted, signature-verified identity behind a short mesh peer + /// ID. Cross-checked against the live session fingerprint so a roster + /// entry can never be pinned to a signing key from a different identity. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? { + guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil } + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }), + let signingKey = identity.signingPublicKey else { return nil } + return (fingerprint, signingKey) + } + + /// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the + /// connected peer for a roster fingerprint is a direct derivation. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? { + let shortID = PeerID(str: String(fingerprint.prefix(16))) + return meshService.isPeerConnected(shortID) ? shortID : nil + } + + func isFingerprintBlocked(_ fingerprint: String) -> Bool { + identityManager.isBlocked(fingerprint: fingerprint) + } + + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupInvite(payload, to: peerID) + } + + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupKeyUpdate(payload, to: peerID) + } + + func broadcastGroupMessagePayload(_ payload: Data) { + meshService.broadcastGroupMessage(payload) + } + + // MARK: CommandContextProvider group commands (parsed by CommandProcessor) + + func groupCreate(named name: String) -> CommandResult { + groupCoordinator.createGroup(named: name) + } + + func groupInvite(nickname: String) -> CommandResult { + groupCoordinator.inviteMember(nickname: nickname) + } + + func groupRemove(nickname: String) -> CommandResult { + groupCoordinator.removeMember(nickname: nickname) + } + + func groupLeave() -> CommandResult { + groupCoordinator.leaveGroup() + } + + func groupList() -> CommandResult { + groupCoordinator.listGroups() + } +} + +/// Owns the private-groups feature: creating groups, creator-managed invites +/// and key rotation over Noise, and sealing/opening group message broadcasts. +/// Delivery is fire-and-flood like public chat — no per-member acks in v1 — +/// with gossip-sync backfill as the only offline catch-up. +@MainActor +final class ChatGroupCoordinator { + private unowned let context: any ChatGroupContext + + private static let maxGroupNameLength = 40 + + init(context: any ChatGroupContext) { + self.context = context + } + + // MARK: - Commands + + func createGroup(named rawName: String) -> CommandResult { + let name = rawName.trimmed + guard !name.isEmpty else { + return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create")) + } + guard name.count <= Self.maxGroupNameLength else { + return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap")) + } + + let myFingerprint = context.myNoiseFingerprint() + let mySigningKey = context.mySigningPublicKey() + guard !myFingerprint.isEmpty, mySigningKey.count == 32 else { + return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations")) + } + + let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname) + guard let group = context.groupStore.createGroup(named: name, creator: creator) else { + return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails")) + } + + context.startPrivateChat(with: group.peerID) + return .success(message: String( + format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"), + locale: .current, + name + )) + } + + func inviteMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let peerID = context.getPeerIDForNickname(nickname) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard context.isPeerConnected(peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard let identity = context.cryptoIdentity(for: peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard !group.isMember(fingerprint: identity.fingerprint) else { + return .error(message: String( + format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard group.members.count < BitchatGroup.maxMembers else { + return .error(message: String( + format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"), + locale: .current, + "\(BitchatGroup.maxMembers)" + )) + } + + let newMember = GroupMember( + fingerprint: identity.fingerprint, + signingKey: identity.signingKey, + nickname: context.peerNickname(for: peerID) ?? nickname + ) + // Rotate the key (epoch + 1) on every roster change, not just removals. + // A monotonically increasing epoch per roster gives the receiver a + // strict ordering: two out-of-order invite states can no longer share + // an epoch and last-writer-wins a just-added member back out. + let members = group.members + [newMember] + guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members), + let payload = signedStatePayload(for: updated, key: key) else { + return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails")) + } + + context.sendGroupInvitePayload(payload, to: peerID) + distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate) + + return .success(message: String( + format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"), + locale: .current, + nickname, + updated.name + )) + } + + /// Creator-side removal: rotates the group key (epoch + 1) and sends the + /// new state to every remaining member so the removed member's key stops + /// decrypting future traffic. + func removeMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else { + return .error(message: String( + format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard member.fingerprint != group.creatorFingerprint else { + return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves")) + } + + let remaining = group.members.filter { $0.fingerprint != member.fingerprint } + guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining), + let payload = signedStatePayload(for: rotated, key: newKey) else { + return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails")) + } + + distributeState(payload, group: rotated, excluding: [], type: .keyUpdate) + notifyRemovedMember(member, rotated: rotated) + + return .success(message: String( + format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"), + locale: .current, + member.nickname + )) + } + + func leaveGroup() -> CommandResult { + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + // Close the chat window first so the confirmation message doesn't + // resurrect the conversation we're about to remove. + context.endPrivateChat() + context.removePrivateChat(group.peerID) + context.groupStore.removeGroup(withID: group.groupID) + context.notifyUIChanged() + return .success(message: String( + format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"), + locale: .current, + group.name + )) + } + + func listGroups() -> CommandResult { + let groups = context.groupStore.groups + guard !groups.isEmpty else { + return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups")) + } + let myFingerprint = context.myNoiseFingerprint() + let lines = groups.map { group -> String in + let role = group.creatorFingerprint == myFingerprint ? " (creator)" : "" + return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)" + } + return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n")) + } + + // MARK: - Sending + + /// Fire-and-flood send: local echo goes straight to `.sent` because group + /// messages have no per-member acknowledgments in v1. + func sendGroupMessage(_ content: String, to groupPeerID: PeerID) { + guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return } + guard let group = context.groupStore.group(for: groupPeerID), + let key = context.groupStore.key(forGroupID: group.groupID) else { + // The person is inside the group thread; the error belongs there, + // not on the active public timeline. + context.addLocalPrivateSystemMessage( + String(localized: "system.group.unknown", comment: "System message when sending into an unknown group"), + to: groupPeerID + ) + return + } + + let messageID = UUID().uuidString + let timestamp = Date() + let payload: Data + do { + payload = try GroupCrypto.sealMessage( + content: content, + messageID: messageID, + senderNickname: context.nickname, + senderSigningKey: context.mySigningPublicKey(), + timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000), + groupID: group.groupID, + epoch: group.epoch, + key: key, + sign: { [weak context] data in context?.signWithNoiseKey(data) } + ) + } catch { + SecureLogger.error("Failed to seal group message: \(error)", category: .encryption) + context.addLocalPrivateSystemMessage( + String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"), + to: groupPeerID + ) + return + } + + let message = BitchatMessage( + id: messageID, + sender: context.nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: context.myPeerID, + mentions: nil, + deliveryStatus: .sent + ) + context.appendPrivateMessage(message, to: groupPeerID) + context.broadcastGroupMessagePayload(payload) + context.notifyUIChanged() + } + + // MARK: - Receiving + + /// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for + /// unknown groups (non-members relay but never read), wrong epochs, bad + /// sender signatures, and senders missing from the pinned roster. + func handleGroupMessagePayload(_ payload: Data, timestamp _: Date) { + guard let envelope = GroupMessageEnvelope.decode(payload) else { return } + guard let group = context.groupStore.group(withID: envelope.groupID) else { return } + guard envelope.epoch == group.epoch else { + SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption) + return + } + guard let key = context.groupStore.key(forGroupID: group.groupID) else { return } + + let plaintext: GroupMessagePlaintext + do { + plaintext = try GroupCrypto.openMessage(envelope, key: key) + } catch { + SecureLogger.debug("Failed to open group message: \(error)", category: .encryption) + return + } + + // Sender must be pinned in the creator-signed roster; key possession + // alone is not authorship. + guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else { + SecureLogger.warning("Dropping group message from non-roster sender", category: .security) + return + } + // Our own broadcast echoed back via relay or sync replay. + guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return } + // Honor /block inside groups too: drop display + notification for a + // blocked member, consistent with every other inbound path. + guard !context.isFingerprintBlocked(member.fingerprint) else { + SecureLogger.debug("Dropping group message from blocked member", category: .security) + return + } + + let groupPeerID = group.peerID + // Trust the authenticated inner timestamp (clamped so a future-dated + // message cannot pin itself to the bottom of the timeline). + let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date()) + let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname + let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16))) + let message = BitchatMessage( + id: plaintext.messageID, + sender: senderName, + content: plaintext.content, + timestamp: messageDate, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: senderPeerID, + mentions: nil + ) + + guard context.appendPrivateMessage(message, to: groupPeerID) else { return } + + let isViewing = context.selectedPrivateChatPeer == groupPeerID + if !isViewing { + context.markPrivateChatUnread(groupPeerID) + let isRecent = Date().timeIntervalSince(messageDate) < 30 + if isRecent { + context.notifyPrivateMessage( + from: "\(senderName) @ \(group.name)", + message: plaintext.content, + peerID: groupPeerID + ) + } + } + context.notifyUIChanged() + } + + /// Accepts creator-signed group state arriving as an invite. The Noise + /// session peer must BE the creator, the signature must verify against + /// the creator key pinned in the roster, and we must be in the roster. + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: true) + } + + /// Accepts creator-signed state updates (rotation/roster). A state whose + /// roster no longer includes us means we were removed: drop the group. + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: false) + } +} + +private extension ChatGroupCoordinator { + enum StateSendType { + case invite + case keyUpdate + } + + func normalizedNickname(_ raw: String) -> String { + let trimmed = raw.trimmed + return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed + } + + func selectedGroup() -> BitchatGroup? { + guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil } + return context.groupStore.group(for: selected) + } + + func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? { + GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in + context?.signWithNoiseKey(data) + }?.encode() + } + + /// Sends the state payload to every connected roster member except us and + /// the excluded fingerprints. Offline members catch up the next time the + /// creator sends them state (v1 limitation, documented in the PR). + func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set, type: StateSendType) { + let myFingerprint = context.myNoiseFingerprint() + for member in group.members { + guard member.fingerprint != myFingerprint, + !excludedFingerprints.contains(member.fingerprint), + let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue } + switch type { + case .invite: + context.sendGroupInvitePayload(payload, to: peerID) + case .keyUpdate: + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + } + } + + /// Tells a just-removed member they're out so their client can deactivate + /// the group instead of silently going dark (dropping every message under + /// the epoch it no longer has the key for). The notice is a creator-signed + /// state whose roster excludes the removee — their `applyGroupState` + /// removal branch fires on the missing-self roster and surfaces the + /// "removed from group" system message. + /// + /// It carries a throwaway all-zero key, never the rotated key, so the + /// removee cannot decrypt post-removal traffic. State is sent 1:1 over + /// authenticated Noise, so no remaining member ever receives this blob + /// (and even if one did, its own missing-self check would not match). + /// If the removee is offline the notice can't be delivered — same v1 + /// limitation as any other missed key update, documented in the PR. + func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) { + guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return } + let throwawayKey = Data(count: BitchatGroup.keyLength) + guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return } + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + + func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) { + guard let state = GroupStatePayload.decode(payload) else { + SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security) + return + } + // The Noise session already authenticated `peerID`; require that the + // authenticated peer IS the creator whose key signed the state, so a + // member can't re-invite or rotate on the creator's behalf. + guard let senderFingerprint = context.meshFingerprint(for: peerID), + senderFingerprint == state.creatorFingerprint else { + SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security) + return + } + guard state.verifyCreatorSignature() else { + SecureLogger.warning("Dropping group state with invalid creator signature", category: .security) + return + } + + let myFingerprint = context.myNoiseFingerprint() + let existing = context.groupStore.group(withID: state.groupID) + + // A creator-signed roster that no longer includes us is a removal. + guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else { + if let existing { + if context.selectedPrivateChatPeer == existing.peerID { + context.endPrivateChat() + } + context.removePrivateChat(existing.peerID) + context.groupStore.removeGroup(withID: existing.groupID) + context.addSystemMessage(String( + format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"), + locale: .current, + existing.name + )) + context.notifyUIChanged() + } + return + } + + // Never regress the epoch: state travels over live Noise sessions, + // so an older epoch here is a stale (or misbehaving) creator device. + if let existing, state.epoch < existing.epoch { + SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security) + return + } + + let isNewMembership = existing == nil + guard context.groupStore.upsert(state.asGroup, key: state.key) else { + SecureLogger.error("Failed to store group state for \(state.name)", category: .session) + return + } + + if isNewMembership { + let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname + ?? context.peerNickname(for: peerID) + ?? "?" + let notice = String( + format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"), + locale: .current, + state.name, + inviter + ) + context.addSystemMessage(notice) + context.markPrivateChatUnread(state.asGroup.peerID) + context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID) + } else if isInvite == false, let existing, state.epoch > existing.epoch { + SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session) + } + context.notifyUIChanged() + } +} diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index e93b7eb9..9b0fab0d 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -53,7 +53,8 @@ protocol ChatLifecycleContext: AnyObject { // MARK: Routing & receipts func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) + @discardableResult + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) @@ -185,6 +186,13 @@ final class ChatLifecycleCoordinator { func markPrivateMessagesAsRead(from peerID: PeerID) { context.markChatAsRead(from: peerID) + // Group chats are keyed under a virtual group_ peerID; no member IS the + // conversation peer, so the receipt loops below (which gate on + // senderPeerID == peerID) must never emit a read/delivered receipt for + // one. This guard makes that explicit so a future refactor of the + // receipt matching can't silently start leaking receipts into groups. + guard !peerID.isGroup else { return } + if peerID.isGeoDM, let recipientHex = context.nostrKeyMapping[peerID], case .location(let channel) = context.activeChannel, @@ -208,23 +216,22 @@ final class ChatLifecycleCoordinator { } var noiseKeyHex: PeerID? - var peerNostrPubkey: String? if let noiseKey = Data(hexString: peerID.id), - let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) { + context.favoriteRelationship(forNoiseKey: noiseKey) != nil { noiseKeyHex = peerID - peerNostrPubkey = favoriteStatus.peerNostrPublicKey } else if let peer = context.unifiedPeer(for: peerID) { noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey) - peerNostrPubkey = favoriteStatus?.peerNostrPublicKey if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) { context.markPrivateChatRead(noiseKeyHex) } } - guard peerNostrPubkey != nil else { return } + // No Nostr-key gate here: the router picks whatever transport can + // reach the peer (mesh included), so read receipts must flow for + // non-favorite mesh peers too. `sentReadReceipts` dedups against the + // PrivateChatManager path; the router drops receipts it can't route. for message in getPrivateChatMessages(for: peerID) { guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else { @@ -242,8 +249,12 @@ final class ChatLifecycleCoordinator { ? peerID : (context.unifiedPeer(for: peerID)?.peerID ?? peerID) - context.routeReadReceipt(receipt, to: recipientPeerID) - context.markReadReceiptSent(message.id) + // Only record the receipt as sent when it actually left via a + // reachable transport; a dropped receipt stays unmarked so the + // next read scan retries it instead of burning it forever. + if context.routeReadReceipt(receipt, to: recipientPeerID) { + context.markReadReceiptSent(message.id) + } } } @@ -324,7 +335,7 @@ private extension ChatLifecycleCoordinator { do { let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: message, geohash: channel.geohash, senderIdentity: identity, @@ -355,9 +366,10 @@ private extension ChatLifecycleCoordinator { case .failed: return 1 case .sending: return 2 case .sent: return 3 - case .partiallyDelivered: return 4 - case .delivered: return 5 - case .read: return 6 + case .carried: return 4 + case .partiallyDelivered: return 5 + case .delivered: return 6 + case .read: return 7 } } } diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift new file mode 100644 index 00000000..6c4c483c --- /dev/null +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -0,0 +1,657 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatLiveVoiceCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`, keeping it independently testable. +@MainActor +protocol ChatLiveVoiceContext: AnyObject { + var nickname: String { get } + var selectedPrivateChatPeer: PeerID? { get } + /// Whether the public mesh timeline is what's on screen (autoplay gate + /// for public bursts). + var isViewingPublicMeshTimeline: Bool { get } + func isPeerBlocked(_ peerID: PeerID) -> Bool + func resolveNickname(for peerID: PeerID) -> String + /// Routes an inbound private message through the full pipeline + /// (store append, unread state, notification, read receipt). + func handlePrivateMessage(_ message: BitchatMessage) + /// Appends directly to the public mesh timeline, bypassing the batched + /// public pipeline: a live bubble must be removable when its burst is + /// canceled or empty, which a pipeline-buffered entry is not (it would + /// re-commit at the next flush). + func appendPublicMeshMessage(_ message: BitchatMessage) + /// Replace-or-append by message ID via the single-writer store intent. + func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) + /// Replace-or-append by message ID in the public mesh timeline. + func upsertPublicMeshMessage(_ message: BitchatMessage) + @discardableResult + func removePrivateMessage(withID messageID: String) -> BitchatMessage? + /// Removes a message from whichever conversation holds it. + func removeMessage(withID messageID: String, cleanupFile: Bool) + /// Publishes who is currently talking live in the public mesh channel + /// (floor-courtesy indicator on the composer mic), nil when nobody is. + func setActivePublicVoiceTalker(_ nickname: String?) + func notifyUIChanged() +} + +extension ChatViewModel: ChatLiveVoiceContext { + var isViewingPublicMeshTimeline: Bool { + selectedPrivateChatPeer == nil && activeChannel == .mesh + } + + func appendPublicMeshMessage(_ message: BitchatMessage) { + _ = appendPublicMessage(message, to: ConversationID(channelID: .mesh)) + } + + func upsertPublicMeshMessage(_ message: BitchatMessage) { + conversations.upsertByID(message, in: ConversationID(channelID: .mesh)) + } + + func setActivePublicVoiceTalker(_ nickname: String?) { + if activePublicVoiceTalker != nickname { + activePublicVoiceTalker = nickname + } + } +} + +/// Where a live voice burst lives: a Noise DM or the public mesh timeline. +enum VoiceBurstScope: Hashable { + case directMessage + case publicMesh +} + +/// Assembles inbound live push-to-talk bursts (`NoisePayloadType.voiceFrame`): +/// orders packets behind a jitter window, persists frames progressively as an +/// ADTS `.aac` so even a partial burst is a replayable voice-note bubble, +/// optionally plays the stream live, and absorbs the sender's finalized +/// `.m4a` voice note (matched by the burst ID in its file name) into the same +/// bubble so nobody sees a duplicate. +@MainActor +final class ChatLiveVoiceCoordinator { + /// Burst IDs are sender-chosen, so they only identify a burst *within* + /// an authenticated (peer, scope) pair: keying assemblies by the full + /// triple stops an attacker who observed a public burst ID from racing + /// a START to capture the real talker's frames. + private struct AssemblyKey: Hashable { + let peerID: PeerID + let scope: VoiceBurstScope + let burstID: Data + } + + private final class Assembly { + let burstID: Data + let peerID: PeerID + let scope: VoiceBurstScope + let nickname: String + let message: BitchatMessage + var messageID: String { message.id } + var messageTimestamp: Date { message.timestamp } + let fileURL: URL + var fileHandle: FileHandle? + /// Data packets buffered ahead of `nextSeq` (seq -> frames). + var buffered: [UInt16: [Data]] = [:] + /// Next data-packet seq to deliver (seq 0 is START). + var nextSeq: UInt16 = 1 + var deliveredFrames = 0 + var receivedBytes = 0 + let firstPacketAt: Date + var endInfo: (totalDataPackets: UInt16, durationMs: UInt32)? + /// When a seq gap was first observed; after + /// `ChatLiveVoiceCoordinator.gapSkipSeconds` the gap is skipped. + var gapSince: Date? + var player: PTTBurstPlayer? + var idleTimeout: Task? + var gapRedrain: Task? + + var key: AssemblyKey { AssemblyKey(peerID: peerID, scope: scope, burstID: burstID) } + + init(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) { + self.burstID = burstID + self.peerID = peerID + self.scope = scope + self.nickname = nickname + self.message = message + self.fileURL = fileURL + self.fileHandle = fileHandle + self.firstPacketAt = Date() + } + } + + private struct FinishedBurst { + let messageID: String + let peerID: PeerID + let scope: VoiceBurstScope + let fileURL: URL + let messageTimestamp: Date + let expiresAt: Date + } + + /// How long a missing packet stalls delivery before being skipped. + private static let gapSkipSeconds: TimeInterval = 0.5 + private static let finishedBurstsCap = 32 + + private unowned let context: any ChatLiveVoiceContext + private let fileStore: BLEIncomingFileStore + /// Captures live in the store's directories, so file operations go + /// through the store's (injectable) file manager. + private var fileManager: FileManager { fileStore.fileManager } + private var assemblies: [AssemblyKey: Assembly] = [:] + private var finishedBursts: [AssemblyKey: FinishedBurst] = [:] + /// Players still draining after their assembly — the sole strong owner — + /// was discarded on burst END. Without this hold the player deallocates + /// mid-tail (or mid session-acquire, killing the whole burst's audio) + /// and its registered session token would only be reclaimed by the + /// deinit backstop. Entries remove themselves via `onStopped`. + private var drainingPlayers: [ObjectIdentifier: PTTBurstPlayer] = [:] + + /// `sweepsOnInit` exists for tests whose coordinator shares the real + /// application-support directory: they pass `false` so parallel test + /// runs never sweep each other's in-flight capture files. + init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), sweepsOnInit: Bool = true) { + self.context = context + self.fileStore = fileStore + // Orphaned partial captures from a previous session (live-only bursts + // whose finalized note never arrived) are dead weight the quota can + // never reclaim (eviction skips voice_live_* by design) — sweep them + // on startup. + if sweepsOnInit { + sweepStaleLiveCaptures() + } + } + + // MARK: - Inbound frames + + /// Inbound DM burst packet (`NoisePayloadType.voiceFrame`). + func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { + handle(payload, from: peerID, scope: .directMessage, nickname: context.resolveNickname(for: peerID), timestamp: timestamp) + } + + /// Inbound public burst packet (`MessageType.voiceFrame`), already + /// signature-verified by the transport, which resolved the nickname. + func handlePublicVoiceFramePayload(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) { + handle(payload, from: peerID, scope: .publicMesh, nickname: nickname, timestamp: timestamp) + } + + private func handle(_ payload: Data, from peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) { + // Live voice off means classic-notes-only in both directions: no live + // bubble, no partial file, no early notification — the finalized + // voice note still arrives through the normal pipeline. + guard PTTSettings.liveVoiceEnabled else { + SecureLogger.debug("PTT: dropping inbound voice frame — live voice is toggled off", category: .session) + return + } + guard let packet = VoiceBurstPacket.decode(payload) else { + SecureLogger.warning("PTT: undecodable voice frame from \(peerID.id.prefix(8))… (\(payload.count) bytes: \(payload.prefix(16).hexEncodedString())…)", category: .session) + return + } + guard !context.isPeerBlocked(peerID) else { + SecureLogger.debug("PTT: dropping voice frame from blocked peer \(peerID.id.prefix(8))…", category: .session) + return + } + + // The sender is authenticated (Noise session or packet signature), + // and the key binds the burst ID to that (peer, scope): a colliding + // START from another peer opens its own assembly instead of + // capturing this one's frames. + let key = AssemblyKey(peerID: peerID, scope: scope, burstID: packet.burstID) + if let assembly = assemblies[key] { + apply(packet, to: assembly) + return + } + + switch packet.kind { + case .start, .frames: + // A data packet with no prior START (lost or mid-burst join) + // still opens the assembly with the default codec. + guard assemblies.count < TransportConfig.pttMaxConcurrentAssemblies else { + SecureLogger.debug("PTT: dropping burst from \(peerID.id.prefix(8))… — assembly cap reached", category: .session) + return + } + guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return } + assemblies[key] = assembly + updatePublicTalkerIndicator() + apply(packet, to: assembly) + case .end, .canceled: + // Control packet for a burst we never saw — nothing to do. + break + } + } + + /// Whether this message is the bubble of a burst still streaming in. + func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool { + assemblies.values.contains { $0.messageID == message.id } + } + + /// Called for every inbound private message: when it is the finalized + /// voice note of a burst we assembled (matched by burst ID in the file + /// name), swap it into the existing live bubble and report `true` so the + /// caller skips normal handling — no duplicate row, no second + /// notification. + func absorbFinalizedVoiceNote(_ message: BitchatMessage) -> Bool { + let prefix = MimeType.Category.audio.messagePrefix + guard message.content.hasPrefix(prefix), + let burstID = Self.burstID(fromVoiceFileName: String(message.content.dropFirst(prefix.count))) + else { return false } + + // Bind the note to the burst's authenticated sender and scope: an + // attacker's burst reusing the same ID lives under its own key and + // never matches the real sender's note (registry is capped at + // `finishedBurstsCap`, so the linear scan is cheap). + func matches(_ key: AssemblyKey) -> Bool { + key.burstID == burstID + && (message.senderPeerID == nil || key.peerID == message.senderPeerID) + && message.isPrivate == (key.scope == .directMessage) + } + + // The note usually lands after END, but a lost END or a fast transfer + // can beat it — close out the live assembly first. + if let assembly = assemblies.first(where: { matches($0.key) })?.value { + finalize(assembly) + } + + pruneFinishedBursts() + guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false } + let finished = entry.value + + let replacement = BitchatMessage( + id: finished.messageID, + sender: message.sender, + content: message.content, + timestamp: finished.messageTimestamp, + isRelay: false, + originalSender: nil, + isPrivate: finished.scope == .directMessage, + recipientNickname: finished.scope == .directMessage ? context.nickname : nil, + senderPeerID: finished.peerID, + mentions: nil, + deliveryStatus: message.deliveryStatus + ) + switch finished.scope { + case .directMessage: + context.upsertPrivateMessage(replacement, in: finished.peerID) + case .publicMesh: + context.upsertPublicMeshMessage(replacement) + } + + // The complete .m4a replaces the partial live capture. + WaveformCache.shared.purge(url: finished.fileURL) + try? fileManager.removeItem(at: finished.fileURL) + finishedBursts.removeValue(forKey: entry.key) + + context.notifyUIChanged() + SecureLogger.debug("PTT: absorbed finalized note for burst \(burstID.hexEncodedString())", category: .session) + return true + } + + // MARK: - Assembly lifecycle + + private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? { + guard let fileURL = makeIncomingURL(burstID: burstID, peerID: peerID, scope: scope) else { + SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session) + return nil + } + // BCH-01-002: live captures share the incoming-media quota with + // finalized transfers; reserve the burst's worst case up front. + // Eviction skips voice_live_* names, so partials still streaming in + // are safe no matter which caller triggers enforcement. + fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes) + fileManager.createFile(atPath: fileURL.path, contents: nil) + guard let handle = try? FileHandle(forWritingTo: fileURL) else { + SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session) + try? fileManager.removeItem(at: fileURL) + return nil + } + + let isPrivate = scope == .directMessage + let message = BitchatMessage( + sender: nickname, + content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)", + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: isPrivate, + recipientNickname: isPrivate ? context.nickname : nil, + senderPeerID: peerID + ) + + let assembly = Assembly( + burstID: burstID, + peerID: peerID, + scope: scope, + nickname: nickname, + message: message, + fileURL: fileURL, + fileHandle: handle + ) + + // DM bubbles ride the full inbound pipeline (store append, unread, + // notification). Public bubbles append directly to the store: the + // batched public pipeline can't purge a buffered entry if the burst + // is canceled before the flush. + switch scope { + case .directMessage: + context.handlePrivateMessage(message) + case .publicMesh: + context.appendPublicMeshMessage(message) + } + + // Live playback only when the user is looking at this conversation + // with the app frontmost and live voice enabled. + let isViewing = switch scope { + case .directMessage: context.selectedPrivateChatPeer == peerID + case .publicMesh: context.isViewingPublicMeshTimeline + } + if PTTSettings.liveVoiceEnabled, PTTSettings.isAppActive, isViewing { + assembly.player = PTTBurstPlayer() + } + + SecureLogger.debug("PTT: burst \(burstID.hexEncodedString()) started from \(peerID.id.prefix(8))…", category: .session) + return assembly + } + + /// Keeps the composer's floor-courtesy indicator pointing at whoever is + /// currently talking live in the public mesh channel. + private func updatePublicTalkerIndicator() { + let talker = assemblies.values.first { $0.scope == .publicMesh }?.nickname + context.setActivePublicVoiceTalker(talker) + } + + private func apply(_ packet: VoiceBurstPacket, to assembly: Assembly) { + assembly.receivedBytes += packet.encode().count + let elapsed = Date().timeIntervalSince(assembly.firstPacketAt) + // Flood guards: a real burst arrives at ~2 KB/s. + guard assembly.receivedBytes <= TransportConfig.pttInboundMaxBytesPerSecond * Int(elapsed + 2), + assembly.receivedBytes <= TransportConfig.pttMaxBurstBytes + else { + SecureLogger.warning("PTT: burst from \(assembly.peerID.id.prefix(8))… exceeded rate/size caps — finalizing", category: .security) + finalize(assembly) + return + } + + rescheduleIdleTimeout(for: assembly) + + switch packet.kind { + case .start(let codec): + guard codec == .aacLC16kMono else { + // Codec we can't decode: drop the burst; the finalized note + // (whose MIME/magic the file handler validates) still arrives. + cancelAssembly(assembly) + return + } + case .frames(let frames): + guard packet.seq >= assembly.nextSeq, assembly.buffered[packet.seq] == nil else { return } + assembly.buffered[packet.seq] = frames + drainInOrder(assembly) + case .end(let totalDataPackets, let durationMs): + assembly.endInfo = (totalDataPackets, durationMs) + drainInOrder(assembly) + finalizeIfComplete(assembly) + case .canceled: + cancelAssembly(assembly) + } + } + + private func drainInOrder(_ assembly: Assembly) { + while true { + if let frames = assembly.buffered.removeValue(forKey: assembly.nextSeq) { + deliver(frames, to: assembly) + assembly.nextSeq &+= 1 + assembly.gapSince = nil + continue + } + guard !assembly.buffered.isEmpty else { + assembly.gapSince = nil + return + } + // Packets buffered ahead of a hole. + if let since = assembly.gapSince { + guard Date().timeIntervalSince(since) >= Self.gapSkipSeconds, + let smallest = assembly.buffered.keys.min() + else { return } + // Give up on the missing packet(s); playback underrun already + // covered the audible gap. + assembly.nextSeq = smallest + assembly.gapSince = nil + } else { + assembly.gapSince = Date() + scheduleGapRedrain(for: assembly) + return + } + } + } + + private func deliver(_ frames: [Data], to assembly: Assembly) { + for frame in frames { + do { + try assembly.fileHandle?.write(contentsOf: ADTSFramer.frame(frame)) + } catch { + SecureLogger.error("PTT: incoming burst write failed: \(error)", category: .session) + assembly.fileHandle = nil + } + } + assembly.deliveredFrames += frames.count + assembly.player?.enqueue(frames) + } + + private func finalizeIfComplete(_ assembly: Assembly) { + guard let end = assembly.endInfo else { return } + // All data packets delivered when nextSeq passed the last one + // (data seqs are 1...totalDataPackets). Otherwise stragglers may + // still arrive; the gap-redrain or idle timeout closes the burst. + if assembly.nextSeq > end.totalDataPackets { + finalize(assembly) + } + } + + private func finalize(_ assembly: Assembly) { + assembly.idleTimeout?.cancel() + assembly.gapRedrain?.cancel() + // Deliver whatever is decodable past any remaining holes. + while !assembly.buffered.isEmpty, let smallest = assembly.buffered.keys.min() { + assembly.nextSeq = smallest + if let frames = assembly.buffered.removeValue(forKey: smallest) { + deliver(frames, to: assembly) + assembly.nextSeq &+= 1 + } + } + try? assembly.fileHandle?.close() + assembly.fileHandle = nil + assemblies.removeValue(forKey: assembly.key) + updatePublicTalkerIndicator() + + guard assembly.deliveredFrames > 0 else { + // Nothing audible ever arrived — drop the empty bubble. + removeBubble(of: assembly) + try? fileManager.removeItem(at: assembly.fileURL) + context.notifyUIChanged() + return + } + + if let player = assembly.player, !player.stopped { + // Park the draining player: this method just dropped the + // assembly, and nothing else holds the player strongly. It may + // still be playing out its tail — or still acquiring the audio + // session off-main — so it must stay alive until it stops. + let id = ObjectIdentifier(player) + drainingPlayers[id] = player + player.onStopped = { [weak self] in + self?.drainingPlayers.removeValue(forKey: id) + } + player.finishAfterDrain() + } + // The bubble's waveform may have been computed from a partial file. + WaveformCache.shared.purge(url: assembly.fileURL) + // The capture is the bubble's replayable audio from here on (unless a + // finalized note arrives to swap in): move it off its voice_live_ + // name so only genuinely in-flight files match the startup sweep and + // the quota's live-capture guard — a kept fallback is never swept, + // and it ages out of the quota like any finalized media. + let fileURL = promoteToFallback(assembly.fileURL) + // Republish so the row re-renders without its LIVE treatment — and + // points at the promoted file — even if no note ever arrives. + republishBubble(of: assembly, fileURL: fileURL) + + pruneFinishedBursts() + finishedBursts[assembly.key] = FinishedBurst( + messageID: assembly.messageID, + peerID: assembly.peerID, + scope: assembly.scope, + fileURL: fileURL, + messageTimestamp: assembly.messageTimestamp, + expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds) + ) + context.notifyUIChanged() + SecureLogger.debug("PTT: burst \(assembly.burstID.hexEncodedString()) finalized (\(assembly.deliveredFrames) frames)", category: .session) + } + + private func removeBubble(of assembly: Assembly) { + switch assembly.scope { + case .directMessage: + context.removePrivateMessage(withID: assembly.messageID) + case .publicMesh: + context.removeMessage(withID: assembly.messageID, cleanupFile: false) + } + } + + private func republishBubble(of assembly: Assembly, fileURL: URL) { + // Same row (same ID), content re-pointed at `fileURL`; the delivery + // status is carried over because the inbound pipeline may have + // updated it on the shared original. + let message = BitchatMessage( + id: assembly.messageID, + sender: assembly.nickname, + content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)", + timestamp: assembly.messageTimestamp, + isRelay: false, + isPrivate: assembly.scope == .directMessage, + recipientNickname: assembly.scope == .directMessage ? context.nickname : nil, + senderPeerID: assembly.peerID, + deliveryStatus: assembly.message.deliveryStatus + ) + switch assembly.scope { + case .directMessage: + context.upsertPrivateMessage(message, in: assembly.peerID) + case .publicMesh: + context.upsertPublicMeshMessage(message) + } + } + + /// Moves a finished capture off its `voice_live_` prefix (onto plain + /// `voice_`), so the in-flight patterns — the startup sweep and the + /// quota's eviction guard — only ever match captures still streaming in. + /// On failure the live name is kept: worst case the file is reclaimed at + /// the next startup, exactly the pre-promotion behavior. + private func promoteToFallback(_ liveURL: URL) -> URL { + let liveName = liveURL.lastPathComponent + guard liveName.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) else { return liveURL } + let fallbackName = "voice_" + liveName.dropFirst(BLEIncomingFileStore.liveCapturePrefix.count) + let destination = liveURL.deletingLastPathComponent().appendingPathComponent(fallbackName) + do { + // A leftover from an earlier burst that reused the same + // (peer, scope, burstID) triple would block the move. + try? fileManager.removeItem(at: destination) + try fileManager.moveItem(at: liveURL, to: destination) + return destination + } catch { + SecureLogger.warning("PTT: keeping live-capture name for finished burst — promotion failed: \(error)", category: .session) + return liveURL + } + } + + private func cancelAssembly(_ assembly: Assembly) { + assembly.idleTimeout?.cancel() + assembly.gapRedrain?.cancel() + assembly.player?.stop() + try? assembly.fileHandle?.close() + assembly.fileHandle = nil + assemblies.removeValue(forKey: assembly.key) + updatePublicTalkerIndicator() + removeBubble(of: assembly) + WaveformCache.shared.purge(url: assembly.fileURL) + try? fileManager.removeItem(at: assembly.fileURL) + context.notifyUIChanged() + } + + // MARK: - Timers + + private func rescheduleIdleTimeout(for assembly: Assembly) { + assembly.idleTimeout?.cancel() + let key = assembly.key + assembly.idleTimeout = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttBurstEndTimeoutSeconds * 1_000_000_000)) + guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } + // Talker went silent/out of range without an END. + self.finalize(assembly) + } + } + + private func scheduleGapRedrain(for assembly: Assembly) { + assembly.gapRedrain?.cancel() + let key = assembly.key + assembly.gapRedrain = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64((Self.gapSkipSeconds + 0.05) * 1_000_000_000)) + guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } + self.drainInOrder(assembly) + self.finalizeIfComplete(assembly) + } + } + + // MARK: - Helpers + + private func pruneFinishedBursts() { + let now = Date() + finishedBursts = finishedBursts.filter { $0.value.expiresAt > now } + while finishedBursts.count >= Self.finishedBurstsCap { + guard let oldest = finishedBursts.min(by: { $0.value.expiresAt < $1.value.expiresAt }) else { break } + finishedBursts.removeValue(forKey: oldest.key) + } + } + + /// Extracts the 8-byte burst ID from a finalized note's file name + /// (`voice_<16 hex>.m4a`, possibly uniquified by the incoming file store). + static func burstID(fromVoiceFileName fileName: String) -> Data? { + guard fileName.hasPrefix("voice_") else { return nil } + let afterPrefix = fileName.dropFirst("voice_".count) + let hex = String(afterPrefix.prefix(16)) + guard hex.count == 16, hex.allSatisfy(\.isHexDigit) else { return nil } + return Data(hexString: hex) + } + + private static let incomingSubdirectory = "\(MimeType.Category.audio.mediaDir)/incoming" + + /// The peer ID and scope in the name mirror the assembly key: colliding + /// burst IDs from different senders — or from the same sender across DM + /// and public — land on distinct files instead of truncating each other. + /// `burstID(fromVoiceFileName:)` still rejects every `voice_live_*` name, + /// so live captures can never absorb a note. + private func makeIncomingURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> URL? { + guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory) else { return nil } + let scopeTag = scope == .directMessage ? "dm" : "mesh" + return directory.appendingPathComponent("\(BLEIncomingFileStore.liveCapturePrefix)\(burstID.hexEncodedString())_\(peerID.id)_\(scopeTag).aac") + } + + /// Deletes partial live captures left behind by a previous session. + /// In-session cleanup needs no sweep: absorb, cancel, and empty-finalize + /// delete their capture file, and finalize promotes keepers off the + /// `voice_live_` name. So anything the sweep matches was orphaned by a + /// crash mid-burst — safe to delete, because chat rows are in-memory + /// only (ConversationStore never persists; the gossip archive replays + /// `MessageType.message` packets only), so no row from a previous + /// process can reference the file. + private func sweepStaleLiveCaptures() { + guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory), + let contents = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + else { return } + for url in contents where url.lastPathComponent.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) && url.pathExtension == "aac" { + try? fileManager.removeItem(at: url) + } + } +} diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index ce51abe9..bf0009da 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator { try? FileManager.default.removeItem(at: url) await MainActor.run { [weak self] in guard let self else { return } - self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") + self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit")) } } catch { SecureLogger.error("Voice note send failed: \(error)", category: .session) await MainActor.run { [weak self] in guard let self else { return } - self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") + self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent")) } } } diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 0b52baa6..ae54966d 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - // MARK: Favorites & notifications (shared with the other contexts) + // MARK: Favorites (shared with the other contexts) /// The persisted favorite relationship for the peer's Noise static key, if any. func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? - /// Adds (or updates) a favorite in the favorites store. - func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) - /// Posts a generic local user notification. - func postLocalNotification(title: String, body: String, identifier: String) } extension ChatViewModel: ChatNostrContext { @@ -71,7 +67,7 @@ final class ChatNostrCoordinator { key: Data? ) { guard let context else { return } - if let _ = key { + if key != nil { if let identity = context.currentNostrIdentity() { context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) } @@ -84,7 +80,7 @@ final class ChatNostrCoordinator { } if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID { - if let _ = key { + if key != nil { if let identity = context.currentNostrIdentity() { context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) } @@ -98,57 +94,6 @@ final class ChatNostrCoordinator { } } - @MainActor - func handleFavoriteNotification(content: String, from nostrPubkey: String) { - guard let context else { return } - guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return } - - let isFavorite = content.contains("FAVORITE:TRUE") - let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" - - if isFavorite { - context.addFavorite( - noiseKey: senderNoiseKey, - nostrPublicKey: nostrPubkey, - nickname: senderNickname - ) - } - - var extractedNostrPubkey: String? - if let range = content.range(of: "NPUB:") { - let suffix = content[range.upperBound...] - let parts = suffix.components(separatedBy: "|") - if let key = parts.first { - extractedNostrPubkey = String(key) - } - } else if content.contains(":") { - let parts = content.components(separatedBy: ":") - if parts.count >= 3 { - extractedNostrPubkey = String(parts[2]) - } - } - - SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session) - - if isFavorite && extractedNostrPubkey != nil { - SecureLogger.info( - "💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", - category: .session - ) - context.addFavorite( - noiseKey: senderNoiseKey, - nostrPublicKey: extractedNostrPubkey, - nickname: senderNickname - ) - } - - context.postLocalNotification( - title: isFavorite ? "New Favorite" : "Favorite Removed", - body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", - identifier: "fav-\(UUID().uuidString)" - ) - } - @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { guard let context else { return } diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 915ea7fa..1bb3e213 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -42,6 +42,12 @@ protocol ChatOutgoingContext: AnyObject { func recordPublicActivity(forChannelKey key: String) func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendGeohash(context: ChatViewModel.GeoOutgoingContext) + /// Ships the bridged (rendezvous) copy of a just-sent public mesh + /// message; no-op when the bridge is off or the send is nearby-only. + /// Takes the origin coordinates (sender + wire timestamp) — the bridge + /// derives the cross-device-stable mesh message ID from them, not from + /// our local timeline UUID (which no other device can recompute). + func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) // MARK: Geohash identity (shared with the other contexts) func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity @@ -62,16 +68,32 @@ extension ChatViewModel: ChatOutgoingContext { func recordPublicActivity(forChannelKey key: String) { lastPublicActivityAt[key] = Date() } + + func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) { + BridgeService.shared.bridgeOutgoing(content: content, senderPeerID: senderPeerID, timestamp: timestamp) + } } @MainActor final class ChatOutgoingCoordinator { private unowned let context: any ChatOutgoingContext + /// In-flight NIP-13 mining for the most recent geohash send. A newer send + /// (or leaving the channel) cancels it, which only expedites the mining — + /// the message still goes out at the difficulty already reached. + /// (Read access is internal so tests can await the send's completion.) + private(set) var geohashMiningTask: Task? + init(context: any ChatOutgoingContext) { self.context = context } + /// Finish any in-flight geohash PoW mining early (the pending message + /// still sends, at whatever committed difficulty it reached). + func expeditePendingGeohashMining() { + geohashMiningTask?.cancel() + } + func sendMessage(_ content: String) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } @@ -92,120 +114,125 @@ final class ChatOutgoingCoordinator { } let mentions = context.parseMentions(from: content) - let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions) - guard let preparedMessage else { return } - appendLocalEcho(preparedMessage.message) - routePublicMessage( - originalContent: content, - mentions: mentions, - geoContext: preparedMessage.geoContext, - messageID: preparedMessage.message.id, - timestamp: preparedMessage.message.timestamp - ) + switch context.activeChannel { + case .mesh: + sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions) + case .location(let channel): + sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel) + } + } + + /// Broadcasts a wave on the mesh channel regardless of the active channel — + /// used by the "bitchatters nearby" notification quick action, which always + /// refers to mesh peers. + func sendMeshWave() { + sendMeshPublicMessage(originalContent: "👋", trimmed: "👋", mentions: []) } } private extension ChatOutgoingCoordinator { - func preparePublicMessage( - content: String, - trimmed: String, - mentions: [String] - ) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? { - var geoContext: ChatViewModel.GeoOutgoingContext? - var displaySender = context.nickname - var localSenderPeerID = context.myPeerID - var messageID: String? - var messageTimestamp = Date() + func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) { + let message = BitchatMessage( + sender: context.nickname, + content: trimmed, + timestamp: Date(), + isRelay: false, + senderPeerID: context.myPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) - switch context.activeChannel { - case .mesh: - break + appendLocalEcho(message, to: .mesh) + context.recordPublicActivity(forChannelKey: "mesh") + context.sendMeshMessage( + originalContent, + mentions: mentions, + messageID: message.id, + timestamp: message.timestamp + ) + context.bridgeOutgoingPublicMessage(trimmed, senderPeerID: context.myPeerID, timestamp: message.timestamp) + } - case .location(let channel): + /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see + /// `NostrPoW`), so the whole echo-and-send runs in a task once the signed + /// event — whose ID is also the local message ID — exists. Typical mining + /// at the default target is well under 100 ms and hard-capped at + /// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed. + func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) { + let identity: NostrIdentity + do { + identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) + } catch { + SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) + context.addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return + } + + let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4)) + let senderPeerID = PeerID(nostr: identity.publicKeyHex) + let teleported = context.isTeleported + let nickname = context.nickname + + // Serialize geohash sends: each send awaits the previous send's task + // before it appends + relays, so user-visible order always matches + // send order even when an earlier message mines longer than a later + // one. Cancelling the previous task only *expedites* its mining (the + // NIP-13 target is polled, not aborted), so it still finishes and + // sends — and it finishes fast, so awaiting it never stacks mining + // delays or blocks a send beyond `NostrPoW.miningTimeCap`. + let previousSend = geohashMiningTask + previousSend?.cancel() + geohashMiningTask = Task { @MainActor [weak context = self.context] in + await previousSend?.value + + let event: NostrEvent do { - let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = context.nickname + "#" + suffix - localSenderPeerID = PeerID(nostr: identity.publicKeyHex) - - let teleported = context.isTeleported - let event = try NostrProtocol.createEphemeralGeohashEvent( + event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: trimmed, geohash: channel.geohash, senderIdentity: identity, - nickname: context.nickname, - teleported: teleported - ) - - messageID = event.id - messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - geoContext = ( - channel: channel, - event: event, - identity: identity, + nickname: nickname, teleported: teleported ) } catch { SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) - context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - return nil - } - } - - let message = BitchatMessage( - id: messageID, - sender: displaySender, - content: trimmed, - timestamp: messageTimestamp, - isRelay: false, - senderPeerID: localSenderPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - return (message, geoContext) - } - - func appendLocalEcho(_ message: BitchatMessage) { - context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel)) - - let contentKey = context.normalizedContentKey(message.content) - context.recordContentKey(contentKey, timestamp: message.timestamp) - } - - func routePublicMessage( - originalContent: String, - mentions: [String], - geoContext: ChatViewModel.GeoOutgoingContext?, - messageID: String, - timestamp: Date - ) { - switch context.activeChannel { - case .mesh: - context.recordPublicActivity(forChannelKey: "mesh") - context.sendMeshMessage( - originalContent, - mentions: mentions, - messageID: messageID, - timestamp: timestamp - ) - - case .location(let channel): - context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") - - guard let geoContext, geoContext.channel.geohash == channel.geohash else { - SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session) - context.addSystemMessage( + context?.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return } + guard let context else { return } - Task { @MainActor [weak context = self.context] in - context?.sendGeohash(context: geoContext) - } + let message = BitchatMessage( + id: event.id, + sender: displaySender, + content: trimmed, + timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)), + isRelay: false, + senderPeerID: senderPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel))) + let contentKey = context.normalizedContentKey(message.content) + context.recordContentKey(contentKey, timestamp: message.timestamp) + + context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") + context.sendGeohash(context: ( + channel: channel, + event: event, + identity: identity, + teleported: teleported + )) } } + + func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) { + context.appendPublicMessage(message, to: conversationID) + + let contentKey = context.normalizedContentKey(message.content) + context.recordContentKey(contentKey, timestamp: message.timestamp) + } } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 46e0787a..93b0f32a 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -29,7 +29,6 @@ protocol ChatPeerIdentityContext: AnyObject { func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) var selectedPrivateChatPeer: PeerID? { get set } var selectedPrivateChatFingerprint: String? { get set } - var nickname: String { get } var myPeerID: PeerID { get } var activeChannel: ChannelID { get } /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). @@ -104,7 +103,7 @@ protocol ChatPeerIdentityContext: AnyObject { extension ChatViewModel: ChatPeerIdentityContext { // `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`, - // `selectedPrivateChatFingerprint`, `nickname`, `myPeerID`, + // `selectedPrivateChatFingerprint`, `myPeerID`, // `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`, // `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`, // `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`, @@ -323,6 +322,15 @@ final class ChatPeerIdentityCoordinator { func startPrivateChat(with peerID: PeerID) { guard peerID != context.myPeerID else { return } + // Group chats are virtual conversations: no peer identity, favorites, + // handshake, or message consolidation applies — just select the chat. + if peerID.isGroup { + context.selectedPrivateChatFingerprint = nil + context.beginPrivateChatSession(with: peerID) + context.markPrivateChatRead(peerID) + return + } + let peerNickname = context.peerNickname(for: peerID) ?? "unknown" if context.unifiedIsBlocked(peerID) { @@ -339,20 +347,10 @@ final class ChatPeerIdentityCoordinator { return } - if let peer = context.unifiedPeer(for: peerID), - peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { - context.addSystemMessage( - String( - format: String( - localized: "system.chat.requires_favorite", - comment: "System message when mutual favorite requirement blocks chat" - ), - locale: .current, - peerNickname - ) - ) - return - } + // No mutual-favorite gate: store-and-forward (couriers, bridge drops, + // retained outbox) only needs the recipient's noise key, so an + // offline non-mutual favorite is still worth writing to — the router + // decides what delivery looks like, not chat entry. _ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname) @@ -545,7 +543,8 @@ final class ChatPeerIdentityCoordinator { !favorite.peerNickname.isEmpty { return favorite.peerNickname } - return "user" + // "anon" matches the default-nickname convention; "user" is banned copy. + return "anon" } } diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 77a40c6b..bd045939 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -36,6 +36,9 @@ protocol ChatPeerListContext: AnyObject { // MARK: Notifications /// Posts the "bitchatters nearby" local notification. func notifyNetworkAvailable(peerCount: Int) + + /// Records peers seen within range for the daily ambient sightings tally. + func recordMeshSightings(peerIDs: [PeerID]) } extension ChatViewModel: ChatPeerListContext { @@ -60,6 +63,12 @@ extension ChatViewModel: ChatPeerListContext { func notifyNetworkAvailable(peerCount: Int) { NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount) } + + func recordMeshSightings(peerIDs: [PeerID]) { + for peerID in peerIDs { + MeshSightingsTracker.shared.recordSighting(peerID: peerID) + } + } } final class ChatPeerListCoordinator: @unchecked Sendable { @@ -129,6 +138,7 @@ private extension ChatPeerListCoordinator { } invalidateNetworkEmptyTimer() + context.recordMeshSightings(peerIDs: meshPeers) let newPeers = meshPeerSet.subtracting(recentlySeenPeers) // Record every sighted peer even when no notification fires. A peer diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index f1d9e1f7..0bfca931 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -19,7 +19,6 @@ protocol ChatPrivateConversationContext: AnyObject { /// lookup on `ChatViewModel` (no `privateChats` dictionary build). func privateMessages(for peerID: PeerID) -> [BitchatMessage] var sentReadReceipts: Set { get } - var unreadPrivateMessages: Set { get } var selectedPrivateChatPeer: PeerID? { get } var nickname: String { get } var activeChannel: ChannelID { get } @@ -40,8 +39,6 @@ protocol ChatPrivateConversationContext: AnyObject { func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool func markPrivateChatUnread(_ peerID: PeerID) func markPrivateChatRead(_ peerID: PeerID) - /// Removes the peer's chat entirely, including unread state. - func removePrivateChat(_ peerID: PeerID) /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat /// (dedup by ID, order preserved, unread carried, old chat removed). func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) @@ -86,21 +83,25 @@ protocol ChatPrivateConversationContext: AnyObject { // MARK: Routing & acknowledgements func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) - func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) + @discardableResult + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) // MARK: System messages - func addSystemMessage(_ content: String) func addMeshOnlySystemMessage(_ content: String) + /// Appends a local-only system line into a specific private thread — + /// errors about a DM belong in that DM, not on the active timeline. + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) // MARK: Favorites & notifications /// The persisted favorite relationship for the peer's Noise static key, if any. func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// The persisted favorite relationship resolved from a short 16-hex mesh + /// peer ID (matched against the IDs derived from stored noise keys). + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? /// Persists that the peer favorited/unfavorited us (favorites store write). func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) /// Posts the incoming-private-message local notification. @@ -160,7 +161,8 @@ extension ChatViewModel: ChatPrivateConversationContext { messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) } - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + @discardableResult + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { messageRouter.sendReadReceipt(receipt, to: peerID) } @@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext { FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) } + // `favoriteRelationship(forPeerID:)` is shared with + // `ChatPeerIdentityContext`; its witness lives in + // `ChatPeerIdentityCoordinator.swift`. + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { FavoritesPersistenceService.shared.updatePeerFavoritedUs( peerNoisePublicKey: noiseKey, @@ -221,21 +227,41 @@ extension ChatViewModel: ChatPrivateConversationContext { final class ChatPrivateConversationCoordinator { private unowned let context: any ChatPrivateConversationContext + // Outbox retries re-wrap the same message in fresh gift-wrap events, so + // relay-level event-ID dedup can't catch them; track inbound GeoDM + // message IDs so each copy past the first costs one (already-deduped) + // ack check and nothing else. + private var seenInboundGeoDMIDs: Set = [] + private var seenInboundGeoDMOrder: [String] = [] + private static let seenInboundGeoDMCap = 512 + init(context: any ChatPrivateConversationContext) { self.context = context } + /// Returns `false` if this GeoDM message ID was already handled. + private func markInboundGeoDMSeen(_ messageId: String) -> Bool { + guard !seenInboundGeoDMIDs.contains(messageId) else { return false } + seenInboundGeoDMIDs.insert(messageId) + seenInboundGeoDMOrder.append(messageId) + if seenInboundGeoDMOrder.count > Self.seenInboundGeoDMCap { + seenInboundGeoDMIDs.remove(seenInboundGeoDMOrder.removeFirst()) + } + return true + } + func sendPrivateMessage(_ content: String, to peerID: PeerID) { guard !content.isEmpty else { return } if context.isPeerBlocked(peerID) { - let nickname = context.peerNickname(for: peerID) ?? "user" - context.addSystemMessage( + let nickname = context.peerNickname(for: peerID) ?? "anon" + context.addLocalPrivateSystemMessage( String( - format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), + format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked person"), locale: .current, nickname - ) + ), + to: peerID ) return } @@ -245,18 +271,23 @@ final class ChatPrivateConversationCoordinator { return } - guard let noiseKey = Data(hexString: peerID.id) else { return } + // Resolve the favorite behind this conversation. It may be keyed by + // the full 64-hex noise-key ID (offline favorite row) or the short + // 16-hex mesh ID — the raw hex bytes of a short ID are a routing ID, + // never a noise key, so they must not be used as a favorites key. + let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID) let isConnected = context.isPeerConnected(peerID) let isReachable = context.isPeerReachable(peerID) - let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) + let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) } + ?? context.favoriteRelationship(forPeerID: peerID) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil - var recipientNickname = context.peerNickname(for: peerID) - if recipientNickname == nil && favoriteStatus != nil { - recipientNickname = favoriteStatus?.peerNickname - } - recipientNickname = recipientNickname ?? "user" + // "anon" matches the app's default-nickname convention; "user" is + // banned copy. + let recipientNickname = context.peerNickname(for: peerID) + ?? favoriteStatus?.peerNickname + ?? "anon" let messageID = UUID().uuidString let message = BitchatMessage( @@ -276,37 +307,35 @@ final class ChatPrivateConversationCoordinator { context.appendPrivateMessage(message, to: peerID) context.notifyUIChanged() + // Always hand the message to the router — it owns delivery. A live + // link sends now; an unreachable peer gets the retained-outbox path + // (resend on reconnect, courier deposits, bridge drops). Pre-judging + // reachability here used to mark the message failed without ever + // routing it, silently bypassing all of that (field-found: DMs + // composed after a peer's reachability window lapsed were dead on + // arrival while identical DMs sent a minute earlier delivered). + context.routePrivateMessage( + content, + to: peerID, + recipientNickname: recipientNickname, + messageID: messageID + ) if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { - context.routePrivateMessage( - content, - to: peerID, - recipientNickname: recipientNickname ?? "user", - messageID: messageID - ) context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID) - } else { - context.setPrivateDeliveryStatus( - .failed( - reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") - ), - forMessageID: messageID, - peerID: peerID - ) - let name = recipientNickname ?? "user" - context.addSystemMessage( - String( - format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), - locale: .current, - name - ) - ) } + // Otherwise the message stays "sending"; router callbacks move it to + // carried (📦) when a courier/bridge copy ships, delivered/read on + // acks, or failed when the outbox TTL expires. } func sendGeohashDM(_ content: String, to peerID: PeerID) { guard case .location(let channel) = context.activeChannel else { - context.addSystemMessage( - String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") + // The failure happened inside a geoDM thread — surface it there, + // not on the public timeline (matches the sibling blocked/unknown + // errors routed into the thread by #1415). + context.addLocalPrivateSystemMessage( + String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel"), + to: peerID ) return } @@ -346,8 +375,9 @@ final class ChatPrivateConversationCoordinator { forMessageID: messageID, peerID: peerID ) - context.addSystemMessage( - String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") + context.addLocalPrivateSystemMessage( + String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because the person is blocked"), + to: peerID ) return } @@ -397,17 +427,45 @@ final class ChatPrivateConversationCoordinator { guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } let messageId = pm.messageID - SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) - + // Ack before the dedup guard: a re-sent copy means the sender may not + // have our DELIVERED yet, and markGeoDeliveryAckSent dedups the + // actual sends. sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + guard markInboundGeoDMSeen(messageId) else { return } + + SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) + if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { return } + // Prefer the favorite's stored nickname when the sender resolved to a + // known noise key; the Nostr display name is a geohash-scoped + // fallback (e.g. "anon#678e") that would mislabel favorite-transport + // DMs. Geohash conversations (nostr_ keys) keep the geo name. + let senderName: String = { + if let noiseKey = convKey.noiseKey, + let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname, + !favoriteNickname.isEmpty { + return favoriteNickname + } + return context.displayNameForNostrPubkey(senderPubkey) + }() + + // Favorite notifications ride the PM channel over Nostr too; intercept + // them so they update the relationship instead of rendering as text. + if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") { + handleFavoriteNotification( + pm.content, + from: convKey, + senderNickname: senderName + ) + return + } + if context.privateChatsContainMessage(withID: messageId) { return } - let senderName = context.displayNameForNostrPubkey(senderPubkey) let message = BitchatMessage( id: messageId, sender: senderName, @@ -456,7 +514,10 @@ final class ChatPrivateConversationCoordinator { category: .session ) } else { - SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) + // A stale ack for a message this device no longer tracks (dropped + // outbox entry, cleared chat, or a peer re-acking after losing our + // receipt) — expected occasionally, not actionable. + SecureLogger.debug("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) } } @@ -486,93 +547,6 @@ final class ChatPrivateConversationCoordinator { context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) } - func handlePrivateMessage( - _ payload: NoisePayload, - actualSenderNoiseKey: Data?, - senderNickname: String, - targetPeerID: PeerID, - messageTimestamp: Date, - senderPubkey: String - ) { - guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } - let messageId = pm.messageID - let messageContent = pm.content - - if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") { - if let key = actualSenderNoiseKey { - handleFavoriteNotificationFromMesh( - messageContent, - from: PeerID(hexData: key), - senderNickname: senderNickname - ) - } - return - } - - if isDuplicateMessage(messageId, targetPeerID: targetPeerID) { - return - } - - let wasReadBefore = context.sentReadReceipts.contains(messageId) - - var isViewingThisChat = false - if context.selectedPrivateChatPeer == targetPeerID { - isViewingThisChat = true - } else if let selectedPeer = context.selectedPrivateChatPeer, - let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer), - let key = actualSenderNoiseKey, - selectedPeerNoiseKey == key { - isViewingThisChat = true - } - - let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 - let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage - - let message = BitchatMessage( - id: messageId, - sender: senderNickname, - content: messageContent, - timestamp: messageTimestamp, - isRelay: false, - isPrivate: true, - recipientNickname: context.nickname, - senderPeerID: targetPeerID, - deliveryStatus: .delivered(to: context.nickname, at: Date()) - ) - - addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) - mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) - - context.sendDeliveryAckViaNostrEmbedded( - message, - wasReadBefore: wasReadBefore, - senderPubkey: senderPubkey, - key: actualSenderNoiseKey - ) - - if wasReadBefore { - // No-op. - } else if isViewingThisChat { - handleViewingThisChat( - message, - targetPeerID: targetPeerID, - key: actualSenderNoiseKey, - senderPubkey: senderPubkey - ) - } else { - markAsUnreadIfNeeded( - shouldMarkAsUnread: shouldMarkAsUnread, - targetPeerID: targetPeerID, - key: actualSenderNoiseKey, - isRecentMessage: isRecentMessage, - senderNickname: senderNickname, - messageContent: messageContent - ) - } - - context.notifyUIChanged() - } - func handlePrivateMessage(_ message: BitchatMessage) { SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) @@ -583,7 +557,7 @@ final class ChatPrivateConversationCoordinator { } if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { - handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) + handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender) return } @@ -632,7 +606,7 @@ final class ChatPrivateConversationCoordinator { /// O(1)-per-conversation dedup via the store's message-ID indexes /// (replaces the full scan over every private chat). - func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { + func isDuplicateMessage(_ messageId: String, targetPeerID _: PeerID) -> Bool { context.privateChatsContainMessage(withID: messageId) } @@ -706,7 +680,10 @@ final class ChatPrivateConversationCoordinator { } } - func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { + /// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either + /// transport. `peerID` must resolve to a noise key — a full 64-hex ID or + /// one the unified peer list knows; otherwise the notification is dropped. + func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) { let isFavorite = content.hasPrefix("[FAVORITED]") let parts = content.split(separator: ":") @@ -848,25 +825,6 @@ final class ChatPrivateConversationCoordinator { } } - func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - var noiseKey: Data? - - if let hexKey = Data(hexString: peerID.id) { - noiseKey = hexKey - } else if let peerNoiseKey = context.noisePublicKey(for: peerID) { - noiseKey = peerNoiseKey - } - - if context.isPeerConnected(peerID) { - context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite) - SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session) - } else if let key = noiseKey { - context.routeFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) - } else { - SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session) - } - } - func isMessageBlocked(_ message: BitchatMessage) -> Bool { if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) { if context.isPeerBlocked(peerID) { return true } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index d6777a71..2cd1b4a9 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -36,9 +36,6 @@ protocol ChatPublicConversationContext: AnyObject { /// message with the same ID is already in that conversation. @discardableResult func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool - /// Appends a geohash message if absent. Returns `true` when stored. - @discardableResult - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool /// Removes a message by ID from whichever public conversation contains it. @discardableResult @@ -82,7 +79,9 @@ protocol ChatPublicConversationContext: AnyObject { // MARK: Inbound public message processing func processActionMessage(_ message: BitchatMessage) -> BitchatMessage func isMessageBlocked(_ message: BitchatMessage) -> Bool - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool + /// `powBits` is the validated NIP-13 difficulty of the source Nostr event + /// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket. + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool /// Buffers a visible-channel message for the batched (~80 ms) pipeline /// flush, which commits it to `conversationID` in the store. func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) @@ -107,7 +106,6 @@ extension ChatViewModel: ChatPublicConversationContext { // `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`, // `deriveNostrIdentity(forGeohash:)`, the public conversation store // intents (`appendPublicMessage(_:to:)`, - // `appendGeohashMessageIfAbsent(_:toGeohash:)`, // `publicConversationContainsMessage(withID:in:)`, // `removePublicMessage(withID:)`, // `removePublicMessages(fromGeohash:where:)`, @@ -137,8 +135,8 @@ extension ChatViewModel: ChatPublicConversationContext { meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) } - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { - publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { + publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits) } func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { @@ -290,7 +288,26 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func clearCurrentPublicTimeline() { context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) + // Clearing the mesh timeline also dismisses its archived echoes for + // good: the watermark stops the next launch from re-seeding them + // (the archive itself keeps carrying the messages for peers), and + // the dedup keys go so a cleared message arriving live shows again. + if case .mesh = context.activeChannel { + MeshEchoSettings.clearedThrough = Date() + archivedEchoKeys.removeAll() + } + + // The SPM test process shares the real Application Support tree, so this + // detached deletion can land mid-test under parallel scheduling and flake + // a file-dependent test. Tests never need the on-disk media cleared. + guard !TestEnvironment.isRunningTests else { return } + Task.detached(priority: .utility) { + // Skipped under tests: the test process shares the user's real + // ~/Library/Application Support/files tree, and this detached + // wipe fires at a nondeterministic time — racing tests that + // write media there (see the same guard in panicClearAllData). + guard !TestEnvironment.isRunningTests else { return } do { let base = try FileManager.default.url( for: .applicationSupportDirectory, @@ -367,7 +384,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { guard let context else { return } do { let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: content, geohash: channel.geohash, senderIdentity: identity, @@ -395,7 +412,29 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { ) } - func handlePublicMessage(_ message: BitchatMessage) { + /// - Parameter powBits: validated NIP-13 difficulty of the source Nostr + /// event (0 for mesh messages). Sufficient PoW relaxes the per-sender + /// rate limit; low/no-PoW events keep the strict limits so old clients + /// still get through at normal rates. + /// Identity keys of the archived echoes seeded into the mesh timeline at + /// launch. Re-synced copies of others' messages now arrive with the same + /// derived stable ID (`MeshMessageIdentity`), so the store's insert-by-ID + /// catches those — but the archive-restored rows themselves carry + /// `echo-`-prefixed IDs, and self echoes get fresh UUIDs, so this content + /// identity remains the way to recognize a live copy of an + /// already-rendered echo. + private var archivedEchoKeys = Set() + + func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) { + archivedEchoKeys.insert(Self.archivedEchoKey(senderPeerID: senderPeerID, timestamp: timestamp, content: content)) + } + + static func archivedEchoKey(senderPeerID: PeerID?, timestamp: Date, content: String) -> String { + let ms = UInt64((timestamp.timeIntervalSince1970 * 1000).rounded()) + return "\(senderPeerID?.id ?? "")|\(ms)|\(content)" + } + + func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) { let finalMessage = context.processActionMessage(message) if context.isMessageBlocked(finalMessage) { return } @@ -405,7 +444,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { if shouldRateLimit { let senderKey = normalizedSenderKey(for: finalMessage) let contentKey = context.normalizedContentKey(finalMessage.content) - if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) { + if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) { return } } @@ -430,6 +469,17 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } guard let destination else { return } + // A live copy of a message already rendered as an archived echo + // (e.g. re-served by a peer's gossip sync) would duplicate the row. + if destination == .mesh, !isSystem { + let key = Self.archivedEchoKey( + senderPeerID: finalMessage.senderPeerID, + timestamp: finalMessage.timestamp, + content: finalMessage.content + ) + if archivedEchoKeys.contains(key) { return } + } + let channelMatches: Bool = { switch context.activeChannel { case .mesh: return !isGeo || isSystem @@ -504,27 +554,27 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { #endif } - func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { + func pipeline(_: PublicMessagePipeline, normalizeContent content: String) -> String { context.normalizedContentKey(content) } - func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { + func pipeline(_: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { context.contentTimestamp(forKey: key) } - func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { + func pipeline(_: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { context.recordContentKey(key, timestamp: timestamp) } - func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { + func pipeline(_: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { context.appendPublicMessage(message, to: conversationID) } - func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { + func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) { context.prewarmMessageFormatting(message) } - func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { + func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) { context.setPublicBatching(isBatching) } } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 31c38f7b..1ec74850 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject { // MARK: Routing & acknowledgements func flushRouterOutbox(for peerID: PeerID) + /// Offer queued mail for *other* peers to this newly connected courier. + func retryCourierDeposits(via peerID: PeerID) func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) // MARK: Delivery status @@ -69,6 +71,14 @@ protocol ChatTransportEventContext: AnyObject { // MARK: Verification payloads func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) + + // MARK: Live voice (push-to-talk) + func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) + + // MARK: Group payloads (creator-signed state over Noise) + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) + func handleVouchPayload(from peerID: PeerID, payload: Data) } extension ChatViewModel: ChatTransportEventContext { @@ -103,6 +113,10 @@ extension ChatViewModel: ChatTransportEventContext { messageRouter.flushOutbox(for: peerID) } + func retryCourierDeposits(via peerID: PeerID) { + messageRouter.courierBecameAvailable(peerID) + } + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { meshService.sendDeliveryAck(for: messageID, to: peerID) } @@ -123,6 +137,22 @@ extension ChatViewModel: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) } + + // `handleVoiceFramePayload(from:payload:timestamp:)` lives in + // ChatViewModel+PrivateChat.swift next to the rest of the live-voice + // surface. + + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + } + + func handleVouchPayload(from peerID: PeerID, payload: Data) { + vouchCoordinator.handleVouchPayload(from: peerID, payload: payload) + } } final class ChatTransportEventCoordinator { @@ -208,6 +238,7 @@ final class ChatTransportEventCoordinator { } context.flushRouterOutbox(for: peerID) + context.retryCourierDeposits(via: peerID) } } @@ -364,6 +395,18 @@ private extension ChatTransportEventCoordinator { case .verifyResponse: context.handleVerifyResponsePayload(from: peerID, payload: payload) + + case .groupInvite: + context.handleGroupInvitePayload(from: peerID, payload: payload) + + case .groupKeyUpdate: + context.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + + case .vouch: + context.handleVouchPayload(from: peerID, payload: payload) + + case .voiceFrame: + context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index e82ebd35..3b78797d 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -24,6 +24,10 @@ protocol ChatVerificationContext: AnyObject { func setStoredVerified(_ fingerprint: String, verified: Bool) func isVerifiedFingerprint(_ fingerprint: String) -> Bool func saveIdentityState() + /// After a fingerprint becomes verified, run a transitive-vouch pass over + /// currently connected peers (so verifying a peer you're already connected + /// to sends vouches immediately, and the new identity propagates onward). + func vouchToConnectedVerifiedPeers() // MARK: Encryption status func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) @@ -86,6 +90,10 @@ extension ChatViewModel: ChatVerificationContext { peerIdentityStore.setVerified(fingerprint, verified: verified) } + func vouchToConnectedVerifiedPeers() { + vouchCoordinator.vouchToConnectedVerifiedPeers() + } + var unifiedPeers: [BitchatPeer] { unifiedPeerService.peers } @@ -127,7 +135,6 @@ final class ChatVerificationCoordinator { let noiseKeyHex: String let signKeyHex: String let nonceA: Data - let startedAt: Date var sent: Bool } @@ -148,6 +155,9 @@ final class ChatVerificationCoordinator { context.saveIdentityState() context.setStoredVerified(fingerprint, verified: true) context.updateEncryptionStatus(for: peerID) + // Verifying a peer is a vouch trigger: push attestations to my other + // connected verified peers (and to this one if already connected). + context.vouchToConnectedVerifiedPeers() } func unverifyFingerprint(for peerID: PeerID) { @@ -247,7 +257,6 @@ final class ChatVerificationCoordinator { noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, - startedAt: Date(), sent: false ) pendingQRVerifications[peerID] = pending @@ -340,6 +349,8 @@ final class ChatVerificationCoordinator { } context.updateEncryptionStatus(for: peerID) + // QR verification just completed — same vouch trigger as manual verify. + context.vouchToConnectedVerifiedPeers() } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a38dc529..24caaa86 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -84,7 +84,6 @@ import SwiftUI import Combine import CommonCrypto import CoreBluetooth -import Tor #if os(iOS) import UIKit #endif @@ -102,7 +101,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor var canSendMediaInCurrentContext: Bool { if let peer = selectedPrivateChatPeer { - return !(peer.isGeoDM || peer.isGeoChat) + // Media transfer is not wired for groups in v1 (sendFilePrivate + // rejects the virtual group_ recipient), so keep the affordance off. + return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup) } switch activeChannel { case .mesh: return true @@ -176,12 +177,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) + lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) + lazy var groupCoordinator = ChatGroupCoordinator(context: self) + lazy var vouchCoordinator = ChatVouchCoordinator(context: self) // Computed properties for compatibility @MainActor var connectedPeers: Set { unifiedPeerService.connectedPeerIDs } @Published var allPeers: [BitchatPeer] = [] + /// Nickname of whoever is talking live in the public mesh channel right + /// now (floor-courtesy indicator on the composer mic), nil when nobody. + @Published var activePublicVoiceTalker: String? /// Read-only derived view of all direct conversations in the /// `ConversationStore`, keyed by routing peer ID. Serves the coordinator @@ -214,12 +221,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele conversations.unreadDirectRoutingPeerIDs() } - /// Check if there are any unread messages (including from temporary Nostr peer IDs) - @MainActor - var hasAnyUnreadMessages: Bool { - !unreadPrivateMessages.isEmpty - } - /// Open the most relevant private chat when tapping the toolbar unread icon. /// Prefers the most recently active unread conversation, otherwise the most recent PM. @MainActor @@ -237,20 +238,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele set { peerIdentityStore.setSelectedPrivateChatFingerprint(newValue) } } - // Resolve full Noise key for a peer's short ID (used by UI header rendering) - @MainActor - private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? { - if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped } - // Fallback: derive from active Noise session if available - if shortPeerID.id.count == 16, - let key = meshService.noiseSessionPublicKeyData(for: shortPeerID) { - let stable = PeerID(hexData: key) - peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID) - return stable - } - return nil - } - // Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex) @MainActor func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID { @@ -305,12 +292,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol + /// Private group membership: keys in the keychain, metadata on disk. + let groupStore: GroupStore private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) var activeChannel: ChannelID { get { conversations.activeChannel } set { guard conversations.activeChannel != newValue else { return } + // Leaving a channel expedites any in-flight NIP-13 mining: the + // pending message still sends, at the difficulty already reached. + outgoingCoordinator.expeditePendingGeohashMining() conversations.setActiveChannel(newValue) visibleMessagesCache = nil objectWillChange.send() @@ -341,18 +333,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Social Features (Delegated to PeerStateManager) - @MainActor - var favoritePeers: Set { unifiedPeerService.favoritePeers } @MainActor var blockedUsers: Set { unifiedPeerService.blockedUsers } // MARK: - Encryption and Security - // Noise Protocol encryption status - var peerEncryptionStatus: [PeerID: EncryptionStatus] { - get { peerIdentityStore.encryptionStatuses } - set { peerIdentityStore.replaceEncryptionStatuses(newValue) } - } var verifiedFingerprints: Set { get { peerIdentityStore.verifiedFingerprints } set { peerIdentityStore.setVerifiedFingerprints(newValue) } @@ -413,16 +398,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let readReceiptsDefaults: UserDefaults /// Default read-receipt persistence store. Production uses `.standard`. - /// Under test, a dedicated scratch suite is used instead — wiped at first - /// use per process — so back-to-back local test runs never see each - /// other's persisted receipts (and tests never pollute `.standard`). - static let defaultReadReceiptsDefaults: UserDefaults = { + /// Under test, every instance gets its own scratch suite: a per-process + /// shared suite let one test's persisted receipts leak into another + /// test's freshly constructed view model (surfaced as an order-dependent + /// CI flake on a duplicated message ID), and tests never pollute + /// `.standard`. + static func defaultReadReceiptsDefaults() -> UserDefaults { guard TestEnvironment.isRunningTests else { return .standard } - let suiteName = "chat.bitchat.tests.readReceipts" + let suiteName = "chat.bitchat.tests.readReceipts.\(UUID().uuidString)" guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard } scratch.removePersistentDomain(forName: suiteName) return scratch - }() + } // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed @@ -711,12 +698,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false } + @MainActor + func bridgeInjectedPublicMessageIsPresent(withID messageID: String) -> Bool { + publicMessagePipeline.containsMessage(withID: messageID) || + publicConversationContainsMessage(withID: messageID, in: .mesh) + } + /// Removes a message by ID from whichever public conversation contains /// it. Returns the removed message, if any. @MainActor @discardableResult func removePublicMessage(withID messageID: String) -> BitchatMessage? { - conversations.removePublicMessage(withID: messageID) + publicMessagePipeline.removeMessage(withID: messageID) + return conversations.removePublicMessage(withID: messageID) + } + + /// Replaces an unauthenticated bridge alias with a later authenticated + /// radio row. In addition to both storage layers, clear the content-window + /// marker written by an already-flushed alias or it would suppress the + /// genuine row during the next public-message batch. + @MainActor + func removeBridgeInjectedPublicMessage(withID messageID: String) { + publicMessagePipeline.removeMessage(withID: messageID) + guard let removed = conversations.removePublicMessage(withID: messageID) else { return } + deduplicationService.forgetContent(removed.content, ifRecordedAt: removed.timestamp) } /// Removes every message matching `predicate` from a geohash @@ -764,15 +769,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { + let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) + meshService.sfMetrics = .shared self.init( keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), + transport: meshService, conversations: conversations, peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), - locationManager: locationManager + locationManager: locationManager, + outboxStore: MessageOutboxStore(keychain: keychain), + sfMetrics: .shared ) } @@ -788,7 +797,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared, - readReceiptsDefaults: UserDefaults? = nil + readReceiptsDefaults: UserDefaults? = nil, + outboxStore: MessageOutboxStore? = nil, + sfMetrics: StoreAndForwardMetrics? = nil ) { let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() @@ -797,10 +808,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele keychain: keychain, idBridge: idBridge, identityManager: identityManager, - meshService: transport + meshService: transport, + outboxStore: outboxStore, + sfMetrics: sfMetrics ) self.keychain = keychain + self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager self.conversations = conversations @@ -815,7 +829,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele self.autocompleteService = services.autocompleteService self.deduplicationService = services.deduplicationService self.publicMessagePipeline = services.publicMessagePipeline - let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults + let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults() self.readReceiptsDefaults = readReceiptsDefaults self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults) @@ -910,6 +924,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele outgoingCoordinator.sendMessage(content) } + /// Sends a 👋 to the mesh channel regardless of the active channel. + @MainActor + func sendMeshWave() { + outgoingCoordinator.sendMeshWave() + } + // MARK: - Geohash Participants @MainActor @@ -937,11 +957,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Public helpers - /// Published geohash people list for SwiftUI observation - var geohashPeople: [GeoPerson] { - participantTracker.visiblePeople - } - /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { @@ -988,16 +1003,50 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { - publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) + // Mesh (Noise identity) block helpers. Unlike the `/block ` + // command, these resolve and persist the block by the peer's stable + // fingerprint (derived from `peerID`), so the exact tapped peer is + // (un)blocked — unambiguous across nickname collisions and functional for + // offline peers that can no longer be resolved through the mesh service. + @MainActor + func blockMeshPeer(peerID: PeerID, displayName: String) { + setMeshPeerBlocked(peerID, blocked: true, displayName: displayName) } - // MARK: - Media Transfers + @MainActor + func unblockMeshPeer(peerID: PeerID, displayName: String) { + setMeshPeerBlocked(peerID, blocked: false, displayName: displayName) + } - private enum MediaSendError: Error { - case encodingFailed - case tooLarge - case copyFailed + @MainActor + private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) { + guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else { + addCommandOutput( + String( + format: String( + localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed", + comment: "System message shown when a mesh peer cannot be blocked or unblocked" + ), + locale: .current, + displayName + ) + ) + return + } + addCommandOutput( + String( + format: String( + localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked", + comment: "System message shown when a mesh peer is blocked or unblocked" + ), + locale: .current, + displayName + ) + ) + } + + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { + publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) } func currentPublicSender() -> (name: String, peerID: PeerID) { @@ -1061,7 +1110,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } @MainActor - @objc func handlePeerStatusUpdate(_ notification: Notification) { + @objc func handlePeerStatusUpdate(_: Notification) { peerIdentityCoordinator.handlePeerStatusUpdate() } @@ -1095,15 +1144,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lifecycleCoordinator.markPrivateMessagesAsRead(from: peerID) } - func getMessages(for peerID: PeerID?) -> [BitchatMessage] { - lifecycleCoordinator.getMessages(for: peerID) - } - - @MainActor - func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { - lifecycleCoordinator.getPrivateChatMessages(for: peerID) - } - @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { peerIdentityCoordinator.getPeerIDForNickname(nickname) @@ -1147,6 +1187,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() + // Drop courier mail carried for third parties (memory and disk), + // our own queued outbox, the carried public history, and the + // counters describing all of it + CourierStore.shared.wipe() + BridgeCourierService.shared.wipe() + messageRouter.wipeOutbox() + GossipMessageArchive.wipeDefault() + StoreAndForwardMetrics.shared.reset() + + // Ambient-liveliness bookkeeping: sampled nearby-chat previews, the + // daily sightings tally, and the echoes-dismissed watermark + GeohashChatActivityTracker.shared.clear() + MeshSightingsTracker.shared.clear() + MeshEchoSettings.reset() + + // Drop private group keys and rosters (keychain + disk) + groupStore.wipe() + // Drop cached peers' prekey bundles (who we could write to is + // metadata too). Our own prekey privates are keychain-backed and go + // with deleteAllKeychainData above plus the identity reset below. + PrekeyBundleStore.shared.wipe() + // Drop bulletin-board posts and tombstones (memory and disk); board + // posts are signed with our identity key and persist for days. + BoardStore.shared.wipe() + // Identity manager has cleared persisted identity data above // Clear autocomplete state @@ -1215,6 +1280,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Delete ALL media files (incoming and outgoing) in background Task.detached(priority: .utility) { + // Skipped under tests: the test process shares the user's real + // ~/Library/Application Support/files tree, and this detached + // utility-priority wipe fires at a nondeterministic time — + // deleting media that concurrently running tests (e.g. the + // sendImage flow) just wrote there, and the developer's real + // app data with it. + guard !TestEnvironment.isRunningTests else { return } do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) @@ -1427,6 +1499,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func setupNoiseCallbacks() { verificationCoordinator.setupNoiseCallbacks() + vouchCoordinator.setupNoiseCallbacks() + } + + /// Whether the fingerprint currently counts as vouched (≥1 valid vouch + /// from a voucher I verified, and no explicit verification of mine). + @MainActor + func isVouchedFingerprint(_ fingerprint: String) -> Bool { + identityManager.isVouched(fingerprint: fingerprint) } // MARK: - BitchatDelegate Methods @@ -1435,7 +1515,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele /// Processes IRC-style commands starting with '/'. /// - Parameter command: The full command string including the leading slash - /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help + /// - Note: Supports commands like /msg, /who, /slap, /clear, /help @MainActor func handleCommand(_ command: String) { let result = commandProcessor.process(command) @@ -1443,16 +1523,56 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele switch result { case .success(let message): if let msg = message { - addSystemMessage(msg) + addCommandOutput(msg) } case .error(let message): - addSystemMessage(message) + addCommandOutput(message) case .handled: // Command was handled, no message needed break } } + /// Command output belongs in the conversation where the user typed the + /// command; the public timeline is invisible while a DM is open. The DM + /// selection is read *after* processing so commands that switch chats + /// (`/msg`) print into the conversation they just opened. + @MainActor + private func addCommandOutput(_ content: String) { + if let peerID = selectedPrivateChatPeer { + addLocalPrivateSystemMessage(content, to: peerID) + } else { + addSystemMessage(content) + } + } + + /// Origin conversation for deferred command output, captured when the + /// command is issued (before any async work starts). + @MainActor + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + // Deferring commands (/ping) are rejected in geohash channels, so a + // non-DM origin is always the #mesh timeline. + return .meshTimeline + } + + /// Routes deferred command output (async /ping results) into the + /// conversation captured at issue time, immune to chat switches in the + /// meantime. A DM result lands in the origin chat's history even if that + /// chat is no longer selected (or was cleared — it then reappears as the + /// first message when the chat is reopened). + @MainActor + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + switch destination { + case .privateChat(let peerID): + addLocalPrivateSystemMessage(content, to: peerID) + case .meshTimeline: + publicConversationCoordinator.addMeshOnlySystemMessage(content) + } + } + // MARK: - Message Reception @MainActor @@ -1484,6 +1604,23 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + Task { @MainActor [weak self] in + self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp) + } + } + + func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) { + Task { @MainActor [weak self] in + self?.liveVoiceCoordinator.handlePublicVoiceFramePayload( + from: peerID, + nickname: nickname, + payload: payload, + timestamp: timestamp + ) + } + } + // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { @@ -1511,6 +1648,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func didUpdatePeerList(_ peers: [PeerID]) { peerListCoordinator.didUpdatePeerList(peers) + // A peer-list update follows every verified announce, which is where a + // peer's `.vouch` capability actually arrives — retry vouching now that + // capabilities may finally be known (closes the auth-time capability race). + Task { @MainActor [weak self] in + self?.vouchCoordinator.peersUpdated(peers) + } } @MainActor @@ -1599,6 +1742,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func addGeohashOnlySystemMessage(_ content: String) { publicConversationCoordinator.addGeohashOnlySystemMessage(content) } + + /// Add a local system message to one specific geohash timeline, active or + /// not. Used by the board's new-pin alerts to scope-match the pin's channel. + @MainActor + func addGeohashSystemMessage(_ content: String, geohash: String) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false + ) + appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash) + } // Send a public message without adding a local user echo. // Used for emotes where we want a local system-style confirmation instead. @MainActor @@ -1606,12 +1762,38 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.sendPublicRaw(content) } + // Send a normal public message (with local echo) to the active channel. + // CommandContextProvider hook for commands that post real messages + // (`/pay`); only called when no private chat is selected. + @MainActor + func sendPublicMessage(_ content: String) { + sendMessage(content) + } + /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { + // Bridge hints are unauthenticated and may never suppress a genuine + // BLE sender. Once the radio packet has passed BLE signature checks, + // replace any earlier bridge alias before this row is enqueued. + if !message.isBridged, + let senderPeerID = message.senderPeerID, + !senderPeerID.isGeoChat { + BridgeService.shared.handleAuthenticatedRadioMessage(messageID: message.id) + } + // A finalized voice note whose burst already streamed in live swaps + // into the existing bubble instead of appearing twice. + if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return } publicConversationCoordinator.handlePublicMessage(message) } + /// Handle an incoming public Nostr message with its validated NIP-13 + /// difficulty; sufficient PoW relaxes the per-sender rate limit. + @MainActor + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { + publicConversationCoordinator.handlePublicMessage(message, powBits: powBits) + } + /// Check for mentions and send notifications func checkForMentions(_ message: BitchatMessage) { publicConversationCoordinator.checkForMentions(message) diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index e2615cf9..1f67f3d5 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle { keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - meshService: Transport + meshService: Transport, + outboxStore: MessageOutboxStore? = nil, + sfMetrics: StoreAndForwardMetrics? = nil ) { let commandProcessor = CommandProcessor(identityManager: identityManager) let privateChatManager = PrivateChatManager(meshService: meshService) @@ -28,14 +30,22 @@ struct ChatViewModelServiceBundle { ) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) nostrTransport.senderPeerID = meshService.myPeerID - let messageRouter = MessageRouter(transports: [meshService, nostrTransport]) + let messageRouter = MessageRouter( + transports: [meshService, nostrTransport], + outboxStore: outboxStore, + metrics: sfMetrics + ) self.commandProcessor = commandProcessor self.messageRouter = messageRouter self.privateChatManager = privateChatManager self.unifiedPeerService = unifiedPeerService self.autocompleteService = AutocompleteService() - self.deduplicationService = MessageDeduplicationService() + // Persist processed gift-wrap event IDs: NIP-59 randomizes their + // timestamps, so the 24h-lookback DM subscriptions redeliver the same + // events on every launch and only a cross-launch record stops the + // reprocessing (re-sent DELIVERED bursts, phantom-ack noise). + self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore()) self.publicMessagePipeline = PublicMessagePipeline() } } @@ -66,6 +76,9 @@ final class ChatViewModelBootstrapper { configureNoiseCallbacks() bindTransferProgress() configureGeoChannels() + configureGateway() + configureBridge() + configureBridgeCourier() bindTeleportState() requestNotifications() registerObservers() @@ -100,11 +113,28 @@ private extension ChatViewModelBootstrapper { category: .session ) viewModel.conversations.setDeliveryStatus( - .failed(reason: "Not delivered"), + .failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")), forMessageID: messageID ) } } + // A message with no reachable transport that was handed to a courier + // shows a distinct "carried" state instead of sitting in "sending" + // forever. Never downgrade a confirmed receipt: the courier copy can + // race direct delivery when the peer reappears. + viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in + guard let viewModel else { return } + switch viewModel.conversations.deliveryStatus(forMessageID: messageID) { + case .delivered, .read: + break + default: + SecureLogger.debug( + "📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried", + category: .session + ) + viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID) + } + } viewModel.commandProcessor.contextProvider = viewModel viewModel.commandProcessor.meshService = viewModel.meshService viewModel.participantTracker.configure(context: viewModel) @@ -150,6 +180,8 @@ private extension ChatViewModelBootstrapper { viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator + loadArchivedEchoes() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in guard let viewModel, let bleService = viewModel.meshService as? BLEService else { return } @@ -169,6 +201,66 @@ private extension ChatViewModelBootstrapper { } } + /// Surfaces the carried store-and-forward window (up to 6h of public + /// mesh messages, persisted across restarts) as dimmed "heard here + /// earlier" rows, so the mesh timeline opens with the place's memory + /// instead of a void. The archive restore runs async on the sync queue + /// right after transport start, so give it a beat before asking. + private func loadArchivedEchoes() { + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in + guard let viewModel else { return } + viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in + guard let viewModel else { return } + // A previous /clear dismissed everything heard up to its + // watermark; only newer archive entries come back. Blocking a + // peer purges their carried messages from the archive at + // block time (when the fingerprint↔peerID mapping is known); + // the filter here is defense-in-depth for entries that slip + // past the purge (e.g. re-synced from a nearby peer), and it + // only resolves connected peers or favorites. + let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast + let archived = allArchived.filter { + $0.timestamp > clearedThrough && !viewModel.isPeerBlocked($0.senderPeerID) + } + guard !archived.isEmpty else { return } + // Seed only an untouched timeline: with live rows already + // present (or after /clear) splicing history back in would + // be wrong. + guard viewModel.conversations.conversationsByID[.mesh]?.messages.isEmpty != false else { return } + + for item in archived { + let echo = BitchatMessage( + id: BitchatMessage.archivedEchoIDPrefix + item.packetIdHex, + sender: item.senderNickname, + content: item.content, + timestamp: item.timestamp, + isRelay: false, + senderPeerID: item.senderPeerID + ) + viewModel.publicConversationCoordinator.registerArchivedEcho( + senderPeerID: item.senderPeerID, + timestamp: item.timestamp, + content: item.content + ) + _ = viewModel.appendPublicMessage(echo, to: .mesh) + } + + if let firstTimestamp = archived.map(\.timestamp).min() { + // Echo-prefixed ID so the divider joins the tinted, + // dimmed echo block in the timeline. + let divider = BitchatMessage( + id: BitchatMessage.archivedEchoIDPrefix + "divider", + sender: "system", + content: String(localized: "content.echoes.divider", comment: "System line shown above dimmed archived messages replayed on the mesh timeline at launch"), + timestamp: firstTimestamp.addingTimeInterval(-1), + isRelay: false + ) + _ = viewModel.appendPublicMessage(divider, to: .mesh) + } + } + } + } + func bindPeerService() { viewModel.unifiedPeerService.$peers .receive(on: DispatchQueue.main) @@ -221,6 +313,319 @@ private extension ChatViewModelBootstrapper { ) } + /// Wires the gateway-mode policy layer (`GatewayService`) to the mesh + /// transport, the relay manager, and the inbound Nostr pipeline. All + /// dependencies are closures so the service stays unit-testable with + /// fakes. + func configureGateway() { + // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests) + // has no carrier packets to bridge. + guard let bleService = viewModel.meshService as? BLEService else { return } + let gateway = GatewayService.shared + + gateway.publishToRelays = { event, geohash in + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: geohash, + count: TransportConfig.nostrGeoRelayCount + ) + // Symmetric with the local send path (GeohashSubscriptionManager + // .sendGeohash): with no known geo relay, refuse rather than + // publish to default relays no geo subscriber reads — that would + // be silent dead traffic, not delivery. + guard !relays.isEmpty else { + SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session) + return + } + NostrRelayManager.shared.sendEvent(event, to: relays) + } + gateway.broadcastToMesh = { [weak bleService] payload in + bleService?.broadcastNostrCarrier(payload) + } + gateway.sendToGatewayPeer = { [weak bleService] payload, peer in + bleService?.sendNostrCarrier(payload, to: peer) ?? false + } + gateway.availableGatewayPeers = { [weak bleService] in + bleService?.reachableGatewayPeers() ?? [] + } + gateway.relaysConnected = { NostrRelayManager.shared.isConnected } + gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash } + // Carried events enter the same pipeline as relay-received events so + // blocking, rate limits, dedup, and rendering behave identically. + gateway.injectInbound = { [weak viewModel] event in + viewModel?.handleNostrEvent(event) + } + // The capability bit is advertised ONLY while the toggle is on; a + // change forces a re-announce so peers learn promptly. + gateway.onEnabledChanged = { [weak bleService] enabled in + bleService?.setLocalCapability(.gateway, enabled: enabled) + } + bleService.onNostrCarrierPacket = { payload, from, directedToUs in + // One decode, two policy engines: geohash-channel carriers go to + // the gateway, mesh-bridge carriers to the bridge. + guard let carrier = NostrCarrierPacket.decode(payload) else { + SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(from.id.prefix(8))…", category: .session) + return + } + switch carrier.direction { + case .toGateway, .fromGateway: + GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) + case .toBridge, .fromBridge: + BridgeService.shared.handleMeshCarrier(carrier, from: from, directedToUs: directedToUs) + } + } + + // Uplinks deposited while relays were unreachable flush on reconnect. + // The publisher re-emits `true` on every relay state recompute, so + // dedupe: field logs showed presence published 5x in one second. + NostrRelayManager.shared.$isConnected + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { connected in + if connected { + GatewayService.shared.flushQueuedUplinks() + BridgeService.shared.flushQueuedUplinks() + BridgeService.shared.publishPresence() + } + } + .store(in: &viewModel.cancellables) + + // Apply the persisted toggle at launch. + if gateway.isEnabled { + bleService.setLocalCapability(.gateway, enabled: true) + } + } + + /// Wires the mesh-bridge policy layer (`BridgeService`) to the mesh + /// transport, the relay manager, location, and the public timeline. Same + /// closure-injection style as `configureGateway`. + func configureBridge() { + guard let bleService = viewModel.meshService as? BLEService else { return } + let bridge = BridgeService.shared + let idBridge = viewModel.idBridge + + bridge.publishToRelays = { event, cell in + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: cell, + count: TransportConfig.nostrGeoRelayCount + ) + guard !relays.isEmpty else { + SecureLogger.warning("🌉 Bridge: no geo relays for cell \(cell); not publishing", category: .session) + return + } + NostrRelayManager.shared.sendEvent(event, to: relays) + } + bridge.openSubscription = { cells in + guard let cell = cells.first else { return } + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: cell, + count: TransportConfig.nostrGeoRelayCount + ) + NostrRelayManager.shared.subscribe( + filter: .bridgeRendezvous(cells, since: Date().addingTimeInterval(-BridgeService.Limits.maxEventAgeSeconds)), + id: Self.bridgeSubscriptionID, + relayUrls: relays.isEmpty ? nil : relays, + handler: { event in + BridgeService.shared.handleRendezvousEvent(event) + } + ) + } + bridge.closeSubscription = { + NostrRelayManager.shared.unsubscribe(id: Self.bridgeSubscriptionID) + } + bridge.relaysConnected = { NostrRelayManager.shared.isConnected } + bridge.locationCell = { [weak viewModel] in + viewModel?.locationManager.availableChannels + .first { $0.level == .neighborhood }? + .geohash + } + bridge.requestLocationFix = { [weak viewModel] in + viewModel?.locationManager.refreshChannels() + } + bridge.meshAdvertisedCell = { [weak bleService] in + bleService?.advertisedBridgeGeohash() + } + bridge.sendToBridgePeer = { [weak bleService] payload, peer in + bleService?.sendNostrCarrier(payload, to: peer) ?? false + } + bridge.availableBridgePeers = { [weak bleService] in + bleService?.reachableBridgePeers() ?? [] + } + bridge.broadcastToMesh = { [weak bleService] payload in + bleService?.broadcastNostrCarrier(payload) + } + bridge.injectInbound = { [weak viewModel] inbound in + viewModel?.handlePublicMessage(BitchatMessage( + id: inbound.messageID, + sender: inbound.senderNickname, + content: inbound.content, + timestamp: inbound.timestamp, + isRelay: false, + senderPeerID: PeerID(bridge: inbound.senderPubkey), + isBridged: true + )) + } + bridge.removeInjectedInbound = { [weak viewModel] messageID in + viewModel?.removeBridgeInjectedPublicMessage(withID: messageID) + } + bridge.isInjectedInboundPresent = { [weak viewModel] messageID in + viewModel?.bridgeInjectedPublicMessageIsPresent(withID: messageID) ?? false + } + bridge.isMessageSeenLocally = { [weak viewModel] messageID in + viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false + } + bridge.deriveIdentity = { cell in + try idBridge.deriveIdentity(forBridgeRendezvous: cell) + } + bridge.myNickname = { [weak viewModel] in viewModel?.nickname ?? "" } + + // The `.bridge` capability + cell TLV advertise serving duty: "send + // me deposits, and this is the island's cell". One switch: bridging + // with a known cell is serving (deposits queue through connectivity + // gaps, so the advertisement doesn't flap with the relays). + let updateAdvertisement: @MainActor () -> Void = { [weak bleService] in + let advertise = BridgeService.shared.isEnabled + && BridgeService.shared.activeCell != nil + bleService?.setLocalBridgeGeohash(advertise ? BridgeService.shared.activeCell : nil) + bleService?.setLocalCapability(.bridge, enabled: advertise) + } + bridge.onEnabledChanged = { [weak viewModel] enabled in + updateAdvertisement() + // One switch collapses further: the bridge toggle also drives + // the geohash-channel gateway — bridging with internet means + // sharing it with the mesh around you, full stop. + GatewayService.shared.setEnabled(enabled) + // Flipping the switch is the user-initiated moment to ask for + // location if it was never asked; otherwise the bridge sits + // cell-less with only a settings caption explaining why. + if enabled, viewModel?.locationManager.permissionState == .notDetermined { + viewModel?.locationManager.enableLocationChannels() + } + } + bridge.onActiveCellChanged = { _ in updateAdvertisement() } + // Align a persisted split state (e.g. gateway enabled back when it + // had its own toggle) to the single switch at launch. + if GatewayService.shared.isEnabled != bridge.isEnabled { + GatewayService.shared.setEnabled(bridge.isEnabled) + } + + // Location fixes (or losing them) move the rendezvous cell. + viewModel.locationManager.$availableChannels + .receive(on: DispatchQueue.main) + .sink { _ in BridgeService.shared.refreshRendezvous() } + .store(in: &viewModel.cancellables) + // The authorization callback lands asynchronously after launch; the + // bootstrap-time location request races it and silently no-ops, so + // re-enter when the permission state resolves (field bug: bridge + // stayed cell-less for a whole session). + viewModel.locationManager.$permissionState + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in BridgeService.shared.refreshRendezvous() } + .store(in: &viewModel.cancellables) + + // Apply the persisted toggle at launch. + if bridge.isEnabled { + bridge.refreshRendezvous() + updateAdvertisement() + } + } + + /// Wires courier-over-bridge (`BridgeCourierService`) to the relay + /// manager, the mesh transport's sealing/opening primitives, the courier + /// store, and the message router's deposit path. + func configureBridgeCourier() { + guard let bleService = viewModel.meshService as? BLEService else { return } + let courier = BridgeCourierService.shared + + courier.bridgeEnabled = { BridgeService.shared.isEnabled } + // A geo/custom relay does not make a global courier drop durable. + // Require an actually connected default (DM) relay so `sendEvent` + // writes to at least one intended relay instead of only entering its + // process-local pending queue. + courier.relaysConnected = { NostrRelayManager.shared.isDMRelayConnected } + courier.publishEvent = { event, completion in + // Default (DM) relays: drops need the standing global relay set, + // not geo relays — sender and recipient share no cell. + // This confirmed path never falls back to the volatile relay + // queue; bridge dedup is committed only after NIP-20 OK. + NostrRelayManager.shared.sendEventImmediately(event, completion: completion) + } + courier.openSubscription = { tagsHex in + NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID) + NostrRelayManager.shared.subscribe( + filter: .courierDrops( + recipientTagsHex: tagsHex, + since: Date().addingTimeInterval(-CourierEnvelope.maxLifetimeSeconds) + ), + id: Self.courierDropSubscriptionID, + handler: { event in + BridgeCourierService.shared.handleDropEvent(event) + } + ) + } + courier.closeSubscription = { + NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID) + } + courier.myNoiseKey = { [weak bleService] in + bleService?.myNoiseStaticPublicKey() + } + courier.localVerifiedPeers = { [weak bleService] in + bleService?.verifiedPeersWithNoiseKeys() ?? [] + } + courier.sealEnvelope = { [weak bleService] content, messageID, recipientKey in + bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey) + } + courier.openEnvelope = { [weak bleService] envelope in + bleService?.openBridgedCourierEnvelope(envelope) ?? false + } + courier.deliverToPeer = { [weak bleService] envelope, peerID in + bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false + } + courier.heldEnvelopes = { cooldown in + CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown) + } + courier.markHeldEnvelopePublished = { envelope in + CourierStore.shared.markBridgePublished(envelope) + } + + viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey, completion in + BridgeCourierService.shared.depositDrop( + content: content, + messageID: messageID, + recipientNoiseKey: recipientKey, + completion: completion + ) + } + // The completion flows back only after a default relay accepts the + // event, so a rejected or unacknowledged write never becomes carried. + viewModel.messageRouter.startBridgeDepositSweep() + bleService.onVerifiedPeerAnnounce = { _ in + Task { @MainActor in + BridgeCourierService.shared.refreshAfterVerifiedAnnounce() + } + } + + // Relay connectivity gates everything; refresh (re)opens or closes. + // Deduped: refresh() resubscribes, and the raw publisher re-emits on + // every relay state recompute (6x in 300ms in field logs). + NostrRelayManager.shared.$isDMRelayConnected + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in BridgeCourierService.shared.refresh() } + .store(in: &viewModel.cancellables) + // Toggle changes re-evaluate the watch set. + BridgeService.shared.$isEnabled + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { _ in BridgeCourierService.shared.refresh() } + .store(in: &viewModel.cancellables) + + courier.refresh() + } + + private static let bridgeSubscriptionID = "bridge-rendezvous" + private static let courierDropSubscriptionID = "bridge-courier-drops" + func bindTeleportState() { viewModel.locationManager.$teleported .receive(on: DispatchQueue.main) diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift new file mode 100644 index 00000000..26cb8158 --- /dev/null +++ b/bitchat/ViewModels/ChatVouchCoordinator.swift @@ -0,0 +1,272 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatVouchCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatVouchContext: AnyObject { + // MARK: Identity & trust state + func getFingerprint(for peerID: PeerID) -> String? + func isVerifiedFingerprint(_ fingerprint: String) -> Bool + /// The peer's announce-bound Ed25519 signing key, if known this session. + func signingKey(forFingerprint fingerprint: String) -> Data? + /// Verified fingerprints ordered most recently verified first. + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] + /// Stores an accepted vouch (identity manager enforces the storage gates). + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool + func lastVouchBatchSent(to fingerprint: String) -> Date? + func markVouchBatchSent(to fingerprint: String, at date: Date) + + // MARK: Transport + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities + /// PeerIDs with a currently established mesh session (used to run a vouch + /// pass over peers we are already connected to when we verify someone). + func connectedPeerIDs() -> [PeerID] + /// Appends a session-established observer (additive; never displaces the + /// verification coordinator's callbacks). + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) + /// Signs `data` with our Noise (Ed25519) signing key. + func noiseSignData(_ data: Data) -> Data? + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) + + // MARK: UI refresh + /// Signals that derived trust state changed so peer list / fingerprint + /// views recompute badges. + func notifyPeerTrustChanged() +} + +extension ChatViewModel: ChatVouchContext { + // `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared + // requirements with the verification context and satisfied by existing + // `ChatViewModel` members. The members below flatten nested service + // accesses into intent-named calls. + + func signingKey(forFingerprint fingerprint: String) -> Data? { + identityManager.signingPublicKey(forFingerprint: fingerprint) + } + + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + identityManager.recordVouch( + voucheeFingerprint: voucheeFingerprint, + voucherFingerprint: voucherFingerprint, + timestamp: timestamp + ) + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + identityManager.lastVouchBatchSent(to: fingerprint) + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + identityManager.markVouchBatchSent(to: fingerprint, at: date) + } + + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { + meshService.peerCapabilities(peerID) + } + + func connectedPeerIDs() -> [PeerID] { + Array(connectedPeers) + } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + meshService.addPeerAuthenticatedObserver(handler) + } + + func noiseSignData(_ data: Data) -> Data? { + meshService.noiseSignData(data) + } + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + meshService.sendVouchAttestations(payload, to: peerID) + } + + func notifyPeerTrustChanged() { + // PeerListModel refreshes on this notification; the view-model change + // covers FingerprintView / VerificationModel consumers. + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + notifyUIChanged() + } +} + +/// Transitive verification ("vouching"): when a Noise session comes up with a +/// peer I verified, I attest — over that authenticated, encrypted session — +/// to the other identities I have verified. Receivers accept such vouches +/// only from peers *they* verified, giving a serverless +/// verified-by-people-you-verified tier (`TrustLevel.vouched`). +@MainActor +final class ChatVouchCoordinator { + /// Minimum spacing between vouch batches to the same peer (persisted). + static let batchInterval: TimeInterval = 24 * 60 * 60 + + private unowned let context: any ChatVouchContext + + init(context: any ChatVouchContext) { + self.context = context + } + + /// Registers the session-established hook. Additive alongside the + /// verification coordinator's callbacks; call once at bootstrap. + func setupNoiseCallbacks() { + context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in + DispatchQueue.main.async { [weak self] in + self?.peerAuthenticated(peerID, fingerprint: fingerprint) + } + } + } + + /// Trigger — session established: on a Noise session coming up with a peer + /// I verified, attempt to send a vouch batch. Kept as the historical entry + /// point; the real work lives in `attemptVouch`. + func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) { + attemptVouch(to: peerID, fingerprint: fingerprint, now: now) + } + + /// Trigger — verified announce processed: a peer's `.vouch` capability + /// arrives on its *announce*, which is handled independently of the Noise + /// handshake. This is invoked on every peer-list update (fired after each + /// verified announce), so it closes the capability race — the batch that + /// `peerAuthenticated` couldn't send (capabilities not yet known) goes out + /// once the capability-bearing announce lands. Throttled per peer. + func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) { + for peerID in peerIDs { + guard let fingerprint = context.getFingerprint(for: peerID) else { continue } + attemptVouch(to: peerID, fingerprint: fingerprint, now: now) + } + } + + /// Trigger — local verification completed: the user just verified a peer. + /// Run a vouch pass over every currently connected peer I verified. This + /// makes vouching fire when verifying someone already connected (whose + /// session is authenticated, so `peerAuthenticated` never re-fires), and it + /// propagates the newly-verified identity to my other verified peers. + /// Throttled per peer by `batchInterval`, so it can't spam. + func vouchToConnectedVerifiedPeers(now: Date = Date()) { + var sentCount = 0 + for peerID in context.connectedPeerIDs() { + guard let fingerprint = context.getFingerprint(for: peerID) else { continue } + if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) { + sentCount += 1 + } + } + if sentCount > 0 { + SecureLogger.info( + "🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)", + category: .security + ) + } + } + + /// Exchange policy shared by every trigger: to a peer I verified, send + /// attestations for up to `VouchAttestation.maxBatchCount` *other* verified + /// fingerprints (most recently verified first), at most once per peer per + /// `batchInterval`. Returns whether a batch was actually sent. + @discardableResult + func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool { + guard context.isVerifiedFingerprint(fingerprint) else { return false } + + // Capability gate, race-tolerant: a peer's `.vouch` bit is carried on + // its announce, processed independently of the Noise handshake, so at + // authentication time the capability set is frequently still empty. + // Treat an empty/unknown set as eligible — the payload is a Noise + // `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly + // ignore, so sending on an unknown set is safe and avoids the race + // dropping the batch. Only skip when the peer advertised a non-empty + // capability set that explicitly lacks `.vouch`. + let capabilities = context.peerCapabilities(for: peerID) + if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false } + + if let lastSent = context.lastVouchBatchSent(to: fingerprint), + now.timeIntervalSince(lastSent) < Self.batchInterval { + return false + } + + let candidates = context.recentlyVerifiedFingerprints( + limit: VouchAttestation.maxBatchCount, + excluding: fingerprint + ) + var attestations: [VouchAttestation] = [] + for candidate in candidates { + // Only fingerprints whose announce-bound signing key we know can + // be anchored to a concrete identity; skip the rest. + guard let fingerprintData = Data(hexString: candidate), + fingerprintData.count == VouchAttestation.fingerprintSize, + let signingKey = context.signingKey(forFingerprint: candidate), + signingKey.count == VouchAttestation.signingKeySize, + let attestation = VouchAttestation.build( + voucheeFingerprint: fingerprintData, + voucheeSigningKey: signingKey, + timestampMs: UInt64(now.timeIntervalSince1970 * 1000), + sign: context.noiseSignData + ) else { + continue + } + attestations.append(attestation) + } + + guard !attestations.isEmpty, + let payload = VouchAttestation.encodeList(attestations) else { return false } + context.sendVouchAttestations(payload, to: peerID) + context.markVouchBatchSent(to: fingerprint, at: now) + SecureLogger.debug( + "🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…", + category: .security + ) + return true + } + + /// Accept policy: process inbound vouches only from a sender I verified, + /// only with a valid Ed25519 signature under the sender's announce-bound + /// signing key, and only within the validity window. Self-vouches and + /// vouches for already-verified peers are dropped by the identity + /// manager's storage gates. + func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) { + guard let senderFingerprint = context.getFingerprint(for: peerID), + context.isVerifiedFingerprint(senderFingerprint) else { + SecureLogger.debug( + "🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))…", + category: .security + ) + return + } + guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else { + SecureLogger.debug( + "🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch", + category: .security + ) + return + } + + var acceptedCount = 0 + for attestation in VouchAttestation.decodeList(from: payload) { + guard attestation.verifySignature(voucherSigningKey: senderSigningKey), + !attestation.isExpired(now: now) else { continue } + let stored = context.recordVouch( + voucheeFingerprint: attestation.voucheeFingerprintHex, + voucherFingerprint: senderFingerprint, + timestamp: attestation.timestamp + ) + if stored { acceptedCount += 1 } + } + + if acceptedCount > 0 { + SecureLogger.info( + "🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))…", + category: .security + ) + context.notifyPeerTrustChanged() + } + } +} diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index bf2051bc..8205e13b 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -35,11 +35,6 @@ extension ChatViewModel { nostrCoordinator.inbound.handleNostrEvent(event) } - @MainActor - func subscribeToGeoChat(_ ch: GeohashChannel) { - nostrCoordinator.subscriptions.subscribeToGeoChat(ch) - } - @MainActor func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id) @@ -55,21 +50,11 @@ extension ChatViewModel { nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes) } - @MainActor - func subscribe(_ gh: String) { - nostrCoordinator.subscriptions.subscribe(gh) - } - @MainActor func subscribeNostrEvent(_ event: NostrEvent, gh: String) { nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh) } - @MainActor - func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { - nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event) - } - @MainActor func endGeohashSampling() { nostrCoordinator.subscriptions.endGeohashSampling() @@ -80,40 +65,11 @@ extension ChatViewModel { nostrCoordinator.subscriptions.setupNostrMessageHandling() } - @MainActor - func handleNostrMessage(_ giftWrap: NostrEvent) { - nostrCoordinator.inbound.handleNostrMessage(giftWrap) - } - - func processNostrMessage(_ giftWrap: NostrEvent) async { - await nostrCoordinator.inbound.processNostrMessage(giftWrap) - } - @MainActor func findNoiseKey(for nostrPubkey: String) -> Data? { nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey) } - @MainActor - func sendDeliveryAckViaNostrEmbedded( - _ message: BitchatMessage, - wasReadBefore: Bool, - senderPubkey: String, - key: Data? - ) { - nostrCoordinator.sendDeliveryAckViaNostrEmbedded( - message, - wasReadBefore: wasReadBefore, - senderPubkey: senderPubkey, - key: key - ) - } - - @MainActor - func handleFavoriteNotification(content: String, from nostrPubkey: String) { - nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey) - } - @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite) diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index fb288dbc..d77a4221 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -6,6 +6,7 @@ // import BitFoundation +import BitLogger import Foundation import SwiftUI @@ -13,6 +14,12 @@ extension ChatViewModel { @MainActor func sendPrivateMessage(_ content: String, to peerID: PeerID) { + // Group chats reuse the private-chat surface but broadcast a sealed + // envelope instead of routing to a single peer. + if peerID.isGroup { + groupCoordinator.sendGroupMessage(content, to: peerID) + return + } privateConversationCoordinator.sendPrivateMessage(content, to: peerID) } @@ -48,21 +55,69 @@ extension ChatViewModel { privateConversationCoordinator.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) } - @MainActor - func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - privateConversationCoordinator.sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubKey, from: id) - } - - @MainActor - func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - privateConversationCoordinator.sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubKey, from: id) - } - @MainActor func sendVoiceNote(at url: URL) { mediaTransferCoordinator.sendVoiceNote(at: url) } + /// Where a live burst would stream right now, or nil when the hold would + /// fall back to a classic voice note. + private enum LiveVoiceTarget { + case peer(PeerID) + case publicMesh + } + + @MainActor + private func liveVoiceTarget() -> LiveVoiceTarget? { + guard PTTSettings.liveVoiceEnabled else { return nil } + + if let selectedPeer = selectedPrivateChatPeer { + guard !selectedPeer.isGeoDM, !selectedPeer.isGeoChat, !selectedPeer.isGroup else { return nil } + // A conversation can be selected under the stable 64-hex Noise key + // (e.g. after migration on disconnect), but Noise sessions are keyed + // by the 16-hex routing ID — normalize once and send to that same + // short ID, like the private-message/file paths do. + let peerID = selectedPeer.toShort() + guard meshService.isPeerReachable(peerID), + case .established = meshService.getNoiseSessionState(for: peerID) + else { return nil } + return .peer(peerID) + } + + // Public mesh timeline: signed live broadcast. Geohash channels never + // reach here (the composer hides media affordances there). + return activeChannel == .mesh ? .publicMesh : nil + } + + /// Picks the capture backend for the composer's hold-to-record gesture: + /// live push-to-talk when the audience can hear it now — a DM peer that + /// is mesh-reachable with an established Noise session, or the public + /// mesh channel — otherwise the classic record-then-send voice note. + /// Either way the release delivers a normal voice note through + /// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble. + @MainActor + func makeVoiceCaptureSession() -> VoiceCaptureSession { + switch liveVoiceTarget() { + case .peer(let peerID): + return PTTLiveVoiceSession(sendPacket: { [meshService] packet in + meshService.sendVoiceFrame(packet, to: peerID) + }) + case .publicMesh: + return PTTLiveVoiceSession(sendPacket: { [meshService] packet in + meshService.sendVoiceFrameBroadcast(packet) + }) + case nil: + SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session) + return VoiceNoteCaptureSession() + } + } + + /// Inbound handler for `NoisePayloadType.voiceFrame`. + @MainActor + func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { + liveVoiceCoordinator.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) + } + #if os(iOS) func processThenSendImage(_ image: UIImage?) { mediaTransferCoordinator.processThenSendImage(image) @@ -97,11 +152,6 @@ extension ChatViewModel { mediaTransferCoordinator.clearTransferMapping(for: messageID) } - @MainActor - func handleMediaSendFailure(messageID: String, reason: String) { - mediaTransferCoordinator.handleMediaSendFailure(messageID: messageID, reason: reason) - } - @MainActor func handleTransferEvent(_ event: TransferProgressManager.Event) { mediaTransferCoordinator.handleTransferEvent(event) @@ -121,83 +171,14 @@ extension ChatViewModel { mediaTransferCoordinator.deleteMediaMessage(messageID: messageID) } - @MainActor - func handlePrivateMessage( - _ payload: NoisePayload, - actualSenderNoiseKey: Data?, - senderNickname: String, - targetPeerID: PeerID, - messageTimestamp: Date, - senderPubkey: String - ) { - privateConversationCoordinator.handlePrivateMessage( - payload, - actualSenderNoiseKey: actualSenderNoiseKey, - senderNickname: senderNickname, - targetPeerID: targetPeerID, - messageTimestamp: messageTimestamp, - senderPubkey: senderPubkey - ) - } - @MainActor func handlePrivateMessage(_ message: BitchatMessage) { + // A finalized voice note whose burst already streamed in live swaps + // into the existing bubble instead of appearing (and notifying) twice. + if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return } privateConversationCoordinator.handlePrivateMessage(message) } - @MainActor - func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { - privateConversationCoordinator.isDuplicateMessage(messageId, targetPeerID: targetPeerID) - } - - @MainActor - func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { - privateConversationCoordinator.addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) - } - - @MainActor - func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { - privateConversationCoordinator.mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: key) - } - - @MainActor - func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?, senderPubkey: String) { - privateConversationCoordinator.handleViewingThisChat( - message, - targetPeerID: targetPeerID, - key: key, - senderPubkey: senderPubkey - ) - } - - @MainActor - func markAsUnreadIfNeeded( - shouldMarkAsUnread: Bool, - targetPeerID: PeerID, - key: Data?, - isRecentMessage: Bool, - senderNickname: String, - messageContent: String - ) { - privateConversationCoordinator.markAsUnreadIfNeeded( - shouldMarkAsUnread: shouldMarkAsUnread, - targetPeerID: targetPeerID, - key: key, - isRecentMessage: isRecentMessage, - senderNickname: senderNickname, - messageContent: messageContent - ) - } - - @MainActor - func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { - privateConversationCoordinator.handleFavoriteNotificationFromMesh( - content, - from: peerID, - senderNickname: senderNickname - ) - } - @MainActor func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { privateConversationCoordinator.processActionMessage(message) @@ -208,11 +189,6 @@ extension ChatViewModel { privateConversationCoordinator.migratePrivateChatsIfNeeded(for: peerID, senderNickname: senderNickname) } - @MainActor - func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - privateConversationCoordinator.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) - } - @MainActor func isMessageBlocked(_ message: BitchatMessage) -> Bool { privateConversationCoordinator.isMessageBlocked(message) diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index 443849db..a0142cde 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -54,7 +54,7 @@ extension ChatViewModel { } } - @objc func handleTorPreferenceChanged(_ notification: Notification) { + @objc func handleTorPreferenceChanged(_: Notification) { Task { @MainActor in self.torStatusAnnounced = false self.torInitialReadyAnnounced = false diff --git a/bitchat/ViewModels/GeoChannelCoordinator.swift b/bitchat/ViewModels/GeoChannelCoordinator.swift index 51af663a..64a8db5d 100644 --- a/bitchat/ViewModels/GeoChannelCoordinator.swift +++ b/bitchat/ViewModels/GeoChannelCoordinator.swift @@ -37,25 +37,47 @@ final class GeoChannelCoordinator { private weak var context: (any GeoChannelContext)? private var cancellables = Set() - private var regionalGeohashes: [String] = [] + private var regionalChannels: [GeohashChannel] = [] private var bookmarkedGeohashes: [String] = [] + private var permissionState: LocationChannelManager.PermissionState + private var locationNotesEnabled: Bool + /// Mirrors `NearbyNotesCounter.revealed` (injectable for tests): the + /// session's one explicit notes act. Until it happens, background + /// sampling must not include the building-precision cell — see + /// `sampledRegionalGeohashes`. + private var notesRevealed = false + private let notesRevealedPublisher: AnyPublisher + private let locationNotesSettingsPublisher: AnyPublisher init( locationManager: LocationChannelManager? = nil, bookmarksStore: GeohashBookmarksStore? = nil, torManager: TorManager? = nil, + notesRevealed: AnyPublisher? = nil, + locationNotesEnabled: Bool? = nil, + locationNotesSettings: AnyPublisher? = nil, context: any GeoChannelContext ) { - self.locationManager = locationManager ?? Self.defaultLocationManager() + let resolvedLocationManager = locationManager ?? Self.defaultLocationManager() + self.locationManager = resolvedLocationManager self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared self.torManager = torManager ?? Self.defaultTorManager() + self.permissionState = resolvedLocationManager.permissionState + self.locationNotesEnabled = locationNotesEnabled ?? LocationNotesSettings.enabled + self.notesRevealedPublisher = notesRevealed + ?? NearbyNotesCounter.shared.$revealed.eraseToAnyPublisher() + self.locationNotesSettingsPublisher = locationNotesSettings + ?? NotificationCenter.default + .publisher(for: LocationNotesSettings.didChangeNotification) + .map { _ in LocationNotesSettings.enabled } + .eraseToAnyPublisher() self.context = context start() } func start() { - regionalGeohashes = locationManager.availableChannels.map { $0.geohash } + regionalChannels = locationManager.availableChannels bookmarkedGeohashes = bookmarksStore.bookmarks locationManager.$selectedChannel @@ -72,7 +94,30 @@ final class GeoChannelCoordinator { .receive(on: DispatchQueue.main) .sink { [weak self] channels in guard let self else { return } - self.regionalGeohashes = channels.map { $0.geohash } + self.regionalChannels = channels + self.updateSampling() + } + .store(in: &cancellables) + + // Revealing the nearby-notes counter is the session's explicit notes + // act; it widens sampling to include the building cell (below). + notesRevealedPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] revealed in + guard let self, self.notesRevealed != revealed else { return } + self.notesRevealed = revealed + self.updateSampling() + } + .store(in: &cancellables) + + // The location-notes preference is a live privacy kill switch. It + // removes the device-derived building cell even if the session was + // previously revealed. Explicit bookmarks remain eligible below. + locationNotesSettingsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] enabled in + guard let self, self.locationNotesEnabled != enabled else { return } + self.locationNotesEnabled = enabled self.updateSampling() } .store(in: &cancellables) @@ -89,10 +134,15 @@ final class GeoChannelCoordinator { locationManager.$permissionState .receive(on: DispatchQueue.main) .sink { [weak self] state in - guard let self, state == .authorized else { return } - Task { @MainActor [weak self] in - self?.locationManager.refreshChannels() + guard let self else { return } + self.permissionState = state + if state == .authorized { + self.locationManager.refreshChannels() } + // Cached channels outlive authorization by design. Recompute + // regardless of direction so revocation tears down regional + // sampling instead of continuing from stale coordinates. + self.updateSampling() } .store(in: &cancellables) @@ -102,18 +152,30 @@ final class GeoChannelCoordinator { updateSampling() } + /// Regional geohashes eligible for background sampling. The + /// building-precision (precision-8) cell identifies a single address, so + /// sampling it passively would leak the same location signal the + /// nearby-notes tap-to-reveal exists to gate — it joins only after the + /// session's explicit notes act. The coarser levels (block and up) keep + /// the nearby-conversation hint and channel participant counts working. + /// Bookmarks are exempt: bookmarking a geohash is itself explicit. + private var sampledRegionalGeohashes: [String] { + guard permissionState == .authorized else { return [] } + return regionalChannels + .filter { (notesRevealed && locationNotesEnabled) || $0.level != .building } + .map { $0.geohash } + } + private func updateSampling() { - let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes)) - Task { @MainActor in - guard !union.isEmpty else { - context?.endGeohashSampling() - return - } - if torManager.isForeground() { - context?.beginGeohashSampling(for: union) - } else { - context?.endGeohashSampling() - } + let union = Array(Set(sampledRegionalGeohashes).union(bookmarkedGeohashes)) + guard !union.isEmpty else { + context?.endGeohashSampling() + return + } + if torManager.isForeground() { + context?.beginGeohashSampling(for: union) + } else { + context?.endGeohashSampling() } } diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift index ffe423b5..4fa22ade 100644 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -115,6 +115,16 @@ final class GeoPresenceTracker { my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } + + // Non-empty content on a sampled event means an actual chat message + // (presence events are empty) — feed the nearby-conversation hint. + GeohashChatActivityTracker.shared.recordChatMessage( + geohash: gh, + senderName: Self.sampledSenderName(for: event, context: context), + content: content, + timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) + ) + guard existingCount == 0 else { return } let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) @@ -131,6 +141,19 @@ final class GeoPresenceTracker { cooldownPerGeohash(gh, content: content, event: event) } + /// Attribution for a sampled event: the event's own `n` tag wins (the + /// active-channel nickname table only covers the selected geohash), + /// falling back to the table, then "anon", always suffixed with the + /// pubkey tail like every other geohash display name. + @MainActor + static func sampledSenderName(for event: NostrEvent, context: any GeoPresenceContext) -> String { + let suffix = String(event.pubkey.suffix(4)) + let tagNick = event.tags.first { $0.count >= 2 && $0[0].lowercased() == "n" }?[1] + let nick = tagNick?.trimmedOrNilIfEmpty + ?? context.geoNicknames[event.pubkey.lowercased()]?.trimmedOrNilIfEmpty + return (nick ?? "anon") + "#" + suffix + } + @MainActor func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { guard let context else { return } diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift index 4e267c59..ce4c9448 100644 --- a/bitchat/ViewModels/GeohashSubscriptionManager.swift +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -115,6 +115,9 @@ final class GeohashSubscriptionManager { private weak var context: (any GeohashSubscriptionContext)? private let inbound: NostrInboundPipeline private let presence: GeoPresenceTracker + /// Geohashes already told "sent via mesh gateway" this session, so the + /// notice appears once per channel instead of once per message. + private var gatewayNoticeGeohashes = Set() init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { self.context = context @@ -145,6 +148,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.subscribeNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -235,6 +241,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.handleNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -280,6 +289,23 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } + // Mesh gateway uplink: with no working relay connection, hand the + // locally signed event to a mesh peer advertising the gateway + // capability (keys never leave this device — only the finished, + // signed event travels). Uplink is only ever attempted here, for a + // freshly composed event, never for received carrier events (loop + // rule 3 in GatewayService). + if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash), + gatewayNoticeGeohashes.insert(channel.geohash).inserted { + context.addPublicSystemMessage( + String( + localized: "system.gateway.sent_via_mesh", + defaultValue: "sent via mesh gateway", + comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable" + ) + ) + } + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) SecureLogger.debug( diff --git a/bitchat/ViewModels/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift index 5bca9a6d..1196c47f 100644 --- a/bitchat/ViewModels/MessageRateLimiter.swift +++ b/bitchat/ViewModels/MessageRateLimiter.swift @@ -48,15 +48,25 @@ struct MessageRateLimiter { self.contentRefill = contentRefillPerSec } - mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool { - var senderBucket = senderBuckets[senderKey] ?? TokenBucket( - capacity: senderCapacity, - tokens: senderCapacity, - refillPerSec: senderRefill, - lastRefill: now - ) - let senderAllowed = senderBucket.allow(now: now) - senderBuckets[senderKey] = senderBucket + /// - Parameter powBits: validated NIP-13 difficulty of the event + /// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events). + /// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is + /// skipped entirely — each such message paid for itself with work — but + /// the per-content flood bucket still applies. + mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool { + let senderAllowed: Bool + if powBits >= NostrPoW.rateLimitBypassBits { + senderAllowed = true + } else { + var senderBucket = senderBuckets[senderKey] ?? TokenBucket( + capacity: senderCapacity, + tokens: senderCapacity, + refillPerSec: senderRefill, + lastRefill: now + ) + senderAllowed = senderBucket.allow(now: now) + senderBuckets[senderKey] = senderBucket + } var contentBucket = contentBuckets[contentKey] ?? TokenBucket( capacity: contentCapacity, @@ -69,9 +79,4 @@ struct MessageRateLimiter { return senderAllowed && contentAllowed } - - mutating func reset() { - senderBuckets.removeAll() - contentBuckets.removeAll() - } } diff --git a/bitchat/ViewModels/MinimalDistancePalette.swift b/bitchat/ViewModels/MinimalDistancePalette.swift index fb05d3c9..c142382c 100644 --- a/bitchat/ViewModels/MinimalDistancePalette.swift +++ b/bitchat/ViewModels/MinimalDistancePalette.swift @@ -82,13 +82,6 @@ final class MinimalDistancePalette { return Color(hue: entry.hue, saturation: saturation, brightness: brightness) } - @MainActor - func reset() { - currentSeeds.removeAll() - entries.removeAll() - previousEntries.removeAll() - } - @MainActor private func rebuildEntries() { guard !currentSeeds.isEmpty else { diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 95d9b234..bc5ae744 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject { func recordGeoParticipant(pubkeyHex: String) // MARK: Inbound public messages - func handlePublicMessage(_ message: BitchatMessage) + /// `powBits` is the validated NIP-13 difficulty of the source event + /// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit + /// downstream. + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) func checkForMentions(_ message: BitchatMessage) func sendHapticFeedback(for message: BitchatMessage) func parseMentions(from content: String) -> [String] @@ -152,6 +155,7 @@ final class NostrInboundPipeline { let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = min(rawTs, Date()) let mentions = context.parseMentions(from: content) + let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) let message = BitchatMessage( id: event.id, sender: senderName, @@ -165,7 +169,7 @@ final class NostrInboundPipeline { Task { @MainActor [weak context] in guard let context else { return } let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - context.handlePublicMessage(message) + context.handlePublicMessage(message, powBits: powBits) if !isBlocked { context.checkForMentions(message) context.sendHapticFeedback(for: message) @@ -187,10 +191,12 @@ final class NostrInboundPipeline { guard event.isValidSignature() else { return } context.recordProcessedNostrEvent(event.id) + let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) + // Sampled: fires for every geo event and floods dev logs in busy geohashes. geoEventLogCount += 1 if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) + SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) } if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { @@ -255,7 +261,7 @@ final class NostrInboundPipeline { Task { @MainActor [weak context] in guard let context else { return } - context.handlePublicMessage(message) + context.handlePublicMessage(message, powBits: powBits) context.checkForMentions(message) context.sendHapticFeedback(for: message) } @@ -297,7 +303,11 @@ final class NostrInboundPipeline { context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. + // Live voice is mesh-only: latency and relay cost make it + // meaningless over Nostr. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: break } } @@ -349,7 +359,11 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. + // Live voice is mesh-only: latency and relay cost make it + // meaningless over Nostr. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: break } } @@ -428,7 +442,11 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse: + // Group state travels only over mesh Noise sessions + // in v1; group traffic over Nostr is ignored. + // Live voice is mesh-only: latency and relay cost make it + // meaningless over Nostr. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: break } } @@ -442,8 +460,7 @@ final class NostrInboundPipeline { } /// Resolves the Noise static key behind a Nostr pubkey via the favorites - /// store. Lives here because the inbound DM path needs it per message; - /// the favorites glue in `ChatNostrCoordinator` delegates to it. + /// store. Lives here because the inbound DM path needs it per message. @MainActor func findNoiseKey(for nostrPubkey: String) -> Data? { guard let context else { return nil } diff --git a/bitchat/ViewModels/PublicMessagePipeline.swift b/bitchat/ViewModels/PublicMessagePipeline.swift index 7383151b..9d30f83e 100644 --- a/bitchat/ViewModels/PublicMessagePipeline.swift +++ b/bitchat/ViewModels/PublicMessagePipeline.swift @@ -14,15 +14,15 @@ import Foundation @MainActor protocol PublicMessagePipelineDelegate: AnyObject { - func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String - func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? - func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) + func pipeline(_: PublicMessagePipeline, normalizeContent content: String) -> String + func pipeline(_: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? + func pipeline(_: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) /// Commits a batched message to its conversation in the store. /// Returns `false` when the message was already present (ID dedup). @discardableResult - func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool - func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) - func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) + func pipeline(_: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool + func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) + func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) } @MainActor @@ -60,6 +60,21 @@ final class PublicMessagePipeline { scheduleFlush() } + /// Discards an uncommitted row by ID. Bridge-first/radio-second dedup uses + /// this before inserting the authenticated radio copy, so the ~80 ms UI + /// batch cannot resurrect the replaced bridge alias after store removal. + func removeMessage(withID messageID: String) { + buffer.removeAll { $0.message.id == messageID } + if buffer.isEmpty { + timer?.invalidate() + timer = nil + } + } + + func containsMessage(withID messageID: String) -> Bool { + buffer.contains { $0.message.id == messageID } + } + func flushIfNeeded() { flushBuffer() } diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift index 92e70a02..4e3d4c6e 100644 --- a/bitchat/ViewModels/VoiceRecordingViewModel.swift +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -55,6 +55,18 @@ final class VoiceRecordingViewModel: ObservableObject { } @Published private(set) var state = State.idle + /// True while the active session streams audio live (push-to-talk); the + /// composer switches its recording HUD to the LIVE treatment. + @Published private(set) var isLiveStreaming = false + + /// Supplies the capture backend per press. `ChatViewModel` swaps in a + /// live push-to-talk session when the current DM peer can hear it now. + var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() } + private var activeSession: VoiceCaptureSession? + /// Monotonic press identity. A slow permission/start/finalize task from an + /// older hold may still deliver its file, but it must never mutate the UI + /// state of a newer hold. + private var holdGeneration: UInt64 = 0 func formattedDuration(for date: Date) -> String { let clamped = max(0, state.duration(for: date)) @@ -67,26 +79,83 @@ final class VoiceRecordingViewModel: ObservableObject { func start(shouldShow: Bool) { guard shouldShow, state == .idle else { return } + holdGeneration &+= 1 + let generation = holdGeneration + let session = sessionProvider() + SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session) + activeSession = session state = .requestingPermission Task { - let granted = await VoiceRecorder.shared.requestPermission() - guard state == .requestingPermission else { return } + let granted = await session.requestPermission() + guard generation == holdGeneration, + state == .requestingPermission, + activeSession === session + else { return } guard granted else { state = .permissionDenied + activeSession = nil return } state = .preparing do { - try await VoiceRecorder.shared.startRecording() - guard state == .preparing else { - cancel() + try await session.start() + guard generation == holdGeneration, + state == .preparing, + activeSession === session + else { + await session.cancel() return } state = .recording(startDate: Date()) + isLiveStreaming = session.isLive + } catch VoiceRecorder.RecorderError.recordingInProgress { + // The previous classic hold may still be in its intentional + // finalize-padding window. This press owns no recorder, so + // its owner-scoped cancel is harmless; return to idle instead + // of surfacing a false capture failure while the prior note + // finishes and delivers normally. + SecureLogger.info("Voice recording start deferred while the previous hold finalizes", category: .session) + await session.cancel() + guard generation == holdGeneration, + state == .preparing, + activeSession === session + else { return } + activeSession = nil + state = .idle } catch { SecureLogger.error("Voice recording failed to start: \(error)", category: .session) - await VoiceRecorder.shared.cancelRecording() - guard state == .preparing else { return } + await session.cancel() + guard generation == holdGeneration, + state == .preparing, + activeSession === session + else { return } + // The live engine and the classic recorder are separate + // capture stacks: when the live one hits an audio-route + // glitch, fall back within the same hold so the user still + // gets a voice note instead of an error. + if session.isLive { + let fallback = VoiceNoteCaptureSession() + activeSession = fallback + do { + try await fallback.start() + guard generation == holdGeneration, + state == .preparing, + activeSession === fallback + else { + await fallback.cancel() + return + } + SecureLogger.warning("PTT: live capture failed — fell back to classic voice note", category: .session) + state = .recording(startDate: Date()) + isLiveStreaming = false + return + } catch { + SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session) + await fallback.cancel() + guard generation == holdGeneration, state == .preparing else { return } + } + } + activeSession = nil state = .error(message: "Could not start recording.") } } @@ -103,19 +172,27 @@ final class VoiceRecordingViewModel: ObservableObject { } state = .idle + isLiveStreaming = false + let session = activeSession + let generation = holdGeneration + activeSession = nil - guard case .recording(let startDate) = previousState, let completion else { - Task { await VoiceRecorder.shared.cancelRecording() } + guard case .recording(let startDate) = previousState, let completion, let session else { + // A quick press releases before the recorder spins up; that has + // always been a silent no-op for voice notes — log it so field + // tests can tell "tapped" apart from "capture broke". + SecureLogger.info("PTT: mic released before recording started (state was \(previousState)) — hold longer to record", category: .session) + Task { await session?.cancel() } return } Task { let finalDuration = Date().timeIntervalSince(startDate) - if let url = await VoiceRecorder.shared.stopRecording(), + if let url = await session.finish(), isValidRecording(at: url, duration: finalDuration) { completion(url) } else { - guard state == .idle else { return } + guard generation == holdGeneration, state == .idle else { return } state = .error( message: finalDuration < VoiceRecorder.minRecordingDuration ? "Recording is too short." diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index cce9499e..646d6fde 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -1,26 +1,86 @@ import SwiftUI +/// The sheet behind the "bitchat/" logo: a segmented Settings/Info surface. +/// Settings gathers every user preference (appearance, voice, connectivity +/// toggles, panic wipe); Info keeps the about content (how-to, features, +/// privacy, symbols legend). struct AppInfoView: View { @Environment(\.dismiss) var dismiss @ThemedPalette private var palette @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @ObservedObject private var bridgeService = BridgeService.shared + + /// Supplies the mesh topology map data. Nil (previews, missing wiring) + /// hides the topology row entirely. + var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? + /// Wipes all local data. Nil (previews, missing wiring) hides the danger + /// zone entirely. + var onPanicWipe: (@MainActor () -> Void)? + + @State private var showTopology = false + @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled + @State private var locationNotesEnabled = LocationNotesSettings.enabled + @ObservedObject private var locationManager = LocationChannelManager.shared + /// Sticky across opens: first-ever open lands on Info (the gentler + /// introduction), and afterwards the sheet reopens wherever it was left. + @AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info + @State private var showPanicConfirmation = false + + private enum Pane: String { + case settings + case info + } private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix } - private var backgroundColor: Color { palette.background } - private var textColor: Color { palette.primary } private var secondaryTextColor: Color { palette.secondary } - + // MARK: - Constants private enum Strings { static let appName: LocalizedStringKey = "app_info.app_name" static let tagline: LocalizedStringKey = "app_info.tagline" static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title" + /// New keys carry their English copy inline (defaultValue) until the + /// i18n pass lands them in the catalog; moved keys keep their homes. + enum Settings { + static let tabPickerLabel = String(localized: "app_info.tab.picker_label", defaultValue: "view", comment: "Accessibility label for the segmented control switching between the settings and info panes of the app info sheet") + static let tabSettings = String(localized: "app_info.tab.settings", defaultValue: "settings", comment: "Segmented control label for the settings pane of the app info sheet") + static let tabInfo = String(localized: "app_info.tab.info", defaultValue: "info", comment: "Segmented control label for the info pane of the app info sheet") + + static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing") + + static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings") + static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does") + static func bridgeCell(_ cell: String) -> String { + String( + format: String(localized: "app_info.settings.bridge.cell", defaultValue: "rendezvous cell: %@", comment: "Caption under the mesh bridge toggle showing the geohash cell the bridge is meeting on"), + locale: .current, + cell + ) + } + static let bridgeNoCell = String(localized: "app_info.settings.bridge.no_cell", defaultValue: "no rendezvous cell yet — needs location access or a nearby bridge peer", comment: "Caption under the mesh bridge toggle when the bridge is on but has no geohash cell to meet on") + + // Moved from LocationChannelsSheet; keys unchanged. (The former + // internet-gateway toggle is gone: the bridge switch drives all + // internet sharing, including geohash-channel gatewaying.) + static let torTitle: LocalizedStringKey = "location_channels.tor.title" + static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + static let toggleOn: LocalizedStringKey = "common.toggle.on" + static let toggleOff: LocalizedStringKey = "common.toggle.off" + + static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings") + static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation") + static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut") + static let panicConfirmTitle = String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe") + static let panicConfirmAction = String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe") + } + enum Features { static let title: LocalizedStringKey = "app_info.features.title" static let offlineComm = AppInfoFeatureInfo( @@ -53,6 +113,56 @@ struct AppInfoView: View { title: "app_info.features.geohash.title", description: "app_info.features.geohash.description" ) + static let bridge = AppInfoFeatureInfo( + icon: "network", + resolvedTitle: String(localized: "app_info.features.bridge.title", defaultValue: "mesh bridging", comment: "Feature row title for the mesh bridge in the app info sheet"), + resolvedDescription: String(localized: "app_info.features.bridge.description", defaultValue: "links nearby mesh islands through the internet so one crowd isn't split by radio range", comment: "Feature row description for the mesh bridge in the app info sheet") + ) + } + + enum Legend { + static let title: LocalizedStringKey = "app_info.legend.title" + /// Every glyph the peer lists and headers use, in one place — + /// nothing else in the app defines them. A nil color renders in + /// the theme's primary text color. + static let items: [(icon: String, color: Color?, text: String)] = [ + ("antenna.radiowaves.left.and.right", nil, String(localized: "app_info.legend.mesh_connected")), + ("point.3.filled.connected.trianglepath.dotted", nil, String(localized: "app_info.legend.mesh_relayed")), + ("globe", nil, String(localized: "app_info.legend.nostr")), + ("network", Color.cyan, String(localized: "app_info.legend.bridged", defaultValue: "message arrived across a mesh bridge", comment: "Symbols legend entry for the cyan network glyph shown on messages carried across a mesh bridge")), + ("person", nil, String(localized: "app_info.legend.offline")), + ("mappin.and.ellipse", nil, String(localized: "app_info.legend.location_nearby")), + ("face.dashed", nil, String(localized: "app_info.legend.teleported")), + ("lock.fill", nil, String(localized: "app_info.legend.encrypted")), + ("lock.slash", nil, String(localized: "app_info.legend.encryption_failed")), + ("checkmark.seal.fill", nil, String(localized: "app_info.legend.verified")), + ("star.fill", nil, String(localized: "app_info.legend.favorite")), + ("envelope.fill", nil, String(localized: "app_info.legend.unread")), + ("nosign", nil, String(localized: "app_info.legend.blocked")) + ] + } + + enum Voice { + static let title: LocalizedStringKey = "app_info.voice.title" + // The live-voice title/description keys are referenced inline at + // the toggle (they ride the shared settingToggle now). + } + + enum Location { + static let notes = AppInfoFeatureInfo( + icon: "mappin.and.ellipse", + title: "app_info.location.notes.title", + description: "app_info.location.notes.description" + ) + } + + enum Network { + static let title: LocalizedStringKey = "app_info.network.title" + static let topology = AppInfoFeatureInfo( + icon: "point.3.connected.trianglepath.dotted", + title: "app_info.network.topology.title", + description: "app_info.network.topology.description" + ) } enum Privacy { @@ -76,18 +186,25 @@ struct AppInfoView: View { enum HowToUse { static let title: LocalizedStringKey = "app_info.how_to_use.title" - static let instructions: [LocalizedStringKey] = [ - "app_info.how_to_use.set_nickname", - "app_info.how_to_use.change_channels", - "app_info.how_to_use.open_sidebar", - "app_info.how_to_use.start_dm", - "app_info.how_to_use.clear_chat", - "app_info.how_to_use.commands" - ] + /// The instruction strings flowed into one comma-separated + /// paragraph. The translations carry their legacy bullet-list + /// prefix ("• "), so it is stripped here. + static var paragraph: String { + [ + String(localized: "app_info.how_to_use.set_nickname"), + String(localized: "app_info.how_to_use.change_channels"), + String(localized: "app_info.how_to_use.open_sidebar"), + String(localized: "app_info.how_to_use.start_dm"), + String(localized: "app_info.how_to_use.clear_chat"), + String(localized: "app_info.how_to_use.commands") + ] + .map { $0.hasPrefix("• ") ? String($0.dropFirst(2)) : $0 } + .joined(separator: ", ") + } } } - + var body: some View { #if os(macOS) VStack(spacing: 0) { @@ -103,51 +220,75 @@ struct AppInfoView: View { } .themedSurface(opacity: 0.95) - ScrollView { - infoContent + VStack(spacing: 0) { + panePicker + + ScrollView { + paneContent + } } .themedSheetBackground() } .frame(width: 600, height: 700) + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #else NavigationView { - ScrollView { - infoContent + VStack(spacing: 0) { + panePicker + + ScrollView { + paneContent + } } .themedSheetBackground() .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .foregroundColor(textColor) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel("app_info.close") + SheetCloseButton { dismiss() } + .foregroundColor(textColor) } } } + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #endif } - - @ViewBuilder - private var infoContent: some View { - VStack(alignment: .leading, spacing: 24) { - // Header - VStack(alignment: .center, spacing: 8) { - Text(Strings.appName) - .bitchatFont(size: 32, weight: .bold) - .foregroundColor(textColor) - - Text(Strings.tagline) - .bitchatFont(size: 16) - .foregroundColor(secondaryTextColor) - } - .frame(maxWidth: .infinity) - .padding(.vertical) + // MARK: - Pane switching + + private var panePicker: some View { + Picker(Strings.Settings.tabPickerLabel, selection: $selectedPane) { + Text(Strings.Settings.tabInfo).tag(Pane.info) + Text(Strings.Settings.tabSettings).tag(Pane.settings) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal) + .padding(.top, 12) + } + + @ViewBuilder + private var paneContent: some View { + switch selectedPane { + case .settings: + settingsContent + case .info: + infoContent + } + } + + // MARK: - Settings pane + + @ViewBuilder + private var settingsContent: some View { + VStack(alignment: .leading, spacing: 24) { // Appearance — single row: label left, theme chips right HStack(spacing: 12) { SectionHeader(Strings.appearanceTitle) @@ -172,17 +313,237 @@ struct AppInfoView: View { } } + // Voice — same card + IRC pill as every other toggle setting. + VStack(alignment: .leading, spacing: 12) { + SectionHeader(Strings.Voice.title) + + settingsCard { + settingToggle( + title: Text("app_info.voice.live.title"), + subtitle: Text("app_info.voice.live.description"), + isOn: Binding( + get: { liveVoiceEnabled }, + set: { newValue in + liveVoiceEnabled = newValue + PTTSettings.liveVoiceEnabled = newValue + } + ) + ) + } + } + + // Connectivity: mesh bridge, internet gateway, tor routing + VStack(alignment: .leading, spacing: 12) { + SectionHeader(verbatim: Strings.Settings.connectivityTitle) + + settingsCard { + settingToggle( + title: Text(Strings.Settings.bridgeTitle), + subtitle: Text(Strings.Settings.bridgeSubtitle), + isOn: bridgeToggleBinding + ) + // Where the bridge meets: the geohash rendezvous cell, or + // a hint about why there isn't one yet (no location and no + // bridge peer advertising a cell). + if bridgeService.isEnabled { + Text(bridgeService.activeCell.map(Strings.Settings.bridgeCell) ?? Strings.Settings.bridgeNoCell) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + } + } + + settingsCard { + settingToggle( + title: Text(Strings.Settings.torTitle), + subtitle: Text(Strings.Settings.torSubtitle), + isOn: torToggleBinding + ) + } + + // Location notes / dead drops (merged from main's flat + // layout into the shared card + pill style). Turning it on + // may need the location prompt; the permission control below + // covers the denied path. + settingsCard { + settingToggle( + title: Strings.Location.notes.title, + subtitle: Strings.Location.notes.description, + isOn: Binding( + get: { locationNotesEnabled }, + set: { newValue in + locationNotesEnabled = newValue + LocationNotesSettings.enabled = newValue + if newValue { + locationManager.enableLocationChannels() + } + } + ) + ) + } + + // Location powers the channels list and the bridge cell, so + // its control lives with the other connectivity settings. + // Platform reality shapes the three states: the app may only + // prompt while never-asked; granted/denied both flip in the + // system permission screen. + switch locationChannelsModel.permissionState { + case .authorized: + Button(action: SystemSettings.location.open) { + Text("location_channels.action.remove_access") + .bitchatFont(size: 12) + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.red.opacity(0.08)) + .cornerRadius(6) + } + .buttonStyle(.plain) + case .notDetermined: + Button(action: { locationChannelsModel.enableLocationChannels() }) { + Text("location_channels.action.request_permissions") + .bitchatFont(size: 12) + .foregroundColor(palette.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(palette.accent.opacity(0.12)) + .cornerRadius(6) + } + .buttonStyle(.plain) + case .denied, .restricted: + settingsCard { + Text("location_channels.permission_denied") + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + Button("location_channels.action.open_settings", action: SystemSettings.location.open) + .buttonStyle(.plain) + .bitchatFont(size: 12) + .foregroundColor(palette.accent) + } + } + } + + // Danger zone + if onPanicWipe != nil { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(verbatim: Strings.Settings.dangerTitle) + + Button(action: { showPanicConfirmation = true }) { + Text(Strings.Settings.panicButton) + .bitchatFont(size: 12) + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.red.opacity(0.08)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .confirmationDialog( + Strings.Settings.panicConfirmTitle, + isPresented: $showPanicConfirmation, + titleVisibility: .visible + ) { + Button(Strings.Settings.panicConfirmAction, role: .destructive) { + onPanicWipe?() + } + Button("common.cancel", role: .cancel) {} + } + + Text(Strings.Settings.panicNote) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding() + } + + private var bridgeToggleBinding: Binding { + Binding( + get: { bridgeService.isEnabled }, + set: { bridgeService.setEnabled($0) } + ) + } + + private var torToggleBinding: Binding { + Binding( + get: { locationChannelsModel.userTorEnabled }, + set: { locationChannelsModel.setUserTorEnabled($0) } + ) + } + + /// The padded card every connectivity setting sits in (moved look from + /// LocationChannelsSheet's toggle sections). + private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8, content: content) + .padding(12) + .background(palette.secondary.opacity(0.12)) + .cornerRadius(8) + } + + /// A title+subtitle row driving an IRC-style on/off pill — the one + /// toggle style every setting uses. + private func settingToggle(title: Text, subtitle: Text, isOn: Binding) -> some View { + Toggle(isOn: isOn) { + VStack(alignment: .leading, spacing: 2) { + title + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(textColor) + subtitle + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + } + } + .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.Settings.toggleOn, offLabel: Strings.Settings.toggleOff)) + } + + // MARK: - Info pane + + @ViewBuilder + private var infoContent: some View { + VStack(alignment: .leading, spacing: 24) { + // Header + VStack(alignment: .center, spacing: 8) { + Text(Strings.appName) + .bitchatFont(size: 32, weight: .bold) + .foregroundColor(textColor) + + Text(Strings.tagline) + .bitchatFont(size: 16) + .foregroundColor(secondaryTextColor) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + // How to Use VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.HowToUse.title) - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in - Text(instruction) + Text(verbatim: Strings.HowToUse.paragraph) + .bitchatFont(size: 14) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) + } + + // Network diagnostics + if topologyProvider != nil { + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.Network.title) + + Button { + showTopology = true + } label: { + HStack(spacing: 0) { + FeatureRow(info: Strings.Network.topology) + Image(systemName: "chevron.right") + .font(.bitchatSystem(size: 12)) + .foregroundColor(secondaryTextColor) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityHint(Text("app_info.network.topology.hint")) } - .bitchatFont(size: 14) - .foregroundColor(textColor) } // Features @@ -195,6 +556,8 @@ struct AppInfoView: View { FeatureRow(info: Strings.Features.extendedRange) + FeatureRow(info: Strings.Features.bridge) + FeatureRow(info: Strings.Features.favorites) FeatureRow(info: Strings.Features.geohash) @@ -212,6 +575,28 @@ struct AppInfoView: View { FeatureRow(info: Strings.Privacy.panic) } + + // Symbols legend + VStack(alignment: .leading, spacing: 10) { + SectionHeader(Strings.Legend.title) + + ForEach(Strings.Legend.items, id: \.icon) { item in + HStack(alignment: .top, spacing: 12) { + Image(systemName: item.icon) + .font(.bitchatSystem(size: 14)) + .foregroundColor(item.color ?? textColor) + .frame(width: 30) + + Text(item.text) + .bitchatFont(size: 13) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + .accessibilityElement(children: .combine) + } + } } .padding() } @@ -219,22 +604,42 @@ struct AppInfoView: View { struct AppInfoFeatureInfo { let icon: String - let title: LocalizedStringKey - let description: LocalizedStringKey + let title: Text + let description: Text + + /// Catalog-backed strings (existing keys). + init(icon: String, title: LocalizedStringKey, description: LocalizedStringKey) { + self.icon = icon + self.title = Text(title) + self.description = Text(description) + } + + /// Pre-resolved strings — new keys that carry their English defaultValue + /// inline until the i18n pass adds them to the catalog. + init(icon: String, resolvedTitle: String, resolvedDescription: String) { + self.icon = icon + self.title = Text(resolvedTitle) + self.description = Text(resolvedDescription) + } } struct SectionHeader: View { - let title: LocalizedStringKey + private let title: Text @ThemedPalette private var palette private var textColor: Color { palette.primary } init(_ title: LocalizedStringKey) { - self.title = title + self.title = Text(title) } - + + /// For pre-resolved strings (new keys with inline defaultValue). + init(verbatim title: String) { + self.title = Text(title) + } + var body: some View { - Text(title) + title .bitchatFont(size: 16, weight: .bold) .foregroundColor(textColor) .padding(.top, 8) @@ -255,18 +660,18 @@ struct FeatureRow: View { .font(.bitchatSystem(size: 20)) .foregroundColor(textColor) .frame(width: 30) - + VStack(alignment: .leading, spacing: 4) { - Text(info.title) + info.title .bitchatFont(size: 14, weight: .semibold) .foregroundColor(textColor) - - Text(info.description) + + info.description .bitchatFont(size: 12) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) } - + Spacer() } } @@ -274,14 +679,17 @@ struct FeatureRow: View { #Preview("Default") { AppInfoView() + .environmentObject(LocationChannelsModel()) } #Preview("Dynamic Type XXL") { AppInfoView() + .environmentObject(LocationChannelsModel()) .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) } #Preview("Dynamic Type XS") { AppInfoView() + .environmentObject(LocationChannelsModel()) .environment(\.sizeCategory, .extraSmall) } diff --git a/bitchat/Views/BridgePeopleList.swift b/bitchat/Views/BridgePeopleList.swift new file mode 100644 index 00000000..89162397 --- /dev/null +++ b/bitchat/Views/BridgePeopleList.swift @@ -0,0 +1,76 @@ +// +// BridgePeopleList.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Shared section header for the people sheet: a small glyph + label pair, +/// identical shape for every section (#mesh, across the bridge, …). +struct PeopleSectionHeader: View { + @ThemedPalette private var palette + let icon: String + let iconColor: Color + let title: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.bitchatSystem(size: 10)) + .foregroundColor(iconColor) + Text(verbatim: title) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.secondary) + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + } +} + +/// The people-sheet section for participants visible across the mesh bridge: +/// same place, beyond radio range. Display-only in v1 — bridged identities +/// are per-cell rendezvous keys with no DM route yet. +struct BridgePeopleList: View { + @ObservedObject private var bridgeService = BridgeService.shared + @ThemedPalette private var palette + + private enum Strings { + static let sectionTitle = String(localized: "bridge_people.section_title", defaultValue: "across the bridge", comment: "Section header in the people sheet for participants reachable via the mesh bridge") + static let rowHint = String(localized: "bridge_people.accessibility.row_hint", defaultValue: "In your area, connected through the bridge", comment: "Accessibility hint for a person listed in the bridge section of the people sheet") + } + + var body: some View { + // Not gated on the toggle: bridged people arrive over passive radio + // (a serving neighbor's carriers) even while this device's own + // bridge is off — whoever is visible in the timeline belongs in the + // sheet. + if !bridgeService.bridgedParticipants.isEmpty { + VStack(alignment: .leading, spacing: 0) { + PeopleSectionHeader( + icon: "network", + iconColor: Color.cyan.opacity(0.9), + title: Strings.sectionTitle + ) + + ForEach(bridgeService.bridgedParticipants) { person in + HStack(spacing: 4) { + Text(person.displayName) + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityHint(Strings.rowHint) + } + } + } + } +} diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index 786a3caf..cc4f9609 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -14,29 +14,46 @@ struct CommandSuggestionsView: View { @Binding var messageText: String + /// The command already typed in full, once arguments have begun. + private var typedCommandAlias: String? { + guard messageText.hasPrefix("/"), + let spaceIndex = messageText.firstIndex(of: " ") + else { return nil } + return String(messageText[.. String { - String( - format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"), - locale: .current, - nickname - ) - } - - static func read(by nickname: String) -> String { - String( - format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"), - locale: .current, - nickname - ) - } - - static func failed(_ reason: String) -> String { - String( - format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"), - locale: .current, - reason - ) - } - - static func deliveredToMembers(_ reached: Int, _ total: Int) -> String { - String( - format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), - locale: .current, - reached, - total - ) - } - } - // MARK: - Body - + var body: some View { + statusGlyph + .help(status.bitchatDescription) + .accessibilityElement(children: .ignore) + .accessibilityLabel(status.bitchatDescription) + } + + @ViewBuilder + private var statusGlyph: some View { switch status { case .sending: Image(systemName: "circle") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) - + case .sent: Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) - - case .delivered(let nickname, _): + + case .carried: + Image(systemName: "figure.walk") + .font(.bitchatSystem(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.8)) + + case .delivered: HStack(spacing: -2) { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) @@ -76,24 +95,22 @@ struct DeliveryStatusView: View { .font(.bitchatSystem(size: 10)) } .foregroundColor(textColor.opacity(0.8)) - .help(Strings.delivered(to: nickname)) - - case .read(let nickname, _): - HStack(spacing: -2) { - Image(systemName: "checkmark") - .font(.bitchatSystem(size: 10, weight: .bold)) - Image(systemName: "checkmark") - .font(.bitchatSystem(size: 10, weight: .bold)) + + case .read: + // Filled variant so read vs delivered is legible without color. + HStack(spacing: 0) { + Image(systemName: "checkmark.circle.fill") + .font(.bitchatSystem(size: 9, weight: .bold)) + Image(systemName: "checkmark.circle.fill") + .font(.bitchatSystem(size: 9, weight: .bold)) } .foregroundColor(palette.accentBlue) - .help(Strings.read(by: nickname)) - - case .failed(let reason): + + case .failed: Image(systemName: "exclamationmark.triangle") .font(.bitchatSystem(size: 10)) .foregroundColor(Color.red.opacity(0.8)) - .help(Strings.failed(reason)) - + case .partiallyDelivered(let reached, let total): HStack(spacing: 1) { Image(systemName: "checkmark") @@ -102,7 +119,6 @@ struct DeliveryStatusView: View { .bitchatFont(size: 10) } .foregroundColor(secondaryTextColor.opacity(0.6)) - .help(Strings.deliveredToMembers(reached, total)) } } } @@ -111,6 +127,7 @@ struct DeliveryStatusView: View { let statuses: [DeliveryStatus] = [ .sending, .sent, + .carried, .delivered(to: "John Doe", at: Date()), .read(by: "Jane Doe", at: Date()), .failed(reason: "Offline"), diff --git a/bitchat/Views/Components/IRCToggleStyle.swift b/bitchat/Views/Components/IRCToggleStyle.swift new file mode 100644 index 00000000..0b97bf08 --- /dev/null +++ b/bitchat/Views/Components/IRCToggleStyle.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// IRC-flavored toggle: the whole row is one button and the state is spelled +/// out as an on/off pill instead of a system switch. Shared by the settings +/// surfaces (App Info's connectivity toggles and friends). +struct IRCToggleStyle: ToggleStyle { + let accent: Color + let onLabel: LocalizedStringKey + let offLabel: LocalizedStringKey + + func makeBody(configuration: Configuration) -> some View { + Button(action: { configuration.isOn.toggle() }) { + HStack(spacing: 12) { + configuration.label + Spacer() + Text(configuration.isOn ? onLabel : offLabel) + .textCase(.uppercase) + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(configuration.isOn ? accent : .secondary) + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) + ) + } + } + .buttonStyle(.plain) + } +} diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift new file mode 100644 index 00000000..a244d5f1 --- /dev/null +++ b/bitchat/Views/Components/MeshEmptyStateView.swift @@ -0,0 +1,217 @@ +// +// MeshEmptyStateView.swift +// bitchat +// +// The empty mesh timeline, upgraded from a dead end into a live surface: +// a sonar shows the radio scanning, the daily sightings tally proves the +// spot isn't dead, the liveliest nearby geohash conversation is one tap +// away, and notes left at this place surface when there are any. +// This is free and unencumbered software released into the public domain. +// + +import SwiftUI + +struct MeshEmptyStateView: View { + /// Visible chat height to fill; the radar centers in the space left + /// below the narration. Zero (previews) keeps a compact layout. + var fillHeight: CGFloat = 0 + /// Ambient-footer mode, appended below archived echoes: skips the + /// intro/help narration (the timeline isn't empty) and shrinks the + /// radar, keeping the sightings tally and the live hints visible. + var compact: Bool = false + + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var peerListModel: PeerListModel + @ObservedObject private var activityTracker = GeohashChatActivityTracker.shared + @ObservedObject private var sightingsTracker = MeshSightingsTracker.shared + @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared + + @ThemedPalette private var palette + + /// The activity window is evaluated at render time; without new events + /// nothing would trigger a re-render, so a stale "people are talking" + /// hint could linger. A slow tick keeps the hints and relative times + /// honest. + @State private var refreshTick = 0 + private let refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + private enum Strings { + static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is") + static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen") + static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today") + static let checkNotes = String(localized: "content.empty.check_notes", comment: "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs") + + static func sightingsMany(_ count: Int) -> String { + String( + format: String(localized: "content.empty.sightings_many", comment: "Empty mesh timeline stat counting devices that came within range today"), + locale: .current, + count + ) + } + + static func activityOne(_ geohash: String) -> String { + String( + format: String(localized: "content.empty.activity_one", comment: "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash"), + locale: .current, + geohash + ) + } + + static func activityMany(_ geohash: String) -> String { + String( + format: String(localized: "content.empty.activity_many", comment: "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash"), + locale: .current, + geohash + ) + } + + } + + /// The radar means "searching for people": once anyone is connected or + /// reachable on the mesh, the search is over and the sweep goes away. + private var isSearchingForPeers: Bool { + peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if compact { + if isSearchingForPeers { + radarBlock + } + if let conversation = nearbyConversation { + conversationHint(conversation) + } + if showsCheckNotesHint { + checkNotesHint + } + } else { + // The radar + tally already say "scanning, nobody yet", so + // the narration stays to two lines with the live hint after + // them, not wedged in between. + narrationLine(Strings.meshIntro) + narrationLine(Strings.switchHint) + if let conversation = nearbyConversation { + conversationHint(conversation) + } + if showsCheckNotesHint { + checkNotesHint + } + + // The radar centers in whatever space is left below the + // text — the flexible spacers split it evenly. + if isSearchingForPeers { + Spacer(minLength: 24) + radarBlock + Spacer(minLength: 12) + } + } + } + .frame(minHeight: compact ? 0 : fillHeight, alignment: .top) + .onReceive(refreshTimer) { _ in + refreshTick += 1 + // Roll the tally over if the local day changed while idle. + sightingsTracker.refreshForDisplay() + } + } + + /// The radar with today's tally as its caption — the stat belongs to + /// the scanning visual, not the narration lines. + private var radarBlock: some View { + VStack(spacing: 4) { + MeshRadarView(height: compact ? 44 : 72) + if sightingsTracker.todayCount > 0 { + Text(verbatim: sightingsText) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) + } + } + .frame(maxWidth: .infinity) + } +} + +private extension MeshEmptyStateView { + var nearbyConversation: NearbyConversation? { + activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels) + } + + /// Tap-to-reveal: the nearby-notes counter never subscribes on its own — + /// looking at the mesh timeline must not open a building-precision relay + /// REQ (a passive location side-channel). This static line is the one + /// explicit act that unlocks it; nothing touches the network until the + /// tap. It only renders when location permission is already granted + /// (the tap never prompts, so without permission it would dead-end + /// silently). Once revealed it yields to today's live strip and count, + /// and the app-info setting stays the kill switch. + var showsCheckNotesHint: Bool { + nearbyNotes.offersRevealHint(permissionState: locationChannelsModel.permissionState) + } + + var checkNotesHint: some View { + Button { + NearbyNotesCounter.shared.reveal() + } label: { + actionLine("📍 \(Strings.checkNotes)") + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // The visual label carries decorative asterisks and an emoji; expose + // just the localized action text to assistive tech. + .accessibilityLabel(Strings.checkNotes) + } + + var sightingsText: String { + sightingsTracker.todayCount == 1 + ? Strings.sightingsOne + : Strings.sightingsMany(sightingsTracker.todayCount) + } + + func conversationHint(_ conversation: NearbyConversation) -> some View { + let headline = conversation.messageCount == 1 + ? Strings.activityOne(conversation.channel.geohash) + : Strings.activityMany(conversation.channel.geohash) + + return Button { + locationChannelsModel.markTeleported(for: conversation.channel.geohash, false) + locationChannelsModel.select(.location(conversation.channel)) + } label: { + VStack(alignment: .leading, spacing: 2) { + actionLine("💬 \(headline)") + narrationLine(" \(previewText(for: conversation.lastMessage))") + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + func previewText(for message: GeohashChatPreview) -> String { + let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen + var content = message.content + if content.count > maxLen { + content = String(content.prefix(maxLen)) + "…" + } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let ago = formatter.localizedString(for: message.timestamp, relativeTo: Date()) + return "<\(message.senderName)> \(content) · \(ago)" + } + + func narrationLine(_ text: String) -> some View { + emptyStateLine(text, color: palette.secondary.opacity(0.9)) + } + + /// Tappable lines render in the primary color so they read as actions + /// amid the grey narration. + func actionLine(_ text: String) -> some View { + emptyStateLine(text, color: palette.primary) + } + + func emptyStateLine(_ text: String, color: Color) -> some View { + // Non-breaking space before the closing asterisk so a tight wrap + // can't orphan a lone "*" onto its own line. + Text(verbatim: "* \(text)\u{00A0}*") + .bitchatFont(size: 13) + .foregroundColor(color) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/bitchat/Views/Components/MeshRadarView.swift b/bitchat/Views/Components/MeshRadarView.swift new file mode 100644 index 00000000..d59c569e --- /dev/null +++ b/bitchat/Views/Components/MeshRadarView.swift @@ -0,0 +1,68 @@ +// +// MeshRadarView.swift +// bitchat +// +// Ambient sonar shown on the empty mesh timeline: expanding rings around a +// center dot make it visible that the radio is broadcasting and scanning +// even when nobody is in range. Purely decorative — hidden from +// accessibility, static under Reduce Motion. +// This is free and unencumbered software released into the public domain. +// + +import SwiftUI + +struct MeshRadarView: View { + /// Full size on the empty timeline; the ambient footer under archived + /// echoes uses a smaller one. + var height: CGFloat = 72 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ThemedPalette private var palette + + private let ringCount = 3 + private let period: TimeInterval = 3.0 + + var body: some View { + Group { + if reduceMotion { + radar(at: 0.35) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { context in + radar(at: context.date.timeIntervalSinceReferenceDate) + } + } + } + .frame(height: height) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } + + private func radar(at time: TimeInterval) -> some View { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let maxRadius = min(size.width, size.height) / 2 - 2 + + for ring in 0.. 1 else { continue } + let alpha = 0.45 * (1 - phase) + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + context.stroke( + Path(ellipseIn: rect), + with: .color(palette.primary.opacity(alpha)), + lineWidth: 1 + ) + } + + let dot = CGRect(x: center.x - 2, y: center.y - 2, width: 4, height: 4) + context.fill(Path(ellipseIn: dot), with: .color(palette.primary.opacity(0.9))) + } + } +} diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index 605f7b47..bdda8772 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -7,12 +7,17 @@ // import SwiftUI +#if os(iOS) +import UIKit +#else +import AppKit +#endif struct PaymentChipView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL @ThemedPalette private var palette - + enum PaymentType { case cashu(String) case lightning(String) @@ -35,14 +40,33 @@ struct PaymentChipView: View { return URL(string: link) } } - + + /// The bare `cashuA…`/`cashuB…` bearer string, when this is a Cashu chip. + var cashuToken: String? { + if case .cashu(let link) = self { + return CashuTokenDecoder.bareToken(from: link) + } + return nil + } + + /// Web fallback for redemption when no wallet handles `cashu:` URLs. + /// The token only reaches the site the user's browser loads; the app + /// itself never contacts a mint. + var cashuWebRedeemURL: URL? { + guard let token = cashuToken, + let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else { + return nil + } + return URL(string: "https://redeem.cashu.me/?token=\(enc)") + } + var emoji: String { switch self { case .cashu: "🥜" case .lightning: "⚡" } } - + var label: String { switch self { case .cashu: @@ -52,27 +76,56 @@ struct PaymentChipView: View { } } } - + let paymentType: PaymentType - + /// Decoded once at construction; tokens are capped in size so this is + /// cheap, and rows re-render often enough that lazy decode in `body` + /// would just repeat the work. + private let cashuInfo: CashuTokenDecoder.TokenInfo? + + init(paymentType: PaymentType) { + self.paymentType = paymentType + if case .cashu(let link) = paymentType { + self.cashuInfo = CashuTokenDecoder.decode(link) + } else { + self.cashuInfo = nil + } + } + private var fgColor: Color { palette.primary } private var bgColor: Color { - colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) + palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12) } private var border: Color { fgColor.opacity(0.25) } - + + /// "500 sat · mint.example.com", degrading to the generic label when the + /// token didn't decode (V4 payloads we can't walk, malformed input…). + private var primaryLabel: String { + guard let info = cashuInfo else { return paymentType.label } + var parts: [String] = [] + if let amount = info.displayAmount { parts.append(amount) } + if let host = info.mintHost { parts.append(host) } + return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ") + } + + private var memoLabel: String? { cashuInfo?.memo } + var body: some View { Button { - #if os(iOS) - if let url = paymentType.url { openURL(url) } - #else - if let url = paymentType.url { NSWorkspace.shared.open(url) } - #endif + primaryAction() } label: { HStack(spacing: 6) { Text(paymentType.emoji) - Text(paymentType.label) - .bitchatFont(size: 12, weight: .semibold) + VStack(alignment: .leading, spacing: 1) { + Text(primaryLabel) + .bitchatFont(size: 12, weight: .semibold) + if let memoLabel { + Text(memoLabel) + .bitchatFont(size: 10) + .opacity(0.7) + .lineLimit(1) + } + } } .padding(.vertical, 6) .padding(.horizontal, 12) @@ -87,13 +140,102 @@ struct PaymentChipView: View { .foregroundColor(fgColor) } .buttonStyle(.plain) + .contextMenu { + if let token = paymentType.cashuToken { + Button { + copyToPasteboard(token) + } label: { + Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc") + } + Button { + redeemCashu() + } label: { + Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass") + } + if let webURL = paymentType.cashuWebRedeemURL { + Button { + openExternalURL(webURL) + } label: { + Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari") + } + } + } + } + .accessibilityLabel(Text(verbatim: accessibilityText)) + } + + private var accessibilityText: String { + var text = "\(paymentType.label): \(primaryLabel)" + if let memoLabel { text += ", \(memoLabel)" } + return text + } + + // MARK: - Actions + + private func primaryAction() { + switch paymentType { + case .cashu: + redeemCashu() + case .lightning: + #if os(iOS) + if let url = paymentType.url { openURL(url) } + #else + if let url = paymentType.url { NSWorkspace.shared.open(url) } + #endif + } + } + + /// Redemption is delegated: try a wallet registered for `cashu:` URLs + /// first, then fall back to the web redemption page. Uses the platform + /// opener directly (not the `openURL` environment) because the message + /// list overrides that action for cashu/lightning schemes without a + /// fallback path. + private func redeemCashu() { + let walletURL = paymentType.url + let webURL = paymentType.cashuWebRedeemURL + #if os(iOS) + if let walletURL { + UIApplication.shared.open(walletURL, options: [:]) { accepted in + if !accepted, let webURL { + UIApplication.shared.open(webURL) + } + } + } else if let webURL { + UIApplication.shared.open(webURL) + } + #else + if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil { + NSWorkspace.shared.open(walletURL) + } else if let webURL { + NSWorkspace.shared.open(webURL) + } else if let walletURL { + NSWorkspace.shared.open(walletURL) + } + #endif + } + + private func openExternalURL(_ url: URL) { + #if os(iOS) + UIApplication.shared.open(url) + #else + NSWorkspace.shared.open(url) + #endif + } + + private func copyToPasteboard(_ string: String) { + #if os(iOS) + UIPasteboard.general.string = string + #else + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + #endif } } #Preview { let cashuLink = "https://example.com/cashu" let lightningLink = "https://example.com/lightning" - + List { HStack { PaymentChipView(paymentType: .cashu(cashuLink)) diff --git a/bitchat/Views/Components/SheetCloseButton.swift b/bitchat/Views/Components/SheetCloseButton.swift new file mode 100644 index 00000000..e2891e35 --- /dev/null +++ b/bitchat/Views/Components/SheetCloseButton.swift @@ -0,0 +1,29 @@ +// +// SheetCloseButton.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// The close "X" every sheet and header shares. One glyph size and weight +/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box +/// so existing header metrics don't move, and a hit target extended to 44pt +/// per platform guidelines. Tint comes from the environment, so callers keep +/// their own foreground color. +struct SheetCloseButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "xmark") + .bitchatFont(size: 13, weight: .semibold) + .frame(width: 32, height: 32) + .contentShape(Rectangle().inset(by: -6)) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) + } +} diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 63346e26..2e1009d7 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -12,6 +12,7 @@ import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.appTheme) private var theme + @ThemedPalette private var palette @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage @@ -24,6 +25,7 @@ struct TextMessageView: View { /// the enum makes the change visible to SwiftUI's structural diff. private let deliveryStatus: DeliveryStatus? @State private var expandedMessageIDs: Set = [] + @State private var showDeliveryDetail = false init(message: BitchatMessage) { self.message = message @@ -35,19 +37,68 @@ struct TextMessageView: View { // Precompute heavy token scans once per row let cashuLinks = message.content.extractCashuLinks() let lightningLinks = message.content.extractLightningLinks() - HStack(alignment: .top, spacing: 0) { + // Baseline alignment keeps the lock and delivery glyphs on the + // first text line; a fixed top padding left the lock's solid body + // hanging below the line's visual center. + HStack(alignment: .firstTextBaseline, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) + if message.isPrivate { + Image(systemName: "lock.fill") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.orange.opacity(0.75)) + .padding(.trailing, 4) + .accessibilityHidden(true) + } + if message.isBridged { + Image(systemName: "network") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.cyan.opacity(0.75)) + .padding(.trailing, 4) + .accessibilityLabel( + String(localized: "content.accessibility.bridged_message", defaultValue: "Arrived across a mesh bridge", comment: "Accessibility label for the glyph marking a message that arrived across a mesh bridge") + ) + } Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) .fixedSize(horizontal: false, vertical: true) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) - // Delivery status indicator for private messages + // Delivery status indicator for private messages. Tappable: + // .help() tooltips only exist on macOS, so iOS users get the + // explanation as a caption under the row instead. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), let status = deliveryStatus { - DeliveryStatusView(status: status) - .padding(.leading, 4) + Button { + showDeliveryDetail.toggle() + } label: { + DeliveryStatusView(status: status) + .padding(.leading, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint( + String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") + ) + } + } + + // Failure reasons stay visible without a tap; other statuses + // reveal on demand. + if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), + let status = deliveryStatus { + if case .failed = status { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(Color.red.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } else if showDeliveryDetail { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) } } @@ -60,7 +111,7 @@ struct TextMessageView: View { else { expandedMessageIDs.insert(message.id) } } .bitchatFont(size: 11, weight: .medium) - .foregroundColor(Color.blue) + .foregroundColor(palette.accentBlue) .padding(.top, 4) } @@ -78,6 +129,17 @@ struct TextMessageView: View { .padding(.leading, 2) } } + // Collapse the revealed caption when the status advances (e.g. + // sending → sent → delivered) so a detail opened for one state + // doesn't linger and silently morph into another. Guarded write: + // under a message storm many rows change status within one frame, + // and an unconditional state write per change trips SwiftUI's + // "tried to update multiple times per frame" re-entrancy warning. + .onChange(of: deliveryStatus) { _ in + if showDeliveryDetail { + showDeliveryDetail = false + } + } } } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index a643487a..9913ce03 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -6,6 +6,8 @@ import UIKit struct ContentComposerView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @ObservedObject private var bridgeService = BridgeService.shared @Environment(\.appTheme) private var theme @ThemedPalette private var palette @@ -43,7 +45,6 @@ struct ContentComposerView: View { .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) - .background(Color.gray.opacity(0.1)) } } .themedOverlayPanel() @@ -60,10 +61,8 @@ struct ContentComposerView: View { TextField( "", text: $messageText, - prompt: Text( - String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") - ) - .foregroundColor(palette.secondary.opacity(0.6)) + prompt: Text(placeholderText) + .foregroundColor(palette.secondary.opacity(0.6)) ) .textFieldStyle(.plain) .bitchatFont(size: 15) @@ -91,6 +90,10 @@ struct ContentComposerView: View { } HStack(alignment: .center, spacing: 4) { + if showsNearbyOnlyToggle { + nearbyOnlyToggle + } + if conversationUIModel.canSendMediaInCurrentContext { attachmentButton } @@ -110,18 +113,92 @@ struct ContentComposerView: View { } private extension ContentComposerView { + /// The nearby-only scope toggle appears only where it means something: + /// the public mesh channel with the bridge on. + var showsNearbyOnlyToggle: Bool { + guard bridgeService.isEnabled, + privateConversationModel.selectedHeaderState == nil, + case .mesh = locationChannelsModel.selectedChannel else { + return false + } + return true + } + + /// Scope control for outgoing messages: bridged (default, crosses to + /// other islands in this area) vs nearby-only (radio range, no internet + /// copy exists for any gateway to carry). + var nearbyOnlyToggle: some View { + Button(action: { bridgeService.nearbyOnly.toggle() }) { + Image(systemName: bridgeService.nearbyOnly ? "antenna.radiowaves.left.and.right" : "network") + .font(.bitchatSystem(size: 16)) + .foregroundColor(bridgeService.nearbyOnly ? palette.secondary : Color.cyan.opacity(0.9)) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + bridgeService.nearbyOnly + ? String(localized: "content.accessibility.nearby_only_on", defaultValue: "Nearby only: messages stay within radio range", comment: "Accessibility label for the compose scope toggle when messages stay local") + : String(localized: "content.accessibility.nearby_only_off", defaultValue: "Bridged: messages also reach people across the bridge", comment: "Accessibility label for the compose scope toggle when messages cross the mesh bridge") + ) + .help( + bridgeService.nearbyOnly + ? String(localized: "content.composer.nearby_only_on", defaultValue: "Nearby only — this message won't cross the bridge", comment: "Tooltip for the compose scope toggle when messages stay local") + : String(localized: "content.composer.nearby_only_off", defaultValue: "Bridged — reaches people beyond radio range in this area", comment: "Tooltip for the compose scope toggle when messages cross the mesh bridge") + ) + } + + /// States where a message will land: the DM partner's name for private + /// chats, the channel (and its public nature) otherwise — so a stressed + /// user never has to guess who can read what they're typing. + var placeholderText: String { + if let header = privateConversationModel.selectedHeaderState { + // A geohash-DM display name already carries its own "#geohash/@name" + // form, so it must not get another "@" prefix; a mesh nickname does. + let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true + let target = isGeoDM ? header.displayName : "@\(header.displayName)" + return String( + format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"), + locale: .current, + target + ) + } + switch locationChannelsModel.selectedChannel { + case .mesh: + return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel") + case .location(let channel): + return String( + format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"), + locale: .current, + channel.geohash + ) + } + } + var recordingIndicator: some View { HStack(spacing: 12) { - Image(systemName: "waveform.circle.fill") + Image(systemName: voiceRecordingVM.isLiveStreaming ? "dot.radiowaves.left.and.right" : "waveform.circle.fill") .foregroundColor(.red) .font(.bitchatSystem(size: 20)) + .modifier(PulsingOpacityModifier(active: voiceRecordingVM.isLiveStreaming)) TimelineView(.periodic(from: .now, by: 0.05)) { context in - Text( - "recording \(voiceRecordingVM.formattedDuration(for: context.date))", - comment: "Voice note recording duration indicator" - ) - .bitchatFont(size: 13) - .foregroundColor(.red) + // Live streaming means audio is heard as you speak — the HUD + // must make that unmistakable, not just show a timer. + if voiceRecordingVM.isLiveStreaming { + Text( + "live \(voiceRecordingVM.formattedDuration(for: context.date))", + comment: "Recording HUD label while a voice message streams live to the recipient" + ) + .bitchatFont(size: 13, weight: .bold) + .foregroundColor(.red) + } else { + Text( + "recording \(voiceRecordingVM.formattedDuration(for: context.date))", + comment: "Voice note recording duration indicator" + ) + .bitchatFont(size: 13) + .foregroundColor(.red) + } } Spacer() Button(action: voiceRecordingVM.cancel) { @@ -158,7 +235,19 @@ private extension ContentComposerView { imagePickerSourceType = .camera showImagePicker = true } - .accessibilityLabel("Tap for library, long press for camera") + .accessibilityLabel( + String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button") + ) + .accessibilityHint( + String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library") + ) + .accessibilityAddTraits(.isButton) + // The long-press → camera path is unreachable for VoiceOver users; + // mirror it as a named action. + .accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) { + imagePickerSourceType = .camera + showImagePicker = true + } #else Button(action: { showMacImagePicker = true }) { Image(systemName: "photo.circle.fill") @@ -167,7 +256,9 @@ private extension ContentComposerView { .frame(width: 36, height: 36) } .buttonStyle(.plain) - .accessibilityLabel("Choose photo") + .accessibilityLabel( + String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button") + ) #endif } @@ -190,10 +281,28 @@ private extension ContentComposerView { } } + /// Floor courtesy: someone else is talking live in the public channel. + /// Only advisory — a decentralized mesh has no floor arbiter, so holding + /// the mic still works; the tint just discourages talk-over. + var busyTalker: String? { + guard privateConversationModel.selectedPeerID == nil else { return nil } + return conversationUIModel.activeLiveVoiceTalker + } + + /// Recording > floor-busy > default accent. Whether the hold streams + /// live or records a classic note is signaled by the recording HUD's + /// LIVE treatment, not the idle button color. + var micColor: Color { + if voiceRecordingVM.state.isActive { return .red } + if busyTalker != nil { return Color.red.opacity(0.6) } + return composerAccentColor + } + var micButtonView: some View { Image(systemName: "mic.circle.fill") .font(.bitchatSystem(size: 24)) - .foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor) + .foregroundColor(micColor) + .modifier(PulsingOpacityModifier(active: busyTalker != nil && !voiceRecordingVM.state.isActive)) .frame(width: 36, height: 36) .contentShape(Circle()) .overlay( @@ -209,7 +318,33 @@ private extension ContentComposerView { } ) ) - .accessibilityLabel("Hold to record a voice note") + .accessibilityLabel( + String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button") + ) + .accessibilityValue( + voiceRecordingVM.state.isActive + ? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording") + : busyTalker.map { + String( + format: String(localized: "content.accessibility.someone_speaking", comment: "Accessibility value on the mic button naming who is talking live in the public channel"), + locale: .current, + $0 + ) + } ?? "" + ) + .accessibilityHint( + String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording") + ) + .accessibilityAddTraits(.isButton) + // Press-and-hold drag gestures can't be activated by VoiceOver; + // give it a start/stop toggle as the default action. + .accessibilityAction { + if voiceRecordingVM.state.isActive { + voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote) + } else { + voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext) + } + } } func sendButtonView(enabled: Bool) -> some View { diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 3c20baa7..96b548fb 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -1,43 +1,74 @@ import SwiftUI -#if os(iOS) -import UIKit -#endif struct ContentHeaderView: View { @EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel + @EnvironmentObject private var boardAlertsModel: BoardAlertsModel + @ObservedObject private var bridgeService = BridgeService.shared @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.appTheme) private var theme @ThemedPalette private var palette @Binding var showSidebar: Bool @Binding var showVerifySheet: Bool - @Binding var showLocationNotes: Bool - @Binding var notesGeohash: String? var isNicknameFieldFocused: FocusState.Binding let headerHeight: CGFloat let headerPeerIconSize: CGFloat let headerPeerCountFontSize: CGFloat + /// Courier envelopes this device is carrying for offline third parties. + @State private var carriedMailCount = 0 + + /// Board posts mirrored from the store so the pin icon can show when the + /// current scope has notices. + @State private var boardPosts: [BoardPostPacket] = [] + + /// Nostr-only location notes at this place (live while the empty mesh + /// timeline is showing) — they should light the pin too. + @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared + + /// The bridged-people count belongs to the mesh channel only. + private var showBridgedPeerCount: Bool { + if case .location = locationChannelsModel.selectedChannel { return false } + return bridgeService.bridgedPeerCount > 0 + } + var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") .bitchatFont(size: 18, weight: .medium) + .lineLimit(1) .foregroundColor(palette.primary) + // When icons crowd the header, squeeze the nickname first + // (priority 0) and the logo only as a last resort; the icon + // cluster at priority 3 never gives up width. + .layoutPriority(2) .onTapGesture(count: 3) { appChromeModel.panicClearAllData() } .onTapGesture(count: 1) { appChromeModel.presentAppInfo() } + // This is the only entry point to App Info, but it reads as + // static text; surface the tap. (The triple-tap panic wipe + // stays undiscoverable on purpose — it's destructive.) + .accessibilityAddTraits(.isButton) + .accessibilityHint( + String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info") + ) + .accessibilityAction { + appChromeModel.presentAppInfo() + } HStack(spacing: 0) { Text(verbatim: "@") .bitchatFont(size: 14) .foregroundColor(palette.secondary) + // Keep the sigil whole while the field beside it shrinks. + .fixedSize() TextField( "content.input.nickname_placeholder", @@ -74,10 +105,51 @@ struct ContentHeaderView: View { if case .location = locationChannelsModel.selectedChannel { return peerListModel.visibleGeohashPeerCount } - return countAndColor.0 + // One number for the whole room: radio-reachable peers plus + // people across the bridge (visible via carriers even while + // this device's own bridge is off). The sheet breaks it down. + return countAndColor.0 + bridgeService.bridgedPeerCount }() HStack(spacing: 2) { + if locationChannelsModel.gatewayEnabled { + // The gateway toggle lives in the App Info settings pane + // now, so the indicator deep-links there. + Button(action: { appChromeModel.presentAppInfo() }) { + Image(systemName: "globe") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.8)) + .headerTapTarget() + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator") + ) + .accessibilityHint( + String(localized: "content.accessibility.gateway_settings_hint", defaultValue: "Opens settings to turn the gateway on or off", comment: "Accessibility hint for the internet gateway indicator explaining a tap opens the settings sheet") + ) + .help( + String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator") + ) + } + + if carriedMailCount > 0 { + Image(systemName: "figure.walk") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.8)) + .headerTapTarget() + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"), + locale: .current, + carriedMailCount + ) + ) + .help( + String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator") + ) + } + if appChromeModel.hasUnreadPrivateMessages { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Image(systemName: "envelope.fill") @@ -91,23 +163,41 @@ struct ContentHeaderView: View { ) } - if case .mesh = locationChannelsModel.selectedChannel, - locationChannelsModel.permissionState == .authorized { - Button(action: { - locationChannelsModel.enableAndRefresh() - notesGeohash = locationChannelsModel.currentBuildingGeohash - showLocationNotes = true - }) { - Image(systemName: "note.text") - .font(.bitchatSystem(size: 12)) - .foregroundColor(Color.orange.opacity(0.8)) - .headerTapTarget() + Button(action: { + var scopes: Set = [""] + if let geoScope = noticesGeoScope { + scopes.insert(geoScope) } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") - ) + boardAlertsModel.markSeen(forScopes: scopes) + appChromeModel.presentNotices() + }) { + // Filled whenever the current scope has notices at all + // (matching the orange tint); hollow means nothing here. + Image(systemName: scopeHasNotices || unseenNoticesCount > 0 ? "pin.fill" : "pin") + .font(.bitchatSystem(size: 12)) + .foregroundColor( + scopeHasNotices || unseenNoticesCount > 0 + ? Color.orange.opacity(0.8) + : palette.secondary.opacity(0.9) + ) + .headerTapTarget() } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button") + ) + .accessibilityValue( + unseenNoticesCount > 0 + ? String( + format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"), + locale: .current, + unseenNoticesCount + ) + : "" + ) + .help( + String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button") + ) if case .location(let channel) = locationChannelsModel.selectedChannel { Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { @@ -183,6 +273,19 @@ struct ContentHeaderView: View { headerOtherPeersCount ) ) + // Connected-vs-nobody is otherwise encoded only in the icon's + // color; say it. With a live bridge, also say who's across it. + .accessibilityValue( + showBridgedPeerCount + ? String( + format: String(localized: "content.accessibility.bridged_count", defaultValue: "%lld more people across the bridge", comment: "Accessibility value announcing the number of people reachable via the mesh bridge"), + locale: .current, + bridgeService.bridgedPeerCount + ) + : (headerPeersReachable + ? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable") + : String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")) + ) } .layoutPriority(3) .sheet(isPresented: $showVerifySheet) { @@ -190,49 +293,34 @@ struct ContentHeaderView: View { .environmentObject(verificationModel) } } + // Fixed height is load-bearing: children fill the bar with + // .frame(maxHeight: .infinity) tap targets, so an open-ended + // minHeight lets the header expand to swallow the whole screen. + // headerHeight is a @ScaledMetric, so it still grows with Dynamic + // Type. .frame(height: headerHeight) .padding(.horizontal, 12) + .onReceive(CourierStore.shared.$carriedCount) { count in + carriedMailCount = count + } + .onReceive(BoardStore.shared.$postsSnapshot) { posts in + boardPosts = posts + } .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) .environmentObject(locationChannelsModel) .environmentObject(peerListModel) } - .sheet(isPresented: $showLocationNotes, onDismiss: { - notesGeohash = nil - }) { - Group { - if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash { - LocationNotesView( - geohash: geohash, - senderNickname: appChromeModel.nickname - ) - .environmentObject(locationChannelsModel) - } else { - ContentLocationNotesUnavailableView( - showLocationNotes: $showLocationNotes, - headerHeight: headerHeight - ) - .environmentObject(locationChannelsModel) - } - } - .onAppear { - locationChannelsModel.enableLocationChannels() - locationChannelsModel.beginLiveRefresh() - } - .onDisappear { - locationChannelsModel.endLiveRefresh() - } - .onChange(of: locationChannelsModel.availableChannels) { channels in - if let current = channels.first(where: { $0.level == .building })?.geohash, - notesGeohash != current { - notesGeohash = current - #if os(iOS) - let generator = UIImpactFeedbackGenerator(style: .light) - generator.prepare() - generator.impactOccurred() - #endif - } - } + .sheet( + isPresented: $appChromeModel.isNoticesSheetPresented, + onDismiss: { appChromeModel.noticesSheetPrefersGeoTab = false } + ) { + NoticesView( + senderNickname: appChromeModel.nickname, + board: appChromeModel.boardManager, + initialTab: initialNoticesTab + ) + .environmentObject(locationChannelsModel) } .onAppear { locationChannelsModel.refreshMeshChannelsIfNeeded() @@ -266,55 +354,63 @@ private extension ContentHeaderView { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } + /// Open the notices sheet on the tab matching the current channel: the + /// geohash channel's notices, or the mesh-local board in mesh chat. An + /// explicit geo-tab request (the "notes left here" hint) wins. + var initialNoticesTab: NoticesView.Tab { + if appChromeModel.noticesSheetPrefersGeoTab { + return .geo + } + if case .location = locationChannelsModel.selectedChannel { + return .geo + } + return .mesh + } + + /// The geo scope the notices sheet would open on: the selected location + /// channel, or the device's building geohash when chatting on mesh. + var noticesGeoScope: String? { + if case .location(let channel) = locationChannelsModel.selectedChannel { + return channel.geohash + } + return locationChannelsModel.currentBuildingGeohash + } + + /// Whether either tab of the notices sheet currently has content: board + /// posts in scope, plus Nostr-only location notes when the nearby-notes + /// counter happens to be live (it runs with the empty mesh timeline). + var scopeHasNotices: Bool { + boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope } + || nearbyNotes.noteCount > 0 + } + + /// New pins in either visible scope since the sheet was last opened. + var unseenNoticesCount: Int { + let meshCount = boardAlertsModel.unseenCount(forGeohash: "") + let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0 + return meshCount + geoCount + } + + /// Whether anyone is actually reachable on the current channel — the + /// state the count icon's color encodes visually. + var headerPeersReachable: Bool { + switch locationChannelsModel.selectedChannel { + case .location: + return peerListModel.visibleGeohashPeerCount > 0 + case .mesh: + return peerListModel.connectedMeshPeerCount > 0 + } + } + func channelPeopleCountAndColor() -> (Int, Color) { switch locationChannelsModel.selectedChannel { case .location: let count = peerListModel.visibleGeohashPeerCount - return (count, count > 0 ? palette.locationAccent : Color.secondary) + return (count, count > 0 ? palette.locationAccent : palette.secondary) case .mesh: let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary + let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary return (peerListModel.reachableMeshPeerCount, color) } } } - -private struct ContentLocationNotesUnavailableView: View { - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @ThemedPalette private var palette - - @Binding var showLocationNotes: Bool - - let headerHeight: CGFloat - - var body: some View { - VStack(spacing: 12) { - HStack { - Text("content.notes.title") - .bitchatFont(size: 16, weight: .bold) - Spacer() - Button(action: { showLocationNotes = false }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .foregroundColor(palette.primary) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) - } - .frame(height: headerHeight) - .padding(.horizontal, 12) - .themedChromePanel(edge: .top) - Text("content.notes.location_unavailable") - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - Button("content.location.enable") { - locationChannelsModel.enableAndRefresh() - } - .buttonStyle(.bordered) - Spacer() - } - .themedSheetBackground() - .foregroundColor(palette.primary) - } -} diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 9e4aaa48..0cbd27d1 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -11,8 +11,6 @@ struct ContentPeopleSheetView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @EnvironmentObject private var peerListModel: PeerListModel @Binding var showSidebar: Bool @Binding var messageText: String @@ -79,8 +77,7 @@ struct ContentPeopleSheetView: View { #endif } else { ContentPeopleListView( - showSidebar: $showSidebar, - headerHeight: headerHeight + showSidebar: $showSidebar ) } } @@ -134,6 +131,7 @@ private struct ContentPeopleListView: View { @EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var verificationModel: VerificationModel + @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel @Environment(\.dismiss) private var dismiss @@ -141,8 +139,6 @@ private struct ContentPeopleListView: View { @Binding var showSidebar: Bool - let headerHeight: CGFloat - @State private var showVerifySheet = false var body: some View { @@ -159,52 +155,32 @@ private struct ContentPeopleListView: View { .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) + // .help maps to the accessibility *hint* on iOS, so the + // button still needs a spoken name. + .accessibilityLabel( + String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button") + ) .help( String(localized: "content.help.verification", comment: "Help text for verification button") ) } - Button(action: { + SheetCloseButton { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { dismiss() showSidebar = false showVerifySheet = false privateConversationModel.endConversation() } - }) { - Image(systemName: "xmark") - .bitchatFont(size: 12, weight: .semibold) - .frame(width: 32, height: 32) } - .buttonStyle(.plain) - .accessibilityLabel("Close") } - let activeText = String.localizedStringWithFormat( - String(localized: "%@ active", comment: "Count of active users in the people sheet"), - "\(peopleSheetActiveCount)" - ) - - if let subtitle = peopleSheetSubtitle { - let subtitleColor: Color = { - switch locationChannelsModel.selectedChannel { - case .mesh: - return palette.accentBlue - case .location: - return palette.locationAccent - } - }() - - HStack(spacing: 6) { - Text(subtitle) - .foregroundColor(subtitleColor) - Text(activeText) - .foregroundColor(.secondary) - } - .bitchatFont(size: 12) - } else { - Text(activeText) + // The mesh sheet titles its sections inline (#mesh / across + // the bridge / groups) — no subtitle or count up here. + // Location channels keep their geohash subtitle. + if case .location(let channel) = locationChannelsModel.selectedChannel { + Text(verbatim: "#\(channel.geohash.lowercased())") .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.locationAccent) } } .padding(.horizontal, 16) @@ -213,7 +189,10 @@ private struct ContentPeopleListView: View { .themedSurface() ScrollView { - VStack(alignment: .leading, spacing: 6) { + // spacing 0: every section supplies its own rhythm (header + // top 12 / bottom 4, rows vertical 4), so inter-child spacing + // here would make the first section's gap read differently. + VStack(alignment: .leading, spacing: 0) { if case .location = locationChannelsModel.selectedChannel { GeohashPeopleList( onTapPerson: { @@ -221,6 +200,11 @@ private struct ContentPeopleListView: View { } ) } else { + PeopleSectionHeader( + icon: "antenna.radiowaves.left.and.right", + iconColor: palette.accentBlue, + title: "#mesh" + ) MeshPeerList( onTapPeer: { peerID in peerListModel.startConversation(with: peerID) @@ -231,11 +215,33 @@ private struct ContentPeopleListView: View { }, onShowFingerprint: { peerID in appChromeModel.showFingerprint(for: peerID) + }, + onToggleBlock: { peer in + if peer.isBlocked { + conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName) + } else { + conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName) + } + } + ) + // People in this area but beyond radio range, and + // private groups: one sheet for the whole room. + BridgePeopleList() + GroupChatList( + groups: peerListModel.groupRows, + onTapGroup: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true } ) } } .padding(.top, 4) + // Full width even when every row is narrow (empty mesh, no + // groups): without this the VStack hugs its widest child and + // the ScrollView centers it — headers and empty states + // floated mid-screen on iPhone. + .frame(maxWidth: .infinity, alignment: .leading) .id(peerListModel.renderID) } } @@ -251,27 +257,9 @@ private extension ContentPeopleListView { String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() } - var peopleSheetSubtitle: String? { - switch locationChannelsModel.selectedChannel { - case .mesh: - return "#mesh" - case .location(let channel): - return "#\(channel.geohash.lowercased())" - } - } - - var peopleSheetActiveCount: Int { - switch locationChannelsModel.selectedChannel { - case .mesh: - return peerListModel.reachableMeshPeerCount - case .location: - return peerListModel.visibleGeohashPeerCount - } - } } private struct ContentPrivateChatSheetView: View { - @EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel @Binding var showSidebar: Bool @@ -333,6 +321,9 @@ private struct ContentPrivateChatSheetView: View { Image(systemName: headerState.isFavorite ? "star.fill" : "star") .font(.bitchatSystem(size: 14)) .foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) + // Same visual box + 44pt hit target as SheetCloseButton. + .frame(width: 32, height: 32) + .contentShape(Rectangle().inset(by: -6)) } .buttonStyle(.plain) .accessibilityLabel( @@ -346,24 +337,20 @@ private struct ContentPrivateChatSheetView: View { Spacer(minLength: 0) - Button(action: { + SheetCloseButton { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { privateConversationModel.endConversation() showSidebar = true } - }) { - Image(systemName: "xmark") - .bitchatFont(size: 12, weight: .semibold) - .frame(width: 32, height: 32) } - .buttonStyle(.plain) - .accessibilityLabel("Close") } - .frame(height: headerHeight) + // minHeight so scaled text at accessibility sizes grows the + // bar instead of clipping inside it. + .frame(minHeight: headerHeight) .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 12) - .themedSurface() + .modifier(PrivateHeaderChrome()) } MessageListView( @@ -380,11 +367,18 @@ private struct ContentPrivateChatSheetView: View { ) .themedSurface() .frame(maxWidth: .infinity, maxHeight: .infinity) + // Swipe-right-to-leave lives on the message list only. On the + // whole sheet it preempted the composer's press-and-hold mic + // gesture (a high-priority ancestor drag cancels child gestures + // within milliseconds — same starvation as the image-reveal bug). + .highPriorityGesture(swipeToLeaveGesture) if !theme.usesGlassChrome { Divider() } + privacyCaption + #if os(iOS) ContentComposerView( messageText: $messageText, @@ -408,18 +402,86 @@ private struct ContentPrivateChatSheetView: View { } .themedSheetBackground() .foregroundColor(palette.primary) - .highPriorityGesture( - DragGesture(minimumDistance: 25, coordinateSpace: .local) - .onEnded { value in - let horizontal = value.translation.width - let vertical = abs(value.translation.height) - guard horizontal > 80, vertical < 60 else { return } - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - showSidebar = true - privateConversationModel.endConversation() - } + } + + private var swipeToLeaveGesture: some Gesture { + DragGesture(minimumDistance: 25, coordinateSpace: .local) + .onEnded { value in + let horizontal = value.translation.width + let vertical = abs(value.translation.height) + guard horizontal > 80, vertical < 60 else { return } + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + showSidebar = true + privateConversationModel.endConversation() } - ) + } + } + + /// Persistent one-line reminder that this composer feeds a private + /// conversation — the DM sheet otherwise renders identically to the + /// public timeline. Claims end-to-end encryption only once the session + /// is actually secured. + private var privacyCaption: some View { + HStack(spacing: 5) { + Image(systemName: "lock.fill") + .font(.bitchatSystem(size: 9)) + // Optical centering: lock.fill's ink is bottom-heavy, so + // geometric centering reads low next to the caption text. + .offset(y: -1) + Text(verbatim: privacyCaptionText) + .bitchatFont(size: 11, weight: .medium) + } + .foregroundColor(Color.orange) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + // The orange text is signature enough; a tinted band here reads as a + // stray strip against the untinted composer chrome below it, so the + // caption sits on the same surface as the rest of the bottom chrome. + .themedSurface() + .accessibilityElement(children: .combine) + } + + private var privacyCaptionText: String { + // Group chats are ChaCha20-Poly1305 sealed to the roster's shared key. + if privateConversationModel.selectedPeerID?.isGroup == true { + return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members") + } + // Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted, + // even though they carry no Noise session status. Mesh DMs earn the + // "encrypted" claim only once the Noise handshake has secured. + let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true + let noiseSecured: Bool = { + switch privateConversationModel.selectedHeaderState?.encryptionStatus { + case .noiseSecured, .noiseVerified: return true + default: return false + } + }() + if isGeoDM || noiseSecured { + return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted") + } + return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established") + } +} + +/// Chrome for the private-chat header. Matrix keeps its orange privacy wash +/// over an opaque themed surface. Glass gets the same floating panel as the +/// main header instead: an orange wash over the backdrop gradient reads as a +/// muddy gray-beige band, and the DM signature is already carried by the +/// orange lock, caption, and composer accents. +private struct PrivateHeaderChrome: ViewModifier { + @Environment(\.appTheme) private var theme + + @ViewBuilder + func body(content: Content) -> some View { + if theme.usesGlassChrome { + content.themedChromePanel(edge: .top) + } else { + // Orange tint before themedSurface so it layers in front of the + // opaque themed background rather than behind it. + content + .background(Color.orange.opacity(0.06)) + .themedSurface() + } } } @@ -432,37 +494,63 @@ private struct ContentPrivateHeaderInfoButton: View { var body: some View { Button(action: { + // A group has no single fingerprint to show. + guard !headerState.isGroupConversation else { return } appChromeModel.showFingerprint(for: headerState.headerPeerID) }) { HStack(spacing: 6) { - switch headerState.availability { - case .bluetoothConnected: - Image(systemName: "dot.radiowaves.left.and.right") + if headerState.isGroupConversation { + Image(systemName: "person.3.fill") .font(.bitchatSystem(size: 14)) .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) - case .meshReachable: - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) - case .nostrAvailable: - Image(systemName: "globe") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.purple) - .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) - case .offline: - EmptyView() + .accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator")) + } else { + switch headerState.availability { + case .bluetoothConnected: + Image(systemName: "dot.radiowaves.left.and.right") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) + case .meshReachable: + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) + case .nostrAvailable: + Image(systemName: "globe") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) + case .offline: + // Slashed variant of the connected glyph — offline as + // the negation of connected, no text label (a leading + // one read as part of the name: "sin conexión bob"). + // VoiceOver still says it. + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.secondary) + .accessibilityLabel(String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")) + } } Text(headerState.displayName) .bitchatFont(size: 16, weight: .medium) .foregroundColor(palette.primary) + // Middle truncation keeps the identity suffix visible on + // long nicknames instead of wrapping into the fixed-height + // header. + .lineLimit(1) + .truncationMode(.middle) if let encryptionStatus = headerState.encryptionStatus, let icon = encryptionStatus.icon { Image(systemName: icon) .font(.bitchatSystem(size: 14)) + // Optical centering: the lock glyphs' ink is bottom-heavy + // (solid body, thin shackle), so geometric centering reads + // ~1pt low next to the name. The seal badge is symmetric + // and needs no lift. + .offset(y: icon.hasPrefix("lock") ? -1 : 0) .foregroundColor( encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured ? palette.primary @@ -476,6 +564,7 @@ private struct ContentPrivateHeaderInfoButton: View { ) ) } + } } .buttonStyle(.plain) @@ -487,8 +576,10 @@ private struct ContentPrivateHeaderInfoButton: View { ) ) .accessibilityHint( - String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") + headerState.isGroupConversation + ? "" + : String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") ) - .frame(height: headerHeight) + .frame(minHeight: headerHeight) } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 86bb31ad..fc1a6766 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -35,6 +35,7 @@ struct ContentView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @State private var messageText = "" @@ -49,8 +50,6 @@ struct ContentView: View { @State private var isAtBottomPrivate = true @State private var autocompleteDebounceTimer: Timer? @State private var showVerifySheet = false - @State private var showLocationNotes = false - @State private var notesGeohash: String? @State private var imagePreviewURL: URL? #if os(iOS) @State private var showImagePicker = false @@ -77,6 +76,9 @@ struct ContentView: View { .onAppear { conversationUIModel.setCurrentColorScheme(colorScheme) conversationUIModel.setCurrentTheme(appTheme) + voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in + conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession() + } #if os(macOS) DispatchQueue.main.async { isNicknameFieldFocused = false @@ -149,7 +151,11 @@ struct ContentView: View { #endif } .sheet(isPresented: $appChromeModel.isAppInfoPresented) { - AppInfoView() + AppInfoView( + topologyProvider: { appChromeModel.meshTopologyDisplayModel() }, + onPanicWipe: { appChromeModel.panicClearAllData() } + ) + .environmentObject(locationChannelsModel) } .sheet(isPresented: Binding( get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, @@ -269,8 +275,6 @@ struct ContentView: View { ContentHeaderView( showSidebar: $showSidebar, showVerifySheet: $showVerifySheet, - showLocationNotes: $showLocationNotes, - notesGeohash: $notesGeohash, isNicknameFieldFocused: $isNicknameFieldFocused, headerHeight: headerHeight, headerPeerIconSize: headerPeerIconSize, diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index cbac2d2a..a6c371ae 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -17,8 +17,6 @@ struct FingerprintView: View { private var textColor: Color { palette.primary } - private var backgroundColor: Color { palette.background } - private enum Strings { static let title: LocalizedStringKey = "fingerprint.title" static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label" @@ -37,8 +35,13 @@ struct FingerprintView: View { } static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified" static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification" - static func unknownPeer() -> String { - String(localized: "common.unknown", comment: "Label for an unknown peer") + static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched" + static func vouchedBy(_ count: Int) -> String { + String( + format: String(localized: "fingerprint.message.vouched_by", comment: "How many people the user verified have vouched for this peer"), + locale: .current, + count + ) } } @@ -54,11 +57,8 @@ struct FingerprintView: View { Spacer() - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 14, weight: .semibold)) - } - .foregroundColor(textColor) + SheetCloseButton { dismiss() } + .foregroundColor(textColor) } .padding() @@ -83,7 +83,7 @@ struct FingerprintView: View { Spacer() } .padding() - .background(Color.gray.opacity(0.1)) + .background(palette.secondary.opacity(0.1)) .cornerRadius(8) // Their fingerprint @@ -101,7 +101,7 @@ struct FingerprintView: View { .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.1)) + .background(palette.secondary.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { @@ -135,7 +135,7 @@ struct FingerprintView: View { .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.1)) + .background(palette.secondary.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { @@ -149,6 +149,41 @@ struct FingerprintView: View { } } + // Vouched (transitively verified) status: shown whenever the + // peer isn't explicitly verified but people I verified vouch + // for them, independent of the current session state. + if fingerprintState.isVouched && !fingerprintState.isVerified { + VStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "checkmark.seal") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.teal) + Text(Strings.vouchedBadge) + .bitchatFont(size: 14, weight: .bold) + .foregroundColor(.teal) + } + .frame(maxWidth: .infinity) + + Text(Strings.vouchedBy(fingerprintState.voucherCount)) + .bitchatFont(size: 12) + .foregroundColor(textColor.opacity(0.7)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if !fingerprintState.voucherNames.isEmpty { + Text(fingerprintState.voucherNames.joined(separator: ", ")) + .bitchatFont(size: 12) + .foregroundColor(textColor.opacity(0.7)) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + } + .padding(.top, 8) + .accessibilityElement(children: .combine) + } + // Verification status if fingerprintState.canToggleVerification { VStack(spacing: 12) { diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 4582e706..9613f227 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -13,6 +13,13 @@ struct GeohashPeopleList: View { static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels") static let unblock: LocalizedStringKey = "geohash_people.action.unblock" static let block: LocalizedStringKey = "geohash_people.action.block" + static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person") + static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person") + static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere") + static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area") + static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") + static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list") + static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat") } var body: some View { @@ -46,7 +53,12 @@ struct GeohashPeopleList: View { let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse" let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark) let rowColor: Color = person.isMe ? .orange : assignedColor - Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor) + Image(systemName: icon) + // Size 10 to match the mesh rows' leading glyphs — + // both lists share the sidebar. + .font(.bitchatSystem(size: 10)) + .foregroundColor(rowColor) + .help(person.isTeleported ? Strings.teleported : Strings.nearby) let (base, suffix) = person.displayName.splitSuffix() HStack(spacing: 0) { @@ -54,6 +66,8 @@ struct GeohashPeopleList: View { .bitchatFont(size: 14) .fontWeight(person.isMe ? .bold : .regular) .foregroundColor(rowColor) + .lineLimit(1) + .truncationMode(.tail) if !suffix.isEmpty { let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) Text(suffix) @@ -105,6 +119,27 @@ struct GeohashPeopleList: View { } } } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: person)) + .accessibilityAddTraits(person.isMe ? [] : .isButton) + .accessibilityHint(person.isMe ? "" : Strings.openDMHint) + .accessibilityActions { + if !person.isMe { + Button(person.isBlocked ? Strings.unblockText : Strings.blockText) { + if person.isBlocked { + peerListModel.unblockGeohashUser( + pubkeyHexLowercased: person.id, + displayName: person.displayName + ) + } else { + peerListModel.blockGeohashUser( + pubkeyHexLowercased: person.id, + displayName: person.displayName + ) + } + } + } + } } } // Seed and update order outside result builder @@ -119,4 +154,13 @@ struct GeohashPeopleList: View { } } } + + /// One spoken sentence per row: name, presence type, and block state. + private func accessibilityDescription(for person: GeohashPersonRow) -> String { + var parts: [String] = [person.displayName] + if person.isMe { parts.append(Strings.youState) } + parts.append(person.isTeleported ? Strings.teleported : Strings.nearby) + if person.isBlocked { parts.append(Strings.blockedState) } + return parts.joined(separator: ", ") + } } diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift new file mode 100644 index 00000000..4dff5c1d --- /dev/null +++ b/bitchat/Views/GroupChatList.swift @@ -0,0 +1,81 @@ +import BitFoundation +import SwiftUI + +/// Compact "groups" section for the people sheet: one row per private group +/// this device belongs to, tappable to open the group chat window. +struct GroupChatList: View { + @ThemedPalette private var palette + + let groups: [GroupChatRow] + let onTapGroup: (PeerID) -> Void + + private enum Strings { + static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list") + static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created") + static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") + static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat") + static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count") + } + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 0) { + // Same glyph+label header shape as #mesh / across the bridge. + PeopleSectionHeader( + icon: "person.3.fill", + iconColor: palette.primary, + title: Strings.header + ) + + ForEach(groups) { group in + HStack(spacing: 4) { + Text("#\(group.name)") + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + + if group.isCreator { + Image(systemName: "crown.fill") + .font(.bitchatSystem(size: 9)) + .foregroundColor(.yellow) + .help(Strings.creator) + } + + Spacer() + + if group.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { onTapGroup(group.peerID) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: group)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Strings.openGroupHint) + } + } + } + } + + private func accessibilityDescription(for group: GroupChatRow) -> String { + var parts: [String] = [ + group.name, + String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)") + ] + if group.isCreator { parts.append(Strings.creator) } + if group.hasUnread { parts.append(Strings.unread) } + return parts.joined(separator: ", ") + } +} diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index addc835c..fe916cf2 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -13,8 +13,6 @@ struct LocationChannelsSheet: View { @State private var customGeohash: String = "" @State private var customError: String? = nil - private var backgroundColor: Color { palette.background } - private enum Strings { static let title: LocalizedStringKey = "location_channels.title" static let description: LocalizedStringKey = "location_channels.description" @@ -22,15 +20,14 @@ struct LocationChannelsSheet: View { static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied" static let openSettings: LocalizedStringKey = "location_channels.action.open_settings" static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby" + static let grantToFind: LocalizedStringKey = "location_channels.grant_to_find" static let teleport: LocalizedStringKey = "location_channels.action.teleport" static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title" - static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" - static let torTitle: LocalizedStringKey = "location_channels.tor.title" - static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" - static let toggleOn: LocalizedStringKey = "common.toggle.on" - static let toggleOff: LocalizedStringKey = "common.toggle.off" static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") + static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") + static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel") + static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark") static func meshTitle(_ count: Int) -> String { let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") @@ -103,7 +100,7 @@ struct LocationChannelsSheet: View { } Text(Strings.description) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) Group { switch locationChannelsModel.permissionState { @@ -122,7 +119,7 @@ struct LocationChannelsSheet: View { VStack(alignment: .leading, spacing: 8) { Text(Strings.permissionDenied) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) Button(Strings.openSettings, action: SystemSettings.location.open) .buttonStyle(.plain) } @@ -169,13 +166,7 @@ struct LocationChannelsSheet: View { } private var closeButton: some View { - Button(action: { isPresented = false }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") + SheetCloseButton { isPresented = false } } private var channelList: some View { @@ -210,7 +201,10 @@ struct LocationChannelsSheet: View { } .buttonStyle(.plain) .padding(.leading, 8) - } + .accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark) + }, + accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark, + accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) } ) { locationChannelsModel.markTeleported(for: channel.geohash, false) locationChannelsModel.select(ChannelID.location(channel)) @@ -218,7 +212,7 @@ struct LocationChannelsSheet: View { } .padding(.vertical, 6) } - } else { + } else if locationChannelsModel.permissionState == .authorized { sectionDivider HStack(spacing: 8) { ProgressView() @@ -227,6 +221,15 @@ struct LocationChannelsSheet: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) + } else { + // No permission means no fix is coming: an honest hint + // beats a spinner that would never finish. + sectionDivider + Text(Strings.grantToFind) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) } sectionDivider @@ -240,22 +243,6 @@ struct LocationChannelsSheet: View { .padding(.vertical, 8) } - if locationChannelsModel.permissionState == .authorized { - sectionDivider - torToggleSection - .padding(.top, 12) - Button(action: SystemSettings.location.open) { - Text(Strings.removeAccess) - .bitchatFont(size: 12) - .foregroundColor(palette.alertRed) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.red.opacity(0.08)) - .cornerRadius(6) - } - .buttonStyle(.plain) - .padding(.vertical, 8) - } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) @@ -277,7 +264,7 @@ struct LocationChannelsSheet: View { HStack(spacing: 2) { Text(verbatim: "#") .bitchatFont(size: 14) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) TextField("geohash", text: $customGeohash) #if os(iOS) .textInputAutocapitalization(.never) @@ -319,7 +306,7 @@ struct LocationChannelsSheet: View { .bitchatFont(size: 14) .padding(.vertical, 6) .padding(.horizontal, 10) - .background(Color.secondary.opacity(0.12)) + .background(palette.secondary.opacity(0.12)) .cornerRadius(6) .opacity(isValid ? 1.0 : 0.4) .disabled(!isValid) @@ -336,7 +323,7 @@ struct LocationChannelsSheet: View { VStack(alignment: .leading, spacing: 8) { Text(Strings.bookmarked) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) LazyVStack(spacing: 0) { ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in let level = levelForLength(gh.count) @@ -357,7 +344,10 @@ struct LocationChannelsSheet: View { } .buttonStyle(.plain) .padding(.leading, 8) - } + .accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark) + }, + accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark, + accessoryAction: { locationChannelsModel.toggleBookmark(gh) } ) { let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh } if !inRegional && !locationChannelsModel.availableChannels.isEmpty { @@ -394,11 +384,13 @@ struct LocationChannelsSheet: View { title: String, subtitlePrefix: String, subtitleName: String? = nil, - subtitleNameBold: Bool = false, + subtitleNameBold _: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, + accessoryActionTitle: String? = nil, + accessoryAction: (() -> Void)? = nil, action: @escaping () -> Void ) -> some View { HStack(alignment: .center, spacing: 8) { @@ -409,17 +401,17 @@ struct LocationChannelsSheet: View { Text(parts.base) .bitchatFont(size: 14) .fontWeight(titleBold ? .bold : .regular) - .foregroundColor(titleColor ?? Color.primary) + .foregroundColor(titleColor ?? palette.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } } let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) Text(subtitleFull) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) .lineLimit(1) .truncationMode(.tail) } @@ -434,6 +426,19 @@ struct LocationChannelsSheet: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture(perform: action) + // The row is a plain HStack with a tap gesture, which VoiceOver reads + // as disconnected static text. Expose it as one activatable button; + // the visible bookmark accessory is mirrored as a named action. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))")) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton]) + .accessibilityHint(Strings.switchChannelHint) + .accessibilityAction(.default, action) + .accessibilityActions { + if let accessoryActionTitle, let accessoryAction { + Button(accessoryActionTitle, action: accessoryAction) + } + } } // Split a title like "#mesh [3 people]" into base and suffix "[3 people]" @@ -462,68 +467,14 @@ struct LocationChannelsSheet: View { } } -// MARK: - TOR Toggle & Standardized Colors +// MARK: - Standardized Colors +// (The tor and internet-gateway toggles moved to AppInfoView's Settings pane; +// IRCToggleStyle now lives in Views/Components.) extension LocationChannelsSheet { - private var torToggleBinding: Binding { - Binding( - get: { locationChannelsModel.userTorEnabled }, - set: { locationChannelsModel.setUserTorEnabled($0) } - ) - } - - private var torToggleSection: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: torToggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(Strings.torTitle) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(.primary) - Text(Strings.torSubtitle) - .bitchatFont(size: 11) - .foregroundColor(.secondary) - } - } - .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) - } - .padding(12) - .background(Color.secondary.opacity(0.12)) - .cornerRadius(8) - } - private var standardGreen: Color { palette.primary } private var standardBlue: Color { palette.accentBlue } } -private struct IRCToggleStyle: ToggleStyle { - let accent: Color - let onLabel: LocalizedStringKey - let offLabel: LocalizedStringKey - - func makeBody(configuration: Configuration) -> some View { - Button(action: { configuration.isOn.toggle() }) { - HStack(spacing: 12) { - configuration.label - Spacer() - Text(configuration.isOn ? onLabel : offLabel) - .textCase(.uppercase) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(configuration.isOn ? accent : .secondary) - .padding(.vertical, 4) - .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) - ) - } - } - .buttonStyle(.plain) - } -} - // MARK: - Coverage helpers extension LocationChannelsSheet { private func coverageString(forPrecision len: Int) -> String { diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift deleted file mode 100644 index 9c79f21e..00000000 --- a/bitchat/Views/LocationNotesView.swift +++ /dev/null @@ -1,311 +0,0 @@ -import SwiftUI - -struct LocationNotesView: View { - @StateObject private var manager: LocationNotesManager - let geohash: String - let senderNickname: String - let onNotesCountChanged: ((Int) -> Void)? - - @ThemedPalette private var palette - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @Environment(\.dismiss) private var dismiss - @State private var draft: String = "" - - init( - geohash: String, - senderNickname: String, - onNotesCountChanged: ((Int) -> Void)? = nil, - manager: LocationNotesManager? = nil - ) { - let gh = geohash.lowercased() - self.geohash = gh - self.senderNickname = senderNickname - self.onNotesCountChanged = onNotesCountChanged - _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) - } - - private var backgroundColor: Color { palette.background } - private var accentGreen: Color { palette.accent } - private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } - - private enum Strings { - static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons") - static let description: LocalizedStringKey = "location_notes.description" - static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" - static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" - static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" - static let retry: LocalizedStringKey = "location_notes.action.retry" - static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" - static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" - static let emptyTitle: LocalizedStringKey = "location_notes.empty_title" - static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle" - static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" - static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder" - } - - var body: some View { -#if os(macOS) - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 0) { - headerSection - notesContent - } - } - .themedSurface() - inputSection - } - .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) - .themedSheetBackground() - .onDisappear { manager.cancel() } - .onChange(of: geohash) { newValue in - manager.setGeohash(newValue) - } - .onAppear { onNotesCountChanged?(manager.notes.count) } - .onChange(of: manager.notes.count) { newValue in - onNotesCountChanged?(newValue) - } -#else - NavigationView { - VStack(spacing: 0) { - headerSection - ScrollView { - notesContent - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - inputSection - } - .themedSurface() - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(true) - #else - .navigationTitle("") - #endif - } - .themedSheetBackground() - .onDisappear { manager.cancel() } - .onChange(of: geohash) { newValue in - manager.setGeohash(newValue) - } - .onAppear { onNotesCountChanged?(manager.notes.count) } - .onChange(of: manager.notes.count) { newValue in - onNotesCountChanged?(newValue) - } -#endif - } - - private var closeButton: some View { - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel(Strings.closeAccessibility) - } - - private var headerSection: some View { - let count = manager.notes.count - return VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(headerTitle(for: count)) - .bitchatFont(size: 18) - Spacer() - closeButton - } - if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty { - Text(building) - .bitchatFont(size: 12) - .foregroundColor(accentGreen) - } else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty { - Text(block) - .bitchatFont(size: 12) - .foregroundColor(accentGreen) - } - Text(Strings.description) - .bitchatFont(size: 12) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - if manager.state == .noRelays { - Text(Strings.relaysPaused) - .bitchatFont(size: 11) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .themedSurface() - } - - private func headerTitle(for count: Int) -> String { - String( - format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), - locale: .current, - "\(geohash) ± 1", count - ) - } - - private var notesContent: some View { - LazyVStack(alignment: .leading, spacing: 12) { - if manager.state == .noRelays { - noRelaysRow - } else if manager.state == .loading && !manager.initialLoadComplete { - loadingRow - } else if manager.notes.isEmpty { - emptyRow - } else { - ForEach(manager.notes) { note in - noteRow(note) - } - } - - if let error = manager.errorMessage, manager.state != .noRelays { - errorRow(message: error) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - private func noteRow(_ note: LocationNotesManager.Note) -> some View { - let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName - let ts = timestampText(for: note.createdAt) - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(verbatim: "@\(baseName)") - .bitchatFont(size: 12, weight: .semibold) - if !ts.isEmpty { - Text(ts) - .bitchatFont(size: 11) - .foregroundColor(.secondary) - } - Spacer() - } - Text(note.content) - .bitchatFont(size: 14) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - } - - private var noRelaysRow: some View { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.noRelaysNearby) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.relaysRetryHint) - .bitchatFont(size: 12) - .foregroundColor(.secondary) - Button(Strings.retry) { manager.refresh() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.vertical, 6) - } - - private var loadingRow: some View { - HStack(spacing: 10) { - ProgressView() - Text(Strings.loadingNotes) - .bitchatFont(size: 12) - .foregroundColor(.secondary) - Spacer() - } - .padding(.vertical, 8) - } - - private var emptyRow: some View { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.emptyTitle) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.emptySubtitle) - .bitchatFont(size: 12) - .foregroundColor(.secondary) - } - .padding(.vertical, 6) - } - - private func errorRow(message: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .bitchatFont(size: 12) - Text(message) - .bitchatFont(size: 12) - Spacer() - } - Button(Strings.dismissError) { manager.clearError() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.vertical, 6) - } - - private var inputSection: some View { - HStack(alignment: .top, spacing: 10) { - TextField(Strings.addPlaceholder, text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .bitchatFont(size: 14) - .lineLimit(maxDraftLines, reservesSpace: true) - .padding(.vertical, 6) - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 20)) - .foregroundColor(sendButtonEnabled ? accentGreen : .secondary) - } - .padding(.top, 2) - .buttonStyle(.plain) - .disabled(!sendButtonEnabled) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .themedSurface() - .overlay(Divider(), alignment: .top) - } - - private func send() { - guard let content = draft.trimmedOrNilIfEmpty else { return } - manager.send(content: content, nickname: senderNickname) - draft = "" - } - - private var sendButtonEnabled: Bool { - !draft.trimmed.isEmpty && manager.state != .noRelays - } - - // MARK: - Timestamp Formatting - private func timestampText(for date: Date) -> String { - let now = Date() - if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" - return rel.isEmpty ? "" : "\(rel) ago" - } else { - let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) - let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter - return fmt.string(from: date) - } - } - - private static let relativeFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 - f.unitsStyle = .abbreviated - f.collapsesLargestUnit = true - return f - }() - - private static let absDateFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d") - return f - }() - - private static let absDateYearFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d, y") - return f - }() -} diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 5801888c..01b5d8cb 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage #endif struct BlockRevealImageView: View { + @ThemedPalette private var palette private let url: URL private let revealProgress: Double? private let isSending: Bool @@ -20,6 +21,25 @@ struct BlockRevealImageView: View { @State private var platformImage: PlatformImage? @State private var aspectRatio: CGFloat = 1 @State private var isBlurred: Bool = false + @State private var showDeleteConfirmation = false + @State private var loadFailed = false + + private enum Strings { + static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it") + static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen") + static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image") + static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image") + static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image") + static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image") + static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image") + static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image") + static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image") + static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image") + static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen") + static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending") + static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded") + static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send") + } init( url: URL, @@ -45,46 +65,19 @@ struct BlockRevealImageView: View { } var body: some View { - ZStack(alignment: .topTrailing) { - if let image = platformImage { - Image(platformImage: image) - .resizable() - .aspectRatio(aspectRatio, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - .mask( - BlockRevealMask( - fraction: fraction, - columns: 24, - rows: 16 - ) - .animation(.easeOut(duration: 0.2), value: fraction) - ) - .blur(radius: isBlurred ? 20 : 0) - .overlay { - if isBlurred { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.black.opacity(0.35)) - .overlay( - Image(systemName: "eye.slash.fill") - .font(.bitchatSystem(size: 24, weight: .semibold)) - .foregroundColor(.white.opacity(0.85)) - ) - } - } - } else { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.2)) - .frame(height: 200) - .overlay( - ProgressView() - .progressViewStyle(.circular) - ) - } - + // The DM sheet wraps the conversation in a high-priority + // swipe-to-close DragGesture (ContentSheetViews). An ancestor + // high-priority gesture starves descendant TapGestures, but Button + // actions still fire — the reveal/open tap must stay a Button or + // received DM images become untappable. + Button(action: handleTap) { + imageContent + } + .buttonStyle(.plain) + // The cancel control must sit outside the Button label: nested + // buttons don't get reliable independent hit testing, and the outer + // tap is a no-op while sending — the x could become untappable. + .overlay(alignment: .topTrailing) { if let onCancel = onCancel, isSending { Button(action: onCancel) { Image(systemName: "xmark") @@ -95,8 +88,10 @@ struct BlockRevealImageView: View { .padding(8) } .buttonStyle(.plain) + .accessibilityLabel(Strings.cancelSend) } } + .simultaneousGesture(hideSwipe) .onAppear { isBlurred = initiallyBlurred loadImage() @@ -105,41 +100,170 @@ struct BlockRevealImageView: View { isBlurred = initiallyBlurred loadImage() } - .gesture(mainGesture) + .contextMenu { + if isSending { + cancelSendAction + } else { + imageActions + } + } + .confirmationDialog( + Strings.deleteConfirmTitle, + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button(Strings.delete, role: .destructive) { + onDelete?() + } + Button("common.cancel", role: .cancel) {} + } message: { + Text(verbatim: Strings.deleteConfirmMessage) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabelText) + .accessibilityHint(accessibilityHintText) + .accessibilityAddTraits(isSending || loadFailed ? [] : .isButton) + .accessibilityActions { + if isSending { + // children: .ignore collapses the visible cancel button, so + // expose it as an action while the send is in flight. + cancelSendAction + } else { + imageActions + } + } + } + + @ViewBuilder + private var imageContent: some View { + if let image = platformImage { + Image(platformImage: image) + .resizable() + .aspectRatio(aspectRatio, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .mask( + BlockRevealMask( + fraction: fraction, + columns: 24, + rows: 16 + ) + .animation(.easeOut(duration: 0.2), value: fraction) + ) + .blur(radius: isBlurred ? 20 : 0) + .overlay { + if isBlurred { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.35)) + .overlay( + VStack(spacing: 6) { + Image(systemName: "eye.slash.fill") + .font(.bitchatSystem(size: 24, weight: .semibold)) + Text(verbatim: Strings.tapToReveal) + // Themed: monospaced under matrix, + // system under liquid glass. + .bitchatFont(size: 12, weight: .medium) + } + .foregroundColor(.white.opacity(0.85)) + ) + } + } + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(palette.secondary.opacity(0.2)) + .frame(height: 200) + .overlay { + if loadFailed { + Image(systemName: "photo") + .font(.bitchatSystem(size: 24, weight: .semibold)) + .foregroundColor(palette.secondary) + } else { + ProgressView() + .progressViewStyle(.circular) + } + } + } + } + + @ViewBuilder + private var cancelSendAction: some View { + if let onCancel { + Button(Strings.cancelSend, action: onCancel) + } + } + + @ViewBuilder + private var imageActions: some View { + // Open/reveal/hide would act on a file that failed to load, so only + // offer delete (when available) to let users clean up the attachment. + if !loadFailed { + if isBlurred { + Button(Strings.reveal) { + withAnimation(.easeOut(duration: 0.2)) { isBlurred = false } + } + } else { + Button(Strings.open) { onOpen?() } + Button(Strings.hide) { + withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true } + } + } + } + if onDelete != nil { + Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true } + } + } + + private var accessibilityLabelText: String { + if isSending { return Strings.sendingImage } + if loadFailed { return Strings.unavailableImage } + return isBlurred ? Strings.hiddenImage : Strings.revealedImage + } + + private var accessibilityHintText: String { + if isSending || loadFailed { return "" } + return isBlurred ? Strings.revealHint : Strings.openHint } private func loadImage() { + loadFailed = false DispatchQueue.global(qos: .userInitiated).async { #if os(iOS) - guard let image = UIImage(contentsOfFile: url.path) else { return } + let image = UIImage(contentsOfFile: url.path) #else - guard let image = NSImage(contentsOf: url) else { return } + let image = NSImage(contentsOf: url) #endif - let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1 DispatchQueue.main.async { + guard let image else { + self.loadFailed = true + return + } self.platformImage = image - self.aspectRatio = ratio + self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1 } } } - private var mainGesture: some Gesture { - let doubleTap = TapGesture(count: 2).onEnded { - guard !isSending else { return } - onDelete?() - } - let singleTap = TapGesture().onEnded { - guard !isSending else { return } - if isBlurred { - withAnimation(.easeOut(duration: 0.2)) { - isBlurred = false - } - } else { - onOpen?() + // Double-tap used to permanently delete the image — the most ingrained + // photo gesture on mobile, racing the reveal tap, with no confirmation + // and no way to get the file back. Delete now lives in the context menu + // behind a confirmation; taps only reveal and open. + private func handleTap() { + guard !isSending, !loadFailed else { return } + if isBlurred { + withAnimation(.easeOut(duration: 0.2)) { + isBlurred = false } + } else { + onOpen?() } - let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in - guard !isSending else { return } + } + + private var hideSwipe: some Gesture { + DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in + guard !isSending, !loadFailed else { return } let horizontal = value.translation.width let vertical = value.translation.height guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return } @@ -149,7 +273,6 @@ struct BlockRevealImageView: View { } } } - return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe) } } diff --git a/bitchat/Views/Media/LiveVoiceBadge.swift b/bitchat/Views/Media/LiveVoiceBadge.swift new file mode 100644 index 00000000..709a170b --- /dev/null +++ b/bitchat/Views/Media/LiveVoiceBadge.swift @@ -0,0 +1,51 @@ +// +// LiveVoiceBadge.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Slow opacity pulse for live-voice indicators (composer HUD, bubble badge). +struct PulsingOpacityModifier: ViewModifier { + let active: Bool + @State private var dimmed = false + + func body(content: Content) -> some View { + content + .opacity(active && dimmed ? 0.35 : 1) + .animation(active ? .easeInOut(duration: 0.7).repeatForever(autoreverses: true) : .default, value: dimmed) + .onAppear { + if active { dimmed = true } + } + .onChange(of: active) { nowActive in + dimmed = nowActive + } + } +} + +/// The red pulsing "LIVE" chip shown on a voice bubble while its burst is +/// still streaming in. +struct LiveVoiceBadge: View { + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(Color.red) + .frame(width: 6, height: 6) + Text("media.voice.live_badge", comment: "Badge on a voice message that is currently streaming in live") + .bitchatFont(size: 10, weight: .bold) + .foregroundColor(.red) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Capsule().fill(Color.red.opacity(0.15)) + ) + .modifier(PulsingOpacityModifier(active: true)) + .accessibilityLabel( + String(localized: "media.voice.accessibility.live", comment: "Accessibility label announcing a live incoming voice message") + ) + } +} diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 8bee3bd2..2b12ee24 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -11,6 +11,7 @@ import BitFoundation struct MediaMessageView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.appTheme) private var theme + @ThemedPalette private var palette @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage let media: BitchatMessage.Media @@ -20,6 +21,7 @@ struct MediaMessageView: View { /// fields by identity, so without the snapshot a status-only change /// (send progress, delivered → read) would not re-render this row. private let deliveryStatus: DeliveryStatus? + @State private var showDeliveryDetail = false @Binding var imagePreviewURL: URL? @@ -31,53 +33,110 @@ struct MediaMessageView: View { } var body: some View { - let state = mediaSendState(for: deliveryStatus) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) + let state = mediaSendState(for: deliveryStatus, isFromMe: isFromMe) let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .center, spacing: 4) { - Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + // Baseline alignment (via the header text inside the VStack) keeps the + // lock on the header line; a fixed top padding left its solid body + // hanging below the line's visual center. + HStack(alignment: .firstTextBaseline, spacing: 0) { + if message.isPrivate { + Image(systemName: "lock.fill") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.orange.opacity(0.75)) + .padding(.trailing, 4) + .accessibilityHidden(true) + } + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 4) { + Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + // Delivery status indicator for private messages. Tappable: + // .help() tooltips only exist on macOS, so iOS users get the + // explanation as a caption under the row instead. + if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), + let status = deliveryStatus { + Button { + showDeliveryDetail.toggle() + } label: { + DeliveryStatusView(status: status) + .padding(.leading, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint( + String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") + ) + } + } + + // Failure reasons stay visible without a tap; other statuses + // reveal on demand. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), let status = deliveryStatus { - DeliveryStatusView(status: status) - .padding(.leading, 4) + if case .failed = status { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(Color.red.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } else if showDeliveryDetail { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } } - } - Group { - switch media { - case .voice(let url): - VoiceNoteView( - url: url, - isSending: state.isSending, - sendProgress: state.progress, - onCancel: cancelAction - ) - case .image(let url): - BlockRevealImageView( - url: url, - revealProgress: state.progress, - isSending: state.isSending, - onCancel: cancelAction, - initiallyBlurred: !isFromMe, - onOpen: { - if !state.isSending { - imagePreviewURL = url - } - }, - onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil - ) - .frame(maxWidth: 280) + Group { + switch media { + case .voice(let url): + VoiceNoteView( + url: url, + isSending: state.isSending, + sendProgress: state.progress, + isLive: conversationUIModel.isLiveVoiceMessage(message), + onCancel: cancelAction + ) + case .image(let url): + BlockRevealImageView( + url: url, + revealProgress: state.progress, + isSending: state.isSending, + onCancel: cancelAction, + initiallyBlurred: !isFromMe, + onOpen: { + if !state.isSending { + imagePreviewURL = url + } + }, + onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil + ) + .frame(maxWidth: 280) + } } } } .padding(.vertical, 4) + // Collapse the revealed caption when the status advances (e.g. + // sending → sent → delivered) so a detail opened for one state + // doesn't linger and silently morph into another. Guarded write: + // under a message storm many rows change status within one frame, + // and an unconditional state write per change trips SwiftUI's + // "tried to update multiple times per frame" re-entrancy warning. + .onChange(of: deliveryStatus) { _ in + if showDeliveryDetail { + showDeliveryDetail = false + } + } } - private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + // A received message is never in a send state: BitchatMessage defaults + // private messages to .sending, so an incoming message's status must + // not drive the reveal mask or disable the reveal tap. + guard isFromMe else { return (false, nil, false) } var isSending = false var progress: Double? if let status = deliveryStatus { @@ -90,7 +149,7 @@ struct MediaMessageView: View { isSending = true progress = Double(reached) / Double(total) } - case .sent, .read, .delivered, .failed: + case .sent, .carried, .read, .delivered, .failed: break } } diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index 9b18e498..661d2193 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -5,6 +5,7 @@ struct VoiceNoteView: View { private let url: URL private let isSending: Bool private let sendProgress: Double? + private let isLive: Bool private let onCancel: (() -> Void)? @Environment(\.colorScheme) private var colorScheme @@ -12,10 +13,11 @@ struct VoiceNoteView: View { @StateObject private var playback: VoiceNotePlaybackController @State private var waveform: [Float] = [] - init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) { + init(url: URL, isSending: Bool, sendProgress: Double?, isLive: Bool = false, onCancel: (() -> Void)?) { self.url = url self.isSending = isSending self.sendProgress = sendProgress + self.isLive = isLive self.onCancel = onCancel _playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url)) } @@ -28,7 +30,9 @@ struct VoiceNoteView: View { } private var backgroundColor: Color { - colorScheme == .dark ? Color.black.opacity(0.6) : Color.white + // Palette-based and slightly translucent so the card doesn't sit as + // an opaque white/black box over the glass gradient. + palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7) } private var borderColor: Color { @@ -50,6 +54,12 @@ struct VoiceNoteView: View { .background(Circle().fill(palette.accent)) } .buttonStyle(.plain) + .accessibilityLabel( + playback.isPlaying + ? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback") + : String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note") + ) + .accessibilityValue(playbackLabel) WaveformView( samples: samples, @@ -61,9 +71,13 @@ struct VoiceNoteView: View { isInteractive: playback.isPlaying ) - Text(playbackLabel) - .bitchatFont(size: 13) - .foregroundColor(Color.secondary) + if isLive { + LiveVoiceBadge() + } else { + Text(playbackLabel) + .bitchatFont(size: 13) + .foregroundColor(palette.secondary) + } if let onCancel = onCancel, isSending { Button(action: onCancel) { @@ -74,6 +88,9 @@ struct VoiceNoteView: View { .foregroundColor(.white) } .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send") + ) } } .padding(12) diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift index c925a1d5..b12ec6ee 100644 --- a/bitchat/Views/Media/WaveformView.swift +++ b/bitchat/Views/Media/WaveformView.swift @@ -42,7 +42,7 @@ struct WaveformView: View { } else if let send = clampedSend, binPosition <= send { color = palette.accentBlue } else { - color = Color.gray.opacity(0.35) + color = palette.secondary.opacity(0.35) } context.fill(Path(rect), with: .color(color)) } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 42902202..1d94af5f 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -7,6 +7,9 @@ struct MeshPeerList: View { let onTapPeer: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void let onShowFingerprint: (PeerID) -> Void + /// Optional so existing call sites (and previews/tests) keep compiling; + /// when absent the block/unblock context-menu entry is hidden. + var onToggleBlock: ((MeshPeerRow) -> Void)? = nil @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] @@ -15,6 +18,22 @@ struct MeshPeerList: View { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator") static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") + static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator") + static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator") + static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator") + static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable") + static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer") + static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") + static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") + static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified") + static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer") + static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") + static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") + static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen") + static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat") + static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person") + static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person") + static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person") } var body: some View { @@ -25,13 +44,14 @@ struct MeshPeerList: View { } if peerListModel.meshRows.isEmpty { - VStack(alignment: .leading, spacing: 0) { - Text(Strings.noneNearby) - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - .padding(.horizontal) - .padding(.top, 12) - } + // Match the section's row rhythm (same size, indent, and vertical + // padding as a peer row) so the empty state reads as the list's + // only line, not a floating caption. + Text(Strings.noneNearby) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .padding(.horizontal) + .padding(.vertical, 4) } else { VStack(alignment: .leading, spacing: 0) { ForEach(0.. String { + var parts: [String] = [peer.displayName] + if !peer.isMe { + if peer.isConnected { + parts.append(Strings.connected) + } else if peer.isReachable { + parts.append(Strings.reachable) + } else if peer.isMutualFavorite { + parts.append(Strings.nostr) + } else { + parts.append(Strings.offline) + } + } + if peer.showsVouchedBadge { parts.append(Strings.vouched) } + if peer.isFavorite { parts.append(Strings.favorite) } + if peer.hasUnread { parts.append(Strings.unread) } + if peer.isBlocked { parts.append(Strings.blocked) } + return parts.joined(separator: ", ") + } } diff --git a/bitchat/Views/MeshTopologyView.swift b/bitchat/Views/MeshTopologyView.swift new file mode 100644 index 00000000..a8bc4e3e --- /dev/null +++ b/bitchat/Views/MeshTopologyView.swift @@ -0,0 +1,217 @@ +// +// MeshTopologyView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Display model for the mesh topology map: nodes are known mesh peers, +/// edges are gossiped `directNeighbors` claims. Built on the main actor from +/// a `MeshTopologySnapshot` plus the current nickname table. +struct MeshTopologyDisplayModel { + struct Node: Identifiable, Equatable { + let id: String + let label: String + let isSelf: Bool + } + + let nodes: [Node] + /// Pairs of `Node.id`; every id is present in `nodes`. + let edges: [(String, String)] + + static let empty = MeshTopologyDisplayModel(nodes: [], edges: []) +} + +/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in +/// the center), drawn with Canvas so it stays cheap at any peer count. +struct MeshTopologyView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var appTheme + @ThemedPalette private var palette + + /// Fetches a fresh model; called on appear and on manual refresh. + let provider: @MainActor () -> MeshTopologyDisplayModel + @State private var model: MeshTopologyDisplayModel = .empty + + var body: some View { + #if os(macOS) + VStack(spacing: 0) { + HStack { + Text("topology.title") + .bitchatFont(size: 16, weight: .bold) + .foregroundColor(palette.primary) + Spacer() + refreshButton + Button("app_info.done") { + dismiss() + } + .buttonStyle(.plain) + .foregroundColor(palette.primary) + } + .padding() + .themedSurface(opacity: 0.95) + + content + } + .frame(width: 500, height: 520) + .themedSheetBackground() + #else + NavigationView { + content + .themedSheetBackground() + .navigationTitle(Text("topology.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + refreshButton + } + ToolbarItem(placement: .navigationBarTrailing) { + SheetCloseButton { dismiss() } + .foregroundColor(palette.primary) + } + } + } + #endif + } + + private var refreshButton: some View { + Button { + model = provider() + } label: { + Image(systemName: "arrow.clockwise") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("topology.refresh")) + } + + @ViewBuilder + private var content: some View { + VStack(spacing: 12) { + if model.nodes.count <= 1 { + Spacer() + Text("topology.empty") + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Spacer() + } else { + graphCanvas + .padding(.horizontal, 8) + } + + VStack(spacing: 4) { + Text(summaryText) + .bitchatFont(size: 13, weight: .semibold) + .foregroundColor(palette.primary) + Text("topology.caption") + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + .onAppear { model = provider() } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(summaryText)) + } + + private var summaryText: String { + String( + format: String( + localized: "topology.summary", + comment: "Topology map summary: number of peers and links" + ), + locale: .current, + model.nodes.count, + model.edges.count + ) + } + + private var graphCanvas: some View { + Canvas { context, size in + let positions = Self.layout(nodes: model.nodes, in: size) + let fontDesign = appTheme.bodyFontDesign + + // Edges first so nodes draw on top. + for (fromID, toID) in model.edges { + guard let from = positions[fromID], let to = positions[toID] else { continue } + var path = Path() + path.move(to: from) + path.addLine(to: to) + context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1) + } + + for node in model.nodes { + guard let center = positions[node.id] else { continue } + let radius: CGFloat = node.isSelf ? 7 : 5 + let dot = Path(ellipseIn: CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + )) + context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary)) + if node.isSelf { + let ring = Path(ellipseIn: CGRect( + x: center.x - radius - 3, + y: center.y - radius - 3, + width: (radius + 3) * 2, + height: (radius + 3) * 2 + )) + context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1) + } + context.draw( + Text(node.label) + .font(.system(size: 10, design: fontDesign)) + .foregroundColor(node.isSelf ? palette.accent : palette.secondary), + at: CGPoint(x: center.x, y: center.y + radius + 4), + anchor: .top + ) + } + } + .accessibilityHidden(true) // The combined summary label narrates the graph. + } + + /// Circular layout: self in the center, everyone else evenly spaced on a + /// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle. + static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + // Leave room for the label row under each ring node. + let radius = max(20, min(size.width, size.height) / 2 - 36) + var positions: [String: CGPoint] = [:] + + let ringNodes = nodes.filter { !$0.isSelf } + for node in nodes where node.isSelf { + positions[node.id] = center + } + for (index, node) in ringNodes.enumerated() { + let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2 + positions[node.id] = CGPoint( + x: center.x + radius * cos(angle), + y: center.y + radius * sin(angle) + ) + } + return positions + } +} + +#Preview("Topology") { + MeshTopologyView(provider: { + MeshTopologyDisplayModel( + nodes: [ + .init(id: "self", label: "me", isSelf: true), + .init(id: "a", label: "alice", isSelf: false), + .init(id: "b", label: "bob", isSelf: false), + .init(id: "c", label: "carol", isSelf: false) + ], + edges: [("self", "a"), ("a", "b"), ("self", "c")] + ) + }) +} diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index c8837983..2742aebd 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -19,6 +19,8 @@ struct MessageListView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var appChromeModel: AppChromeModel + @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared @Environment(\.colorScheme) private var colorScheme @Environment(\.appTheme) private var theme @@ -36,8 +38,20 @@ struct MessageListView: View { var isTextFieldFocused: FocusState.Binding @State private var showMessageActions = false + @State private var showClearConfirmation = false @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? + @State private var unseenCount = 0 + @State private var lastSeenMessageCount = 0 + /// Context key the unseen counters were baselined against. Channel + /// switches swap the timeline wholesale, so a count delta is only a + /// "new messages" signal while the context is unchanged. + @State private var unseenBaselineKey = "" + /// Whether this instance holds the nearby-notes counter active (mesh + /// public timeline only); balanced against activate/deactivate. + @State private var holdsNotesCounter = false + + @ThemedPalette private var palette var body: some View { let currentWindowCount: Int = { @@ -63,8 +77,20 @@ struct MessageListView: View { return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message) } + VStack(spacing: 0) { + // Notes pinned to this place stay visible while chatting — a + // conversation starting must not hide what's left here. + if privatePeer == nil, + case .mesh = locationChannelsModel.selectedChannel, + nearbyNotes.noteCount > 0 { + notesHereStrip + } + GeometryReader { geometry in ScrollViewReader { proxy in ScrollView { + if messageItems.isEmpty && privatePeer == nil { + publicEmptyState(fillHeight: geometry.size.height) + } LazyVStack(alignment: .leading, spacing: 0) { ForEach(messageItems) { item in let message = item.message @@ -72,6 +98,7 @@ struct MessageListView: View { .onAppear { if message.id == windowedMessages.last?.id { isAtBottom = true + unseenCount = 0 } if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { @@ -89,13 +116,32 @@ struct MessageListView: View { } } .contentShape(Rectangle()) - .onTapGesture { - if message.sender != "system" { - messageText = "@\(message.sender) " - isTextFieldFocused.wrappedValue = true - } - } .contextMenu { + let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message) + if showsUserActions { + // Mention and DM are redundant inside a 1:1 conversation: + // mentioning the only other participant is noise, and "DM" + // would just reopen the conversation that is already open. + if privatePeer == nil { + Button("content.actions.mention") { + insertMention(message.sender) + } + if let peerID = message.senderPeerID { + Button("content.actions.direct_message") { + privateConversationModel.openConversation(for: peerID) + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + showSidebar = true + } + } + } + } + Button("content.actions.hug") { + conversationUIModel.sendHug(to: message.sender) + } + Button("content.actions.slap") { + conversationUIModel.sendSlap(to: message.sender) + } + } Button("content.message.copy") { #if os(iOS) UIPasteboard.general.string = message.content @@ -105,17 +151,55 @@ struct MessageListView: View { pb.setString(message.content, forType: .string) #endif } + if isResendableFailedMessage(message) { + Button("content.actions.resend") { + conversationUIModel.resendFailedPrivateMessage(message) + } + } + if showsUserActions { + Button("content.actions.block", role: .destructive) { + conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender) + } + } } .padding(.horizontal, 12) .padding(.vertical, 1) + // Archived echoes read as one tinted block, not + // just faded rows. + .background(message.isArchivedEcho ? palette.secondary.opacity(0.08) : Color.clear) } } .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 2) + + // Only carried history on screen: the ambient layer (radar, + // sightings, live hints) stays visible below it instead of + // vanishing the moment echoes exist. + if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) { + MeshEmptyStateView(compact: true) + .padding(.horizontal, 12) + .padding(.top, 20) + .padding(.bottom, 8) + } + } + .overlay(alignment: .bottomTrailing) { + if !isAtBottom && !messageItems.isEmpty { + jumpToLatestPill(proxy: proxy) + } } .onOpenURL(perform: handleOpenURL) .onTapGesture(count: 3) { - conversationUIModel.clearCurrentConversation() + showClearConfirmation = true + } + .confirmationDialog( + "content.clear.confirm_title", + isPresented: $showClearConfirmation, + titleVisibility: .visible + ) { + Button("content.clear.confirm_action", role: .destructive) { + conversationUIModel.clearCurrentConversation() + } + Button("common.cancel", role: .cancel) {} } .onAppear { scrollToBottom(on: proxy) @@ -139,9 +223,7 @@ struct MessageListView: View { ) { Button("content.actions.mention") { if let sender = selectedMessageSender { - // Pre-fill the input with an @mention and focus the field - messageText = "@\(sender) " - isTextFieldFocused.wrappedValue = true + insertMention(sender) } } @@ -191,6 +273,12 @@ struct MessageListView: View { scrollThrottleTimer?.invalidate() } } + } + } + .onAppear { updateNotesCounterHold() } + .onDisappear { releaseNotesCounterHold() } + .onChange(of: locationChannelsModel.selectedChannel) { _ in updateNotesCounterHold() } + .onChange(of: privatePeer) { _ in updateNotesCounterHold() } .environment(\.openURL, OpenURLAction { url in // Intercept custom cashu: links created in attributed text if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" { @@ -208,6 +296,203 @@ struct MessageListView: View { } private extension MessageListView { + var currentContextKey: String { + if let peer = privatePeer { + return "dm:\(peer)" + } + return locationChannelsModel.selectedChannel.contextKey + } + + /// Tappable strip above the mesh timeline while notes are pinned at this + /// place: opens the notices sheet on the geo tab. + var notesHereStrip: some View { + let text: String = nearbyNotes.noteCount == 1 + ? String(localized: "content.empty.notes_one", comment: "Hint when exactly one note was left at this place") + : String( + format: String(localized: "content.empty.notes_many", comment: "Hint counting notes left at this place"), + locale: .current, + nearbyNotes.noteCount + ) + + return Button { + appChromeModel.presentNotices(geoTab: true) + } label: { + HStack(spacing: 6) { + Text(verbatim: "📍 \(text)") + .bitchatFont(size: 12) + .foregroundColor(palette.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.bitchatSystem(size: 10)) + .foregroundColor(palette.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(palette.secondary.opacity(0.08)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + /// The nearby-notes counter is held whenever the mesh public timeline is + /// showing — the strip needs a live count before it can decide to exist. + /// Holding is not subscribing: nothing hits the relays until an explicit + /// act reveals the counter (tap-to-reveal). + func updateNotesCounterHold() { + let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh + guard shouldHold != holdsNotesCounter else { return } + holdsNotesCounter = shouldHold + if shouldHold { + NearbyNotesCounter.shared.activate() + } else { + NearbyNotesCounter.shared.deactivate() + } + } + + func releaseNotesCounterHold() { + guard holdsNotesCounter else { return } + holdsNotesCounter = false + NearbyNotesCounter.shared.deactivate() + } + + /// True when the mesh timeline holds nothing but archived echoes and + /// system lines — no live conversation yet, so the ambient layer still + /// applies. + private func showsAmbientFooter(messageItems: [MessageDisplayItem]) -> Bool { + guard case .mesh = locationChannelsModel.selectedChannel, + !messageItems.isEmpty else { return false } + return messageItems.allSatisfy { $0.message.isArchivedEcho || $0.message.sender == "system" } + } + + /// Terminal-styled narration for an empty public timeline: says which + /// channel this is, that the app is waiting for peers, and where to go + /// next. Rendered inside the ScrollView; disappears with the first row. + /// The mesh case fills the visible chat height so its radar can center + /// in the space below the text. + func publicEmptyState(fillHeight: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 6) { + switch locationChannelsModel.selectedChannel { + case .mesh: + MeshEmptyStateView(fillHeight: max(0, fillHeight - 24)) + case .location(let channel): + emptyStateLine( + String( + format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"), + locale: .current, + channel.geohash + ) + ) + emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) + } + } + .padding(.horizontal, 12) + .padding(.top, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + + func emptyStateLine(_ text: String) -> some View { + // Non-breaking space before the closing asterisk so a tight wrap + // can't orphan a lone "*" onto its own line. + Text(verbatim: "* \(text)\u{00A0}*") + .bitchatFont(size: 13) + .foregroundColor(palette.secondary.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } + + /// Messages the unseen counters may book as "new": rows that render as + /// human messages. System lines render as narration and whitespace-only + /// content never renders at all, so neither belongs in the pill count. + func unseenEligibleCount(in messages: [BitchatMessage]) -> Int { + messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count + } + + /// Updates the unseen-count baseline for the current context and returns + /// how many messages were appended since the last observation. A context + /// change (timeline swapped wholesale) re-baselines and reports zero, so + /// cross-channel count differences are never booked as "new" messages. + func rebaselinedAppendedCount(newCount: Int) -> Int { + let key = currentContextKey + if unseenBaselineKey != key { + unseenBaselineKey = key + unseenCount = 0 + lastSeenMessageCount = newCount + return 0 + } + let appended = max(0, newCount - lastSeenMessageCount) + lastSeenMessageCount = newCount + return appended + } + + /// A failed private text message of our own can be resent through the + /// normal send path (the context menu removes the failed original and + /// re-submits its content). + func isResendableFailedMessage(_ message: BitchatMessage) -> Bool { + guard message.isPrivate, + conversationUIModel.isSentByCurrentUser(message), + conversationUIModel.mediaAttachment(for: message) == nil, + case .some(.failed) = message.deliveryStatus + else { return false } + return true + } + + /// Appends an @mention to the composer draft (never overwrites what the + /// user has already typed) and focuses the input field. + func insertMention(_ sender: String) { + let mention = "@\(sender) " + if messageText.isEmpty { + messageText = mention + } else if messageText.hasSuffix(" ") { + messageText += mention + } else { + messageText += " " + mention + } + isTextFieldFocused.wrappedValue = true + } + + /// Floating pill shown while scrolled up: re-presents the isAtBottom / + /// unseenCount state the view already tracks, and jumps to the newest + /// message via the existing scrollToBottom helper. + func jumpToLatestPill(proxy: ScrollViewProxy) -> some View { + Button { + scrollToBottom(on: proxy) + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.down") + .font(.bitchatSystem(size: 11, weight: .semibold)) + if unseenCount > 0 { + Text( + String( + format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), + locale: .current, + unseenCount + ) + ) + .bitchatFont(size: 12, weight: .medium) + } + } + .foregroundColor(palette.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .themedOverlayPanel() + .padding(.trailing, 12) + .padding(.bottom, 10) + .accessibilityLabel(jumpToLatestAccessibilityLabel) + } + + var jumpToLatestAccessibilityLabel: String { + let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button") + guard unseenCount > 0 else { return base } + let count = String( + format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), + locale: .current, + unseenCount + ) + return "\(base), \(count)" + } + @ViewBuilder func messageRow(for message: BitchatMessage) -> some View { Group { @@ -219,6 +504,9 @@ private extension MessageListView { TextMessageView(message: message) } } + // Archived echoes ("heard here earlier") render dimmed: real history, + // visually distinct from the live conversation. + .opacity(message.isArchivedEcho ? 0.55 : 1) } @ViewBuilder @@ -294,6 +582,9 @@ private extension MessageListView { func scrollToBottom(on proxy: ScrollViewProxy) { isAtBottom = true + unseenCount = 0 + lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer)) + unseenBaselineKey = currentContextKey if let targetPeerID { proxy.scrollTo(targetPeerID, anchor: .bottom) } @@ -316,15 +607,23 @@ private extension MessageListView { } func onMessagesChange(proxy: ScrollViewProxy) { + guard privatePeer == nil else { return } let messages = publicChatModel.messages - guard privatePeer == nil, let lastMsg = messages.last else { return } + let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) + guard let lastMsg = messages.last else { + // Timeline emptied (e.g. /clear): nothing below to jump to. + unseenCount = 0 + return + } // If the newest message is from me, always scroll to bottom let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom + unseenCount += appendedCount return } else { // Ensure we consider ourselves at bottom for subsequent messages isAtBottom = true + unseenCount = 0 } func scrollIfNeeded(date: Date) { @@ -352,18 +651,23 @@ private extension MessageListView { } func onPrivateChatsChange(proxy: ScrollViewProxy) { - guard let peerID = privatePeer, - let lastMsg = privateInboxModel.messages(for: peerID).last else { + guard let peerID = privatePeer else { return } + let messages = privateInboxModel.messages(for: peerID) + let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) + guard let lastMsg = messages.last else { + // Timeline emptied (e.g. /clear): nothing below to jump to. + unseenCount = 0 return } - let messages = privateInboxModel.messages(for: peerID) // If the newest private message is from me, always scroll let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom + unseenCount += appendedCount return } else { isAtBottom = true + unseenCount = 0 } func scrollIfNeeded(date: Date) { @@ -391,17 +695,27 @@ private extension MessageListView { func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) { // When switching to a new geohash channel, scroll to the bottom guard privatePeer == nil else { return } + // Invalidate the unseen baseline: the timeline is about to swap (or + // already has — the ordering of this onChange vs the count onChange + // is not guaranteed), so the next count observation re-baselines + // instead of booking the cross-channel difference as "new". + unseenCount = 0 + unseenBaselineKey = "" + // Entering any public channel shows its latest messages: a channel + // switch swaps the timeline wholesale, so the prior scroll offset is + // meaningless. Landing at the bottom keeps isAtBottom honest (no + // stale jump-to-latest pill) and matches standard chat behavior. + isAtBottom = true + windowCountPublic = TransportConfig.uiWindowInitialCountPublic + let contextKey: String switch channel { case .mesh: - break + contextKey = "mesh" case .location(let ch): - // Reset window size - isAtBottom = true - windowCountPublic = TransportConfig.uiWindowInitialCountPublic - let contextKey = "geo:\(ch.geohash)" - if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { - proxy.scrollTo(target, anchor: .bottom) - } + contextKey = "geo:\(ch.geohash)" + } + if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { + proxy.scrollTo(target, anchor: .bottom) } } @@ -426,6 +740,6 @@ private extension ChannelID { } } -//#Preview { +// #Preview { // MessageListView() -//} +// } diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index bd653ec9..c684d3f1 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -21,17 +21,20 @@ extension String { return current >= threshold } - // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. + // Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare + // bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI + // form matches too — the token embedded after the scheme is the match. func extractCashuLinks(max: Int = 3) -> [String] { let regex = MessageFormattingEngine.Patterns.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] - for m in regex.matches(in: self, range: range) { - if m.numberOfRanges > 0 { - let token = ns.substring(with: m.range(at: 0)) - let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token - found.append("cashu:\(enc)") + for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 { + let token = ns.substring(with: m.range(at: 0)) + // Dedup: repeated tokens are one bearer instrument (and duplicate + // ForEach IDs) — one chip is enough. + if !found.contains(token) { + found.append(token) if found.count >= max { break } } } diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift new file mode 100644 index 00000000..51edd184 --- /dev/null +++ b/bitchat/Views/NoticesView.swift @@ -0,0 +1,828 @@ +// +// NoticesView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// The unified notices sheet behind the header's pin icon: one place for +/// everything pinned around you, with a scope toggle. +/// +/// - geo: the current geohash's notices — mesh-synced board posts merged and +/// deduped with Nostr kind-1 location notes, so you also see notices from +/// people who aren't on your mesh. +/// - mesh: the mesh-local board only (empty geohash, fully offline). +struct NoticesView: View { + enum Tab: Hashable { + case geo + case mesh + } + + let senderNickname: String + @ObservedObject var board: BoardManager + + @ThemedPalette private var palette + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @State private var tab: Tab + @State private var draft: String = "" + @State private var urgent = false + /// Days until the notice fades; `permanentExpiry` (geo default) means no + /// NIP-40 tag — the note stays until its relay drops it. + @State private var expiryDays: Int + /// Mirrors the app-info kill switch so its notification produces an + /// immediate presentation update as well as tearing down subscriptions. + @State private var locationNotesEnabled = LocationNotesSettings.enabled + + /// Sentinel picker tag for the ∞ option (geo tab only). + private static let permanentExpiry = 0 + + /// Injected notes manager for tests; live use derives one per geohash. + private let notesManager: LocationNotesManager? + /// Pooled manager held by the sheet so the composer can post pure Nostr + /// notes (∞ expiry has no mesh-board copy) and the list can render them. + /// Acquired from `LocationNotesPool` (shared with the nearby-notes + /// counter, one REQ per geohash) and released on dismissal. + @State private var liveGeoManager: LocationNotesManager? + /// Tracks only this sheet's high-accuracy refresh ownership, so repeated + /// Combine/SwiftUI invalidations do not restart CoreLocation and a + /// revocation or kill-switch transition balances the begin call once. + @State private var ownsLiveGeoRefresh = false + + init( + senderNickname: String, + board: BoardManager, + initialTab: Tab, + notesManager: LocationNotesManager? = nil + ) { + self.senderNickname = senderNickname + self.board = board + self.notesManager = notesManager + _tab = State(initialValue: initialTab) + _expiryDays = State(initialValue: initialTab == .geo ? Self.permanentExpiry : 7) + } + + private var activeNotesManager: LocationNotesManager? { + notesManager ?? liveGeoManager + } + + /// The one explicit act inside the sheet that unlocks the passive + /// nearby-notes counter: the person actively picking the geo segment + /// while the sheet has a geo scope. Landing on the geo tab via the + /// sheet's initial selection (auto-derived from the current channel — + /// e.g. browsing a remote geohash) is not an act toward the LOCAL + /// building cell and must not reveal it. + static func revealsNearbyNotes(onSwitchingTo tab: Tab, geoGeohash: String?) -> Bool { + tab == .geo && geoGeohash != nil + } + + struct GeoSessionState { + let manager: LocationNotesManager? + let ownsLiveRefresh: Bool + } + + enum GeoPresentationState: Equatable { + case disabled + case locationUnavailable + case available(String) + } + + static func geoPresentationState(notesEnabled: Bool, geohash: String?) -> GeoPresentationState { + guard notesEnabled else { return .disabled } + guard let geohash else { return .locationUnavailable } + return .available(geohash) + } + + static func composerGeohash(tab: Tab, notesEnabled: Bool, geoGeohash: String?) -> String? { + switch tab { + case .geo: + guard case .available(let geohash) = geoPresentationState( + notesEnabled: notesEnabled, + geohash: geoGeohash + ) else { + return nil + } + return geohash + case .mesh: + return "" + } + } + + /// Reconciles both privacy-sensitive resources owned by the sheet: the + /// high-accuracy CoreLocation refresh and the precise notes REQ. Kept as + /// a callback-driven function so permission and kill-switch transitions + /// can be regression tested without presenting SwiftUI. + @MainActor + static func reconcileGeoSession( + tab: Tab, + needsDeviceLocation: Bool, + permissionState: LocationChannelManager.PermissionState, + notesEnabled: Bool, + geohash: String?, + manager: LocationNotesManager?, + ownsLiveRefresh: Bool, + beginLiveRefresh: () -> Void, + endLiveRefresh: () -> Void, + acquire: (String) -> LocationNotesManager, + release: (LocationNotesManager?) -> Void + ) -> GeoSessionState { + let geoTabActive = tab == .geo && notesEnabled + let wantsLiveRefresh = geoTabActive && needsDeviceLocation && permissionState == .authorized + + if wantsLiveRefresh != ownsLiveRefresh { + if wantsLiveRefresh { + beginLiveRefresh() + } else { + endLiveRefresh() + } + } + + // A selected location channel is an explicit remote/teleported scope + // and remains usable without device permission. Only device-derived + // scope requires current authorization. + let mayUseNotes = geoTabActive && + (!needsDeviceLocation || permissionState == .authorized) + guard mayUseNotes, let geohash else { + if manager != nil { + release(manager) + } + return GeoSessionState(manager: nil, ownsLiveRefresh: wantsLiveRefresh) + } + + if let manager { + if manager.geohash != geohash.lowercased() { + // Pooled managers are shared; never retarget one in place. + release(manager) + return GeoSessionState( + manager: acquire(geohash), + ownsLiveRefresh: wantsLiveRefresh + ) + } + if manager.state == .idle { + manager.refresh() + } + return GeoSessionState(manager: manager, ownsLiveRefresh: wantsLiveRefresh) + } + + return GeoSessionState( + manager: acquire(geohash), + ownsLiveRefresh: wantsLiveRefresh + ) + } + + private func reconcileGeoSession(notesEnabled: Bool? = nil) { + let notesEnabled = notesEnabled ?? locationNotesEnabled + let next = Self.reconcileGeoSession( + tab: tab, + needsDeviceLocation: geoTabNeedsDeviceLocation, + permissionState: locationChannelsModel.permissionState, + notesEnabled: notesEnabled, + geohash: geoGeohash, + manager: liveGeoManager, + ownsLiveRefresh: ownsLiveGeoRefresh, + beginLiveRefresh: { + locationChannelsModel.enableLocationChannels() + locationChannelsModel.beginLiveRefresh() + }, + endLiveRefresh: { locationChannelsModel.endLiveRefresh() }, + acquire: { geohash in + notesManager ?? LocationNotesPool.shared.acquire(geohash) + }, + release: { manager in + guard notesManager == nil else { return } + LocationNotesPool.shared.release(manager) + } + ) + // A test-injected manager is owned by the caller, not by the pool or + // this view's lifecycle state. + liveGeoManager = notesManager == nil ? next.manager : nil + ownsLiveGeoRefresh = next.ownsLiveRefresh + } + + private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } + + /// The geohash the geo tab is scoped to: the selected location channel, + /// or the device's building geohash when chatting on mesh. + private var geoGeohash: String? { + if case .location(let channel) = locationChannelsModel.selectedChannel { + return channel.geohash + } + guard locationChannelsModel.permissionState == .authorized else { + return nil + } + return locationChannelsModel.currentBuildingGeohash + } + + /// The geo scope comes from device location only when no location channel + /// is selected; that's the case that needs the location machinery. + private var geoTabNeedsDeviceLocation: Bool { + if case .location = locationChannelsModel.selectedChannel { return false } + return true + } + + private var activeGeohash: String? { + Self.composerGeohash( + tab: tab, + notesEnabled: locationNotesEnabled, + geoGeohash: geoGeohash + ) + } + + enum Strings { + static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet") + static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices") + static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices") + static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle") + // The pre-merge location-notes explainer, reused so its existing + // translations carry over. + static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet") + static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet") + static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") + static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") + static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") + static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") + static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") + static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") + static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") + static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") + static let permanentOption = String(localized: "notices.expiry.permanent", defaultValue: "permanent", comment: "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker") + static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button") + static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh") + static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays") + static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices") + static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices") + static let locationNotesTitle: LocalizedStringKey = "app_info.location.notes.title" + static let locationNotesDescription: LocalizedStringKey = "app_info.location.notes.description" + static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" + static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays" + static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" + static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" + static let retry: LocalizedStringKey = "location_notes.action.retry" + static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" + + static func expiryDaysOption(_ days: Int) -> String { + String( + format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), + locale: .current, + days + ) + } + + static func fades(_ expiresAt: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return String( + format: String(localized: "notices.fades", defaultValue: "fades %@", comment: "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'"), + locale: .current, + formatter.localizedString(for: expiresAt, relativeTo: Date()) + ) + } + + static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { + let base = String( + format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), + locale: .current, + author, content + ) + return urgent ? "\(urgentBadge), \(base)" : base + } + } + + var body: some View { + VStack(spacing: 0) { + headerSection + contentSection + if activeGeohash != nil { + composer + } + } + .themedSurface() + #if os(macOS) + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) + #endif + .themedSheetBackground() + .onAppear { + reconcileGeoSession() + } + .onChange(of: tab) { newTab in + if newTab == .geo { + if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) { + NearbyNotesCounter.shared.reveal() + } + } + reconcileGeoSession() + // Each tab keeps its natural default: geo notes stay until + // deleted (∞), mesh board posts fade within a week. + expiryDays = newTab == .geo ? Self.permanentExpiry : 7 + urgent = false + } + // Catches both grant and revocation. Revocation must balance the live + // refresh and release a device-derived building REQ immediately. + .onChange(of: locationChannelsModel.permissionState) { _ in + reconcileGeoSession() + } + .onChange(of: geoGeohash) { _ in + reconcileGeoSession() + } + .onChange(of: geoTabNeedsDeviceLocation) { _ in + reconcileGeoSession() + } + .onReceive(NotificationCenter.default.publisher(for: LocationNotesSettings.didChangeNotification)) { _ in + let enabled = LocationNotesSettings.enabled + locationNotesEnabled = enabled + reconcileGeoSession(notesEnabled: enabled) + } + .onDisappear { + if ownsLiveGeoRefresh { + locationChannelsModel.endLiveRefresh() + ownsLiveGeoRefresh = false + } + if notesManager == nil { + LocationNotesPool.shared.release(liveGeoManager) + } + liveGeoManager = nil + } + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(verbatim: scopeTitle) + .bitchatFont(size: 18) + Spacer() + SheetCloseButton { dismiss() } + .accessibilityLabel(Strings.closeHint) + } + Picker(Strings.scopePicker, selection: $tab) { + Text(Strings.geoTab).tag(Tab.geo) + Text(Strings.meshTab).tag(Tab.mesh) + } + .pickerStyle(.segmented) + .accessibilityLabel(Strings.scopePicker) + Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .themedSurface() + } + + private var scopeTitle: String { + switch tab { + case .mesh: + return "\(Strings.title) @ #mesh" + case .geo: + if let geohash = geoGeohash { + return "\(Strings.title) @ #\(geohash)" + } + return Strings.title + } + } + + @ViewBuilder + private var contentSection: some View { + switch tab { + case .mesh: + NoticesList( + items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []), + showsSource: false, + board: board, + notesManager: nil + ) + case .geo: + switch Self.geoPresentationState( + notesEnabled: locationNotesEnabled, + geohash: geoGeohash + ) { + case .disabled: + locationNotesDisabledSection + case .available(let geohash): + if let manager = activeNotesManager { + GeoNoticesList(geohash: geohash, board: board, manager: manager) + } else { + // Manager is created on appear; visible for one frame. + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { reconcileGeoSession() } + } + case .locationUnavailable: + locationUnavailableSection + } + } + } + + private var locationNotesDisabledSection: some View { + ScrollView { + Toggle( + isOn: Binding( + get: { locationNotesEnabled }, + set: { enabled in + locationNotesEnabled = enabled + LocationNotesSettings.enabled = enabled + } + ) + ) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.locationNotesTitle) + .bitchatFont(size: 14) + Text(Strings.locationNotesDescription) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .toggleStyle(.switch) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + private var locationUnavailableSection: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(Strings.locationUnavailable) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(Strings.enableLocation) { + locationChannelsModel.enableAndRefresh() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + TextField(Strings.placeholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .bitchatFont(size: 14) + .lineLimit(maxDraftLines, reservesSpace: true) + .padding(.vertical, 6) + Button(action: send) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 20)) + .foregroundColor(sendEnabled ? palette.accent : .secondary) + } + .padding(.top, 2) + .buttonStyle(.plain) + .disabled(!sendEnabled) + .accessibilityLabel(Strings.send) + } + // Both tabs pick an expiry (geo notes may be ∞); urgency is a + // mesh-board concept — notes are ambient by nature. + HStack(spacing: 12) { + if tab == .mesh { + Toggle(isOn: $urgent) { + Text(Strings.urgentToggle) + .bitchatFont(size: 12) + .foregroundColor(urgent ? palette.alertRed : palette.secondary) + } + .toggleStyle(.switch) + .fixedSize() + .accessibilityLabel(Strings.urgentToggle) + } + Spacer() + Text(Strings.expiryLabel) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Picker(Strings.expiryLabel, selection: $expiryDays) { + // Mesh board posts must fade (the wire caps their + // lifetime); only relay-backed geo notes can be ∞. + if tab == .geo { + Text(verbatim: "∞") + .accessibilityLabel(Strings.permanentOption) + .tag(Self.permanentExpiry) + } + ForEach([1, 3, 7], id: \.self) { days in + Text(Strings.expiryDaysOption(days)).tag(days) + } + } + .pickerStyle(.segmented) + // macOS segmented pickers render their own label; the themed + // Text alongside already carries it (and accessibility keeps + // the explicit label below). + .labelsHidden() + .fixedSize() + .accessibilityLabel(Strings.expiryLabel) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .themedSurface() + .overlay(Divider(), alignment: .top) + } + + private var sendEnabled: Bool { + let trimmed = draft.trimmed + return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes + } + + private func send() { + guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return } + + // ∞ (geo default): a pure relay note with no NIP-40 tag. It skips + // the mesh board deliberately — a board copy must fade within days, + // which would contradict the permanence the user just picked. + if tab == .geo, expiryDays == Self.permanentExpiry { + guard let manager = activeNotesManager else { return } + manager.send(content: content, nickname: senderNickname, expiresAt: nil) + draft = "" + urgent = false + return + } + + // Expiring posts go to the board and are bridged to Nostr by + // BoardManager, so mesh and internet see the same notice with the + // chosen expiry (expiresAt on mesh, NIP-40 on the bridged note). + // Urgency is mesh-only. + let sent = board.createPost( + content: content, + geohash: geohash, + urgent: tab == .mesh && urgent, + expiryDays: expiryDays, + nickname: senderNickname + ) + if sent { + draft = "" + urgent = false + } + } +} + +/// The geo tab's list: renders the sheet-owned Nostr notes subscription +/// merged with the board posts for the same geohash. The manager lives on +/// `NoticesView` so the composer can post through the same instance (∞ +/// notes local-echo into this list). +private struct GeoNoticesList: View { + let geohash: String + @ObservedObject var board: BoardManager + @ObservedObject var notesManager: LocationNotesManager + + init(geohash: String, board: BoardManager, manager: LocationNotesManager) { + self.geohash = geohash.lowercased() + self.board = board + self.notesManager = manager + } + + var body: some View { + NoticesList( + items: UnifiedNotices.merge( + posts: board.posts(forGeohash: geohash), + notes: notesManager.notes + ), + showsSource: true, + board: board, + notesManager: notesManager + ) + } +} + +/// Renders merged notices with per-source affordances: swipe-delete for own +/// items and a mesh/net badge when sources mix. +private struct NoticesList: View { + let items: [NoticeItem] + let showsSource: Bool + let board: BoardManager + let notesManager: LocationNotesManager? + + @ThemedPalette private var palette + + private typealias Strings = NoticesView.Strings + + var body: some View { + Group { + if items.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + statusRows + if showEmptyState { + Text(Strings.emptyTitle) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.emptySubtitle) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } else { + List { + statusRows + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + ForEach(items) { item in + row(item) + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + /// Notes may still be loading or unreachable; only claim "no notices yet" + /// once the sources settled. + private var showEmptyState: Bool { + guard let notesManager else { return true } + return notesManager.initialLoadComplete + && notesManager.state != .loading + && notesManager.state != .connecting + } + + @ViewBuilder + private var statusRows: some View { + if let notesManager { + if notesManager.state == .loading && !notesManager.initialLoadComplete { + HStack(spacing: 10) { + ProgressView() + Text(Strings.loadingNotes) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Spacer() + } + .padding(.vertical, 8) + } else if notesManager.state == .connecting { + HStack(spacing: 10) { + ProgressView() + Text(Strings.connectingRelays) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Spacer() + } + .padding(.vertical, 8) + } else if notesManager.state == .noRelays { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.noRelaysNearby) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.relaysRetryHint) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Button(Strings.retry) { notesManager.refresh() } + .bitchatFont(size: 12) + .buttonStyle(.plain) + } + .padding(.bottom, 8) + } else if let error = notesManager.errorMessage { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .bitchatFont(size: 12) + Text(error) + .bitchatFont(size: 12) + Spacer() + } + Button(Strings.dismissError) { notesManager.clearError() } + .bitchatFont(size: 12) + .buttonStyle(.plain) + } + .padding(.bottom, 8) + } + } + } + + private func canDelete(_ item: NoticeItem) -> Bool { + switch item.source { + case .board(let post): + return board.isOwnPost(post) + case .nostr(let note): + return notesManager?.isOwnNote(note) ?? false + } + } + + private func delete(_ item: NoticeItem) { + switch item.source { + case .board(let post): + // Tombstones the board post and retracts the bridged Nostr copy. + board.deletePost(post) + case .nostr(let note): + notesManager?.delete(note: note) + } + } + + private func row(_ item: NoticeItem) -> some View { + let isOwn = canDelete(item) + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if item.isUrgent { + Image(systemName: "exclamationmark.triangle.fill") + .font(.bitchatSystem(size: 11)) + .foregroundColor(palette.alertRed) + Text(Strings.urgentBadge) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.alertRed) + } + Text(verbatim: "@\(item.author)") + .bitchatFont(size: 12, weight: .semibold) + Text(Self.timestampText(for: item.createdAt)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + if let expiresAt = item.expiresAt, expiresAt > Date() { + Text(Strings.fades(expiresAt)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) + } + Spacer() + if showsSource { + sourceBadge(item) + } + if isOwn { + Button { + delete(item) + } label: { + Image(systemName: "trash") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.deleteAction) + } + } + Text(item.content) + .bitchatFont(size: 14) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent)) + .accessibilityActions { + if isOwn { + Button(Strings.deleteAction) { delete(item) } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwn { + Button(role: .destructive) { + delete(item) + } label: { + Label(Strings.deleteAction, systemImage: "trash") + } + } + } + } + + private func sourceBadge(_ item: NoticeItem) -> some View { + HStack(spacing: 3) { + Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe") + .font(.bitchatSystem(size: 10)) + Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) + .bitchatFont(size: 10) + } + .foregroundColor(palette.secondary.opacity(0.8)) + .accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) + } + + // MARK: - Timestamp Formatting + + private static func timestampText(for date: Date) -> String { + let now = Date() + if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { + // The whole "3 hr ago" phrase must come from the formatter — + // gluing an English "ago" onto a localized duration ships the + // wrong word order to most locales ("hace 3 h", "vor 3 Std"). + return relativeFormatter.localizedString(for: date, relativeTo: now) + } + let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) + return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date) + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + + private static let absDateFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d") + return f + }() + + private static let absDateYearFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d, y") + return f + }() +} diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 78e0ff93..94aaf211 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -11,7 +11,10 @@ import AppKit struct MyQRView: View { let qrString: String @Environment(\.colorScheme) var colorScheme - private var boxColor: Color { Color.gray.opacity(0.1) } + @ThemedPalette private var palette + // Palette-tinted so the box follows the theme (green under matrix) + // instead of a fixed gray band over the glass gradient. + private var boxColor: Color { palette.secondary.opacity(0.1) } private enum Strings { static let title: LocalizedStringKey = "verification.my_qr.title" @@ -50,6 +53,7 @@ struct MyQRView: View { struct QRCodeImage: View { let data: String let size: CGFloat + @ThemedPalette private var palette private let context = CIContext() private let filter = CIFilter.qrCodeGenerator() @@ -65,12 +69,12 @@ struct QRCodeImage: View { .frame(width: size, height: size) } else { RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray.opacity(0.5), lineWidth: 1) + .stroke(palette.secondary.opacity(0.5), lineWidth: 1) .frame(width: size, height: size) .overlay( Text(Strings.unavailable) .bitchatFont(size: 12) - .foregroundColor(.gray) + .foregroundColor(palette.secondary) ) } } @@ -108,6 +112,7 @@ struct ImageWrapper: View { /// Placeholder scanner UI; real camera scanning will be added later. struct QRScanView: View { @EnvironmentObject private var verificationModel: VerificationModel + @ThemedPalette private var palette var isActive: Bool = true var onSuccess: (() -> Void)? = nil // Called when verification succeeds @State private var input = "" @@ -153,7 +158,7 @@ struct QRScanView: View { .bitchatFont(size: 14, weight: .medium) TextEditor(text: $input) .frame(height: 100) - .border(Color.gray.opacity(0.4)) + .border(palette.secondary.opacity(0.4)) Button(Strings.validate) { // Deduplicate: ignore if we just processed this exact QR guard input != lastValid else { @@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable { } final class PreviewView: UIView { - override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } override init(frame: CGRect) { super.init(frame: frame) @@ -283,9 +288,8 @@ struct VerificationSheetView: View { @State private var showingScanner = false @ThemedPalette private var palette - private var backgroundColor: Color { palette.background } private var accentColor: Color { palette.accent } - private var boxColor: Color { Color.gray.opacity(0.1) } + private var boxColor: Color { palette.secondary.opacity(0.1) } var body: some View { VStack(spacing: 0) { @@ -295,15 +299,11 @@ struct VerificationSheetView: View { .bitchatFont(size: 14, weight: .bold) .foregroundColor(accentColor) Spacer() - Button(action: { + SheetCloseButton { showingScanner = false isPresented = false - }) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 14, weight: .semibold)) - .foregroundColor(accentColor) } - .buttonStyle(.plain) + .foregroundColor(accentColor) } .padding(.horizontal, 16) .padding(.top, 12) diff --git a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift index 49e63d3f..32827f48 100644 --- a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift +++ b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift @@ -10,25 +10,37 @@ import BitFoundation import Foundation final class PreviewKeychainManager: KeychainManagerProtocol { + // Locked: KeychainManager.makeDefault() hands one shared instance to + // every default-constructed component under test, which access it from + // arbitrary threads. + private let lock = NSLock() private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] init() {} func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + lock.lock() + defer { lock.unlock() } storage[key] = keyData return true } func getIdentityKey(forKey key: String) -> Data? { - storage[key] + lock.lock() + defer { lock.unlock() } + return storage[key] } func deleteIdentityKey(forKey key: String) -> Bool { + lock.lock() + defer { lock.unlock() } storage.removeValue(forKey: key) return true } func deleteAllKeychainData() -> Bool { + lock.lock() + defer { lock.unlock() } storage.removeAll() serviceStorage.removeAll() return true @@ -39,11 +51,15 @@ final class PreviewKeychainManager: KeychainManagerProtocol { func secureClear(_ string: inout String) {} func verifyIdentityKeyExists() -> Bool { - storage["identity_noiseStaticKey"] != nil + lock.lock() + defer { lock.unlock() } + return storage["identity_noiseStaticKey"] != nil } // BCH-01-009: New methods with proper error classification func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + lock.lock() + defer { lock.unlock() } if let data = storage[key] { return .success(data) } @@ -51,6 +67,8 @@ final class PreviewKeychainManager: KeychainManagerProtocol { } func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + lock.lock() + defer { lock.unlock() } storage[key] = keyData return .success } @@ -58,17 +76,26 @@ final class PreviewKeychainManager: KeychainManagerProtocol { // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { - if serviceStorage[service] == nil { - serviceStorage[service] = [:] - } - serviceStorage[service]?[key] = data + lock.lock() + defer { lock.unlock() } + serviceStorage[service, default: [:]][key] = data } func load(key: String, service: String) -> Data? { - serviceStorage[service]?[key] + lock.lock() + defer { lock.unlock() } + return serviceStorage[service]?[key] } func delete(key: String, service: String) { + lock.lock() + defer { lock.unlock() } serviceStorage[service]?.removeValue(forKey: key) } + + func deleteAll(service: String) { + lock.lock() + defer { lock.unlock() } + serviceStorage.removeValue(forKey: service) + } } diff --git a/bitchatShareExtension/Info.plist b/bitchatShareExtension/Info.plist index a9c29c8d..62d9b453 100644 --- a/bitchatShareExtension/Info.plist +++ b/bitchatShareExtension/Info.plist @@ -28,8 +28,6 @@ NSExtensionActivationRule - NSExtensionActivationSupportsImageWithMaxCount - 1 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount diff --git a/bitchatShareExtension/Localization/Localizable.xcstrings b/bitchatShareExtension/Localization/Localizable.xcstrings index 20a68af4..6ffa096c 100644 --- a/bitchatShareExtension/Localization/Localizable.xcstrings +++ b/bitchatShareExtension/Localization/Localizable.xcstrings @@ -1,708 +1,1176 @@ { - "sourceLanguage": "en", - "strings": { - "share.fallback.shared_link_title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "shared Link", - "comment": "Fallback title when saving a shared link" + "sourceLanguage" : "en", + "strings" : { + "share.fallback.shared_link_title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "رابط مشترك", + "comment" : "Fallback title when saving a shared link" } }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "رابط مشترك", - "comment": "Fallback title when saving a shared link" + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "শেয়ার করা লিঙ্ক" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "geteilter link", - "comment": "Fallback title when saving a shared link" + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "geteilter link", + "comment" : "Fallback title when saving a shared link" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "enlace compartido", - "comment": "Fallback title when saving a shared link" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "shared Link", + "comment" : "Fallback title when saving a shared link" } }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "lien partagé", - "comment": "Fallback title when saving a shared link" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enlace compartido", + "comment" : "Fallback title when saving a shared link" } }, - "he": { - "stringUnit": { - "state": "translated", - "value": "קישור משותף", - "comment": "Fallback title when saving a shared link" + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "naibahaging link" } }, - "id": { - "stringUnit": { - "state": "translated", - "value": "tautan dibagikan", - "comment": "Fallback title when saving a shared link" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "lien partagé", + "comment" : "Fallback title when saving a shared link" } }, - "it": { - "stringUnit": { - "state": "translated", - "value": "link condiviso", - "comment": "Fallback title when saving a shared link" + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קישור משותף", + "comment" : "Fallback title when saving a shared link" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "共有リンク", - "comment": "Fallback title when saving a shared link" + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "शेयर किया गया लिंक" } }, - "ne": { - "stringUnit": { - "state": "translated", - "value": "साझा गरिएको लिङ्क", - "comment": "Fallback title when saving a shared link" + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tautan dibagikan", + "comment" : "Fallback title when saving a shared link" } }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "link compartilhado", - "comment": "Fallback title when saving a shared link" + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "link condiviso", + "comment" : "Fallback title when saving a shared link" } }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "поделился ссылкой", - "comment": "Fallback title when saving a shared link" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有リンク", + "comment" : "Fallback title when saving a shared link" } }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "спільне посилання", - "comment": "Fallback title when saving a shared link" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공유된 링크", + "comment" : "Fallback title when saving a shared link" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "分享的链接", - "comment": "Fallback title when saving a shared link" + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pautan dikongsi" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "공유된 링크", - "comment": "Fallback title when saving a shared link" + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "साझा गरिएको लिङ्क", + "comment" : "Fallback title when saving a shared link" } }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "paylaşılan bağlantı", - "comment": "Fallback title when saving a shared link" + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gedeelde link" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "udostępniony link" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ligação partilhada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "link compartilhado", + "comment" : "Fallback title when saving a shared link" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "поделился ссылкой", + "comment" : "Fallback title when saving a shared link" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "delad länk" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பகிரப்பட்ட இணைப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ลิงก์ที่แชร์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "paylaşılan bağlantı", + "comment" : "Fallback title when saving a shared link" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "спільне посилання", + "comment" : "Fallback title when saving a shared link" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "شیئر کردہ لنک" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "liên kết đã chia sẻ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享的链接", + "comment" : "Fallback title when saving a shared link" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "分享的連結" } } } }, - "share.status.failed_to_encode": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "failed to encode link", - "comment": "Shown when the share payload cannot be encoded" + "share.status.failed_to_encode" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذر ترميز الرابط", + "comment" : "Shown when the share payload cannot be encoded" } }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذر ترميز الرابط", - "comment": "Shown when the share payload cannot be encoded" + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "লিঙ্ক এনকোড করা যায়নি" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "link konnte nicht codiert werden", - "comment": "Shown when the share payload cannot be encoded" + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "link konnte nicht codiert werden", + "comment" : "Shown when the share payload cannot be encoded" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "no se pudo codificar el enlace", - "comment": "Shown when the share payload cannot be encoded" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "failed to encode link", + "comment" : "Shown when the share payload cannot be encoded" } }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "échec de l'encodage du lien", - "comment": "Shown when the share payload cannot be encoded" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo codificar el enlace", + "comment" : "Shown when the share payload cannot be encoded" } }, - "he": { - "stringUnit": { - "state": "translated", - "value": "לא ניתן לקודד את הקישור", - "comment": "Shown when the share payload cannot be encoded" + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi ma-encode ang link" } }, - "id": { - "stringUnit": { - "state": "translated", - "value": "gagal mengodekan tautan", - "comment": "Shown when the share payload cannot be encoded" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "échec de l'encodage du lien", + "comment" : "Shown when the share payload cannot be encoded" } }, - "it": { - "stringUnit": { - "state": "translated", - "value": "impossibile codificare il link", - "comment": "Shown when the share payload cannot be encoded" + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לקודד את הקישור", + "comment" : "Shown when the share payload cannot be encoded" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リンクのエンコードに失敗しました", - "comment": "Shown when the share payload cannot be encoded" + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "लिंक एन्कोड नहीं हो सका" } }, - "ne": { - "stringUnit": { - "state": "translated", - "value": "लिङ्क सङ्केत गर्न सकेन", - "comment": "Shown when the share payload cannot be encoded" + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "gagal mengodekan tautan", + "comment" : "Shown when the share payload cannot be encoded" } }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "falha ao codificar link", - "comment": "Shown when the share payload cannot be encoded" + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile codificare il link", + "comment" : "Shown when the share payload cannot be encoded" } }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "не удалось закодировать ссылку", - "comment": "Shown when the share payload cannot be encoded" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リンクのエンコードに失敗しました", + "comment" : "Shown when the share payload cannot be encoded" } }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "не вдалося закодувати посилання", - "comment": "Shown when the share payload cannot be encoded" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "링크를 인코딩하는 데 실패했습니다", + "comment" : "Shown when the share payload cannot be encoded" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法编码链接", - "comment": "Shown when the share payload cannot be encoded" + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gagal mengekod pautan" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "링크를 인코딩하는 데 실패했습니다", - "comment": "Shown when the share payload cannot be encoded" + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "लिङ्क सङ्केत गर्न सकेन", + "comment" : "Shown when the share payload cannot be encoded" } }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "bağlantı kodlanamadı", - "comment": "Shown when the share payload cannot be encoded" + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "link coderen mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się zakodować linku" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha ao codificar a ligação" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "falha ao codificar link", + "comment" : "Shown when the share payload cannot be encoded" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не удалось закодировать ссылку", + "comment" : "Shown when the share payload cannot be encoded" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte koda länken" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணைப்பை என்கோட் செய்ய முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้ารหัสลิงก์ไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bağlantı kodlanamadı", + "comment" : "Shown when the share payload cannot be encoded" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося закодувати посилання", + "comment" : "Shown when the share payload cannot be encoded" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لنک انکوڈ نہیں ہو سکا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể mã hóa liên kết" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法编码链接", + "comment" : "Shown when the share payload cannot be encoded" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法編碼連結" } } } }, - "share.status.no_shareable_content": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "no shareable content", - "comment": "Shown when provided content cannot be shared" + "share.status.no_shareable_content" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا محتوى قابلاً للمشاركة", + "comment" : "Shown when provided content cannot be shared" } }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا محتوى قابلاً للمشاركة", - "comment": "Shown when provided content cannot be shared" + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "শেয়ার করার মতো কোনো কনটেন্ট নেই" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "kein teilbarer inhalt", - "comment": "Shown when provided content cannot be shared" + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kein teilbarer inhalt", + "comment" : "Shown when provided content cannot be shared" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "sin contenido que se pueda compartir", - "comment": "Shown when provided content cannot be shared" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no shareable content", + "comment" : "Shown when provided content cannot be shared" } }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "aucun contenu partageable", - "comment": "Shown when provided content cannot be shared" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sin contenido que se pueda compartir", + "comment" : "Shown when provided content cannot be shared" } }, - "he": { - "stringUnit": { - "state": "translated", - "value": "אין תוכן שניתן לשתף", - "comment": "Shown when provided content cannot be shared" + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "walang maibabahaging nilalaman" } }, - "id": { - "stringUnit": { - "state": "translated", - "value": "tidak ada konten yang bisa dibagikan", - "comment": "Shown when provided content cannot be shared" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aucun contenu partageable", + "comment" : "Shown when provided content cannot be shared" } }, - "it": { - "stringUnit": { - "state": "translated", - "value": "nessun contenuto condivisibile", - "comment": "Shown when provided content cannot be shared" + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין תוכן שניתן לשתף", + "comment" : "Shown when provided content cannot be shared" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "共有可能なコンテンツがありません", - "comment": "Shown when provided content cannot be shared" + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "शेयर करने योग्य कोई सामग्री नहीं" } }, - "ne": { - "stringUnit": { - "state": "translated", - "value": "बाँड्न मिल्ने सामग्री छैन", - "comment": "Shown when provided content cannot be shared" + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak ada konten yang bisa dibagikan", + "comment" : "Shown when provided content cannot be shared" } }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "nenhum conteúdo compartilhável", - "comment": "Shown when provided content cannot be shared" + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessun contenuto condivisibile", + "comment" : "Shown when provided content cannot be shared" } }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "нет подходящего контента", - "comment": "Shown when provided content cannot be shared" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有可能なコンテンツがありません", + "comment" : "Shown when provided content cannot be shared" } }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "нема відповідного контенту", - "comment": "Shown when provided content cannot be shared" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공유할 수 있는 내용이 없습니다", + "comment" : "Shown when provided content cannot be shared" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "没有可分享的素材", - "comment": "Shown when provided content cannot be shared" + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tiada kandungan yang boleh dikongsi" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "공유할 수 있는 내용이 없습니다", - "comment": "Shown when provided content cannot be shared" + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "बाँड्न मिल्ने सामग्री छैन", + "comment" : "Shown when provided content cannot be shared" } }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "paylaşılabilir içerik yok", - "comment": "Shown when provided content cannot be shared" + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geen deelbare inhoud" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "brak treści do udostępnienia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sem conteúdo partilhável" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhum conteúdo compartilhável", + "comment" : "Shown when provided content cannot be shared" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "нет подходящего контента", + "comment" : "Shown when provided content cannot be shared" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inget delbart innehåll" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பகிரக்கூடிய உள்ளடக்கம் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่มีเนื้อหาที่แชร์ได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "paylaşılabilir içerik yok", + "comment" : "Shown when provided content cannot be shared" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "нема відповідного контенту", + "comment" : "Shown when provided content cannot be shared" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "شیئر کرنے کے قابل کوئی مواد نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không có nội dung có thể chia sẻ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可分享的素材", + "comment" : "Shown when provided content cannot be shared" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "沒有可分享的素材" } } } }, - "share.status.nothing_to_share": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "nothing to share", - "comment": "Shown when the share extension receives no content" + "share.status.nothing_to_share" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا شيء لمشاركته", + "comment" : "Shown when the share extension receives no content" } }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا شيء لمشاركته", - "comment": "Shown when the share extension receives no content" + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "শেয়ার করার কিছু নেই" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "nichts zum teilen", - "comment": "Shown when the share extension receives no content" + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nichts zum teilen", + "comment" : "Shown when the share extension receives no content" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "nada que compartir", - "comment": "Shown when the share extension receives no content" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nothing to share", + "comment" : "Shown when the share extension receives no content" } }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "rien à partager", - "comment": "Shown when the share extension receives no content" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nada que compartir", + "comment" : "Shown when the share extension receives no content" } }, - "he": { - "stringUnit": { - "state": "translated", - "value": "אין מה לשתף", - "comment": "Shown when the share extension receives no content" + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "walang maibabahagi" } }, - "id": { - "stringUnit": { - "state": "translated", - "value": "tidak ada yang bisa dibagikan", - "comment": "Shown when the share extension receives no content" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "rien à partager", + "comment" : "Shown when the share extension receives no content" } }, - "it": { - "stringUnit": { - "state": "translated", - "value": "niente da condividere", - "comment": "Shown when the share extension receives no content" + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין מה לשתף", + "comment" : "Shown when the share extension receives no content" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "共有できるものがありません", - "comment": "Shown when the share extension receives no content" + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "शेयर करने के लिए कुछ नहीं" } }, - "ne": { - "stringUnit": { - "state": "translated", - "value": "बाँड्ने केही छैन", - "comment": "Shown when the share extension receives no content" + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak ada yang bisa dibagikan", + "comment" : "Shown when the share extension receives no content" } }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "nada para compartilhar", - "comment": "Shown when the share extension receives no content" + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "niente da condividere", + "comment" : "Shown when the share extension receives no content" } }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "нечем поделиться", - "comment": "Shown when the share extension receives no content" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有できるものがありません", + "comment" : "Shown when the share extension receives no content" } }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "нема чим ділитися", - "comment": "Shown when the share extension receives no content" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공유할 내용이 없습니다", + "comment" : "Shown when the share extension receives no content" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "没有可分享的内容", - "comment": "Shown when the share extension receives no content" + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tiada apa-apa untuk dikongsi" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "공유할 내용이 없습니다", - "comment": "Shown when the share extension receives no content" + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "बाँड्ने केही छैन", + "comment" : "Shown when the share extension receives no content" } }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "paylaşılacak bir şey yok", - "comment": "Shown when the share extension receives no content" + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "niets om te delen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie ma nic do udostępnienia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nada para partilhar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nada para compartilhar", + "comment" : "Shown when the share extension receives no content" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "нечем поделиться", + "comment" : "Shown when the share extension receives no content" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inget att dela" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பகிர எதுவும் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่มีอะไรให้แชร์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "paylaşılacak bir şey yok", + "comment" : "Shown when the share extension receives no content" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "нема чим ділитися", + "comment" : "Shown when the share extension receives no content" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "شیئر کرنے کے لیے کچھ نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không có gì để chia sẻ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可分享的内容", + "comment" : "Shown when the share extension receives no content" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "沒有可分享的內容" } } } }, - "share.status.shared_link": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "✓ shared link to bitchat", - "comment": "Confirmation after successfully sharing a link" + "share.status.shared_link" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ تم إرسال الرابط إلى bitchat", + "comment" : "Confirmation after successfully sharing a link" } }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "✓ تم إرسال الرابط إلى bitchat", - "comment": "Confirmation after successfully sharing a link" + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat-এ লিঙ্ক শেয়ার করা হয়েছে" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "✓ link zu bitchat geteilt", - "comment": "Confirmation after successfully sharing a link" + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ link zu bitchat geteilt", + "comment" : "Confirmation after successfully sharing a link" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "✓ enlace compartido con bitchat", - "comment": "Confirmation after successfully sharing a link" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ shared link to bitchat", + "comment" : "Confirmation after successfully sharing a link" } }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "✓ lien partagé vers bitchat", - "comment": "Confirmation after successfully sharing a link" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ enlace compartido con bitchat", + "comment" : "Confirmation after successfully sharing a link" } }, - "he": { - "stringUnit": { - "state": "translated", - "value": "✓ הקישור נשלח אל bitchat", - "comment": "Confirmation after successfully sharing a link" + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ naibahagi ang link sa bitchat" } }, - "id": { - "stringUnit": { - "state": "translated", - "value": "✓ tautan dikirim ke bitchat", - "comment": "Confirmation after successfully sharing a link" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ lien partagé vers bitchat", + "comment" : "Confirmation after successfully sharing a link" } }, - "it": { - "stringUnit": { - "state": "translated", - "value": "✓ link inviato a bitchat", - "comment": "Confirmation after successfully sharing a link" + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ הקישור נשלח אל bitchat", + "comment" : "Confirmation after successfully sharing a link" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchatにリンクを共有", - "comment": "Confirmation after successfully sharing a link" + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat पर लिंक शेयर किया गया" } }, - "ne": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchat मा लिङ्क पठाइयो", - "comment": "Confirmation after successfully sharing a link" + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ tautan dikirim ke bitchat", + "comment" : "Confirmation after successfully sharing a link" } }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "✓ link enviado para bitchat", - "comment": "Confirmation after successfully sharing a link" + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ link inviato a bitchat", + "comment" : "Confirmation after successfully sharing a link" } }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "✓ ссылка отправлена в bitchat", - "comment": "Confirmation after successfully sharing a link" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchatにリンクを共有", + "comment" : "Confirmation after successfully sharing a link" } }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "✓ посилання надіслано в bitchat", - "comment": "Confirmation after successfully sharing a link" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchat으로 링크를 공유했습니다", + "comment" : "Confirmation after successfully sharing a link" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "✓ 已将链接分享至 bitchat", - "comment": "Confirmation after successfully sharing a link" + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ pautan dikongsi ke bitchat" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchat으로 링크를 공유했습니다", - "comment": "Confirmation after successfully sharing a link" + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchat मा लिङ्क पठाइयो", + "comment" : "Confirmation after successfully sharing a link" } }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchat'e bağlantı paylaşıldı", - "comment": "Confirmation after successfully sharing a link" + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ link gedeeld met bitchat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ udostępniono link w bitchat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ligação enviada para o bitchat" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ link enviado para bitchat", + "comment" : "Confirmation after successfully sharing a link" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ ссылка отправлена в bitchat", + "comment" : "Confirmation after successfully sharing a link" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ länk delad till bitchat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat-க்கு இணைப்பு பகிரப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ แชร์ลิงก์ไปยัง bitchat แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchat'e bağlantı paylaşıldı", + "comment" : "Confirmation after successfully sharing a link" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ посилання надіслано в bitchat", + "comment" : "Confirmation after successfully sharing a link" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat پر لنک شیئر کر دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ đã chia sẻ liên kết tới bitchat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ 已将链接分享至 bitchat", + "comment" : "Confirmation after successfully sharing a link" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 已將連結分享至 bitchat" } } } }, - "share.status.shared_text": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "✓ shared text to bitchat", - "comment": "Confirmation after successfully sharing text" + "share.status.shared_text" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ تم إرسال النص إلى bitchat", + "comment" : "Confirmation after successfully sharing text" } }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "✓ تم إرسال النص إلى bitchat", - "comment": "Confirmation after successfully sharing text" + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat-এ টেক্সট শেয়ার করা হয়েছে" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "✓ text zu bitchat geteilt", - "comment": "Confirmation after successfully sharing text" + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ text zu bitchat geteilt", + "comment" : "Confirmation after successfully sharing text" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "✓ texto compartido con bitchat", - "comment": "Confirmation after successfully sharing text" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ shared text to bitchat", + "comment" : "Confirmation after successfully sharing text" } }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "✓ texte partagé vers bitchat", - "comment": "Confirmation after successfully sharing text" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ texto compartido con bitchat", + "comment" : "Confirmation after successfully sharing text" } }, - "he": { - "stringUnit": { - "state": "translated", - "value": "✓ הטקסט נשלח אל bitchat", - "comment": "Confirmation after successfully sharing text" + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ naibahagi ang teksto sa bitchat" } }, - "id": { - "stringUnit": { - "state": "translated", - "value": "✓ teks dikirim ke bitchat", - "comment": "Confirmation after successfully sharing text" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ texte partagé vers bitchat", + "comment" : "Confirmation after successfully sharing text" } }, - "it": { - "stringUnit": { - "state": "translated", - "value": "✓ testo inviato a bitchat", - "comment": "Confirmation after successfully sharing text" + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ הטקסט נשלח אל bitchat", + "comment" : "Confirmation after successfully sharing text" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchatにテキストを共有", - "comment": "Confirmation after successfully sharing text" + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat पर टेक्स्ट शेयर किया गया" } }, - "ne": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchat मा पाठ पठाइयो", - "comment": "Confirmation after successfully sharing text" + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ teks dikirim ke bitchat", + "comment" : "Confirmation after successfully sharing text" } }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "✓ texto enviado para bitchat", - "comment": "Confirmation after successfully sharing text" + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ testo inviato a bitchat", + "comment" : "Confirmation after successfully sharing text" } }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "✓ текст отправлен в bitchat", - "comment": "Confirmation after successfully sharing text" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchatにテキストを共有", + "comment" : "Confirmation after successfully sharing text" } }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "✓ текст надіслано в bitchat", - "comment": "Confirmation after successfully sharing text" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchat으로 텍스트를 공유했습니다", + "comment" : "Confirmation after successfully sharing text" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "✓ 已将文本分享至 bitchat", - "comment": "Confirmation after successfully sharing text" + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ teks dikongsi ke bitchat" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchat으로 텍스트를 공유했습니다", - "comment": "Confirmation after successfully sharing text" + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchat मा पाठ पठाइयो", + "comment" : "Confirmation after successfully sharing text" } }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "✓ bitchat'e metin paylaşıldı", - "comment": "Confirmation after successfully sharing text" + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ tekst gedeeld met bitchat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ udostępniono tekst w bitchat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ texto enviado para o bitchat" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ texto enviado para bitchat", + "comment" : "Confirmation after successfully sharing text" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ текст отправлен в bitchat", + "comment" : "Confirmation after successfully sharing text" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ text delad till bitchat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat-க்கு உரை பகிரப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ แชร์ข้อความไปยัง bitchat แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ bitchat'e metin paylaşıldı", + "comment" : "Confirmation after successfully sharing text" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ текст надіслано в bitchat", + "comment" : "Confirmation after successfully sharing text" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ bitchat پر متن شیئر کر دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ đã chia sẻ văn bản tới bitchat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ 已将文本分享至 bitchat", + "comment" : "Confirmation after successfully sharing text" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 已將文字分享至 bitchat" } } } } }, - "version": "1.0" + "version" : "1.0" } diff --git a/bitchatShareExtension/PrivacyInfo.xcprivacy b/bitchatShareExtension/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..d773e0ef --- /dev/null +++ b/bitchatShareExtension/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + 1C8F.1 + + + + + diff --git a/bitchatShareExtension/ShareViewController.swift b/bitchatShareExtension/ShareViewController.swift index a777720a..6867d1d4 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -84,7 +84,7 @@ final class ShareViewController: UIViewController { self.loadFirstPlainText(from: providers) { text in if let t = text, !t.isEmpty { // Treat as URL if parseable http(s), else plain text - if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") { + if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") { self.saveAndFinish(url: u, title: item.attributedTitle?.string) } else { self.saveAndFinish(text: t) @@ -107,38 +107,46 @@ final class ShareViewController: UIViewController { private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) { let identifiers = [UTType.url.identifier, "public.url", "public.file-url"] - let grp = DispatchGroup() - var found: URL? - - for p in providers where found == nil { - for id in identifiers where p.hasItemConformingToTypeIdentifier(id) { - grp.enter() - p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in - defer { grp.leave() } - if let u = item as? URL { found = u; return } - if let s = item as? String, let u = URL(string: s) { found = u; return } - if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return } - } - break + for provider in providers { + guard let identifier = identifiers.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else { + continue } + provider.loadItem(forTypeIdentifier: identifier, options: nil) { item, _ in + let result: URL? + if let url = item as? URL { + result = url + } else if let string = item as? String { + result = URL(string: string) + } else if let data = item as? Data, + let string = String(data: data, encoding: .utf8) { + result = URL(string: string) + } else { + result = nil + } + DispatchQueue.main.async { completion(result) } + } + return } - grp.notify(queue: .main) { completion(found) } + DispatchQueue.main.async { completion(nil) } } private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) { - let id = UTType.plainText.identifier - let grp = DispatchGroup() - var text: String? - for p in providers where p.hasItemConformingToTypeIdentifier(id) { - grp.enter() - p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in - defer { grp.leave() } - if let s = item as? String { text = s } - else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s } - } - break + let identifier = UTType.plainText.identifier + guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(identifier) }) else { + DispatchQueue.main.async { completion(nil) } + return + } + provider.loadItem(forTypeIdentifier: identifier, options: nil) { item, _ in + let result: String? + if let string = item as? String { + result = string + } else if let data = item as? Data { + result = String(data: data, encoding: .utf8) + } else { + result = nil + } + DispatchQueue.main.async { completion(result) } } - grp.notify(queue: .main) { completion(text) } } // MARK: - Save + Finish @@ -170,10 +178,13 @@ final class ShareViewController: UIViewController { } private func finishWithMessage(_ msg: String) { - statusLabel.text = msg - // Complete shortly after showing status - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { - self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.statusLabel.text = msg + // Complete shortly after showing status. + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { [weak self] in + self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } } } } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 0b70f0e4..69b7fc89 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -591,6 +591,45 @@ struct AppArchitectureTests { #expect(!verificationModel.isVerified(peerID: peerID)) } + @Test("VerificationModel refreshes when peer trust changes (vouch accepted)") + @MainActor + func verificationModelRefreshesOnPeerTrustChange() async { + let viewModel = makeArchitectureViewModel() + var privateConversationModel: PrivateConversationModel? = PrivateConversationModel( + chatViewModel: viewModel, + conversations: viewModel.conversations, + locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager()) + ) + let verificationModel = VerificationModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel! + ) + + // PrivateConversationModel happens to observe the same notification + // and re-assign its published selection, which would ripple into + // VerificationModel; release it so this test pins VerificationModel's + // own subscription rather than that incidental chain. + privateConversationModel = nil + + // The bound @Published sources replay their current values on + // subscription; let those initial main-queue emissions settle so the + // sink below observes only the trust-change signal. + try? await Task.sleep(nanoseconds: 100_000_000) + + // ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted + // vouches via "peerStatusUpdated"; an open fingerprint sheet must + // re-render its vouched badge from that signal alone. + var refreshed = false + let cancellable = verificationModel.objectWillChange.sink { _ in + refreshed = true + } + defer { cancellable.cancel() } + + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + await waitUntil { refreshed } + #expect(refreshed) + } + @Test("PeerListModel publishes mesh and geohash directory state") @MainActor func peerListModelPublishesDirectoryState() async { diff --git a/bitchatTests/AudioSessionCoordinatorTests.swift b/bitchatTests/AudioSessionCoordinatorTests.swift new file mode 100644 index 00000000..b9627c1a --- /dev/null +++ b/bitchatTests/AudioSessionCoordinatorTests.swift @@ -0,0 +1,510 @@ +// +// AudioSessionCoordinatorTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +/// Thread-safe: the coordinator invokes it on its private serial queue (that +/// the calls happen off the main thread is itself under test) while the test +/// reads from the main actor. +private final class MockAudioSession: SessionApplying, @unchecked Sendable { + enum Call: Equatable { + case setCategory(AudioSessionCoordinator.Category) + case setActive(Bool, notifyOthers: Bool) + } + + private let lock = NSLock() + private var _calls: [Call] = [] + private var _callsOnMainThread: [Bool] = [] + private var _nextError: Error? + private var _nextActivationError: Error? + + var calls: [Call] { lock.withLock { _calls } } + /// Whether each recorded call ran on the main thread — the coordinator's + /// whole point is that none ever does (the real calls block on IPC to the + /// audio server). + var callsOnMainThread: [Bool] { lock.withLock { _callsOnMainThread } } + var nextError: Error? { + get { lock.withLock { _nextError } } + set { lock.withLock { _nextError = newValue } } + } + /// Fails only the next `setActive` (so `setCategory` can succeed first). + var nextActivationError: Error? { + get { lock.withLock { _nextActivationError } } + set { lock.withLock { _nextActivationError = newValue } } + } + + func setCategory(_ category: AudioSessionCoordinator.Category) throws { + try lock.withLock { + if let error = _nextError { + _nextError = nil + throw error + } + _calls.append(.setCategory(category)) + _callsOnMainThread.append(Thread.isMainThread) + } + } + + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { + try lock.withLock { + if let error = _nextError { + _nextError = nil + throw error + } + if let error = _nextActivationError { + _nextActivationError = nil + throw error + } + _calls.append(.setActive(active, notifyOthers: notifyOthersOnDeactivation)) + _callsOnMainThread.append(Thread.isMainThread) + } + } + + var categoryCalls: [AudioSessionCoordinator.Category] { + calls.compactMap { if case .setCategory(let category) = $0 { category } else { nil } } + } + + var activationCalls: [Bool] { + calls.compactMap { if case .setActive(let active, _) = $0 { active } else { nil } } + } +} + +private struct MockSessionError: Error {} + +/// Async suspension gate used to force lifecycle races without sleeps. The +/// production operation announces that it reached the gated boundary, then +/// stays suspended until the test opens it. +private actor AsyncGate { + private var isOpen = false + private var hasWaiter = false + private var arrivalWaiters: [CheckedContinuation] = [] + private var gateWaiters: [CheckedContinuation] = [] + + func wait() async { + hasWaiter = true + let arrivals = arrivalWaiters + arrivalWaiters = [] + for continuation in arrivals { + continuation.resume() + } + guard !isOpen else { return } + await withCheckedContinuation { continuation in + gateWaiters.append(continuation) + } + } + + func waitUntilEntered() async { + guard !hasWaiter else { return } + await withCheckedContinuation { continuation in + arrivalWaiters.append(continuation) + } + } + + func open() { + isOpen = true + let waiters = gateWaiters + gateWaiters = [] + for continuation in waiters { + continuation.resume() + } + } +} + +@MainActor +struct AudioSessionCoordinatorTests { + // MARK: - Reference-counted activation + + @Test func activatesOnFirstAcquireAndDeactivatesOnLastRelease() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + let first = try await coordinator.acquire(.playback) {} + let second = try await coordinator.acquire(.playback) {} + #expect(session.activationCalls == [true]) + + coordinator.release(first) + await coordinator.drain() + #expect(session.activationCalls == [true]) + + coordinator.release(second) + await coordinator.drain() + #expect(session.activationCalls == [true, false]) + #expect(session.calls.last == .setActive(false, notifyOthers: true)) + } + + @Test func releasingOneOfTwoClientsDoesNotDeactivate() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + let playback = try await coordinator.acquire(.playback) {} + let capture = try await coordinator.acquire(.capture) {} + + coordinator.release(capture) + await coordinator.drain() + #expect(session.activationCalls == [true]) + + coordinator.release(playback) + await coordinator.drain() + #expect(session.activationCalls == [true, false]) + } + + @Test func doubleReleaseIsIdempotent() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + let first = try await coordinator.acquire(.playback) {} + let second = try await coordinator.acquire(.playback) {} + + coordinator.release(first) + coordinator.release(first) + await coordinator.drain() + // The stale second release must not tear the session out from under + // the remaining holder. + #expect(session.activationCalls == [true]) + + coordinator.release(second) + coordinator.release(second) + await coordinator.drain() + #expect(session.activationCalls == [true, false]) + } + + // MARK: - Off-main session calls + + @Test func sessionCallsNeverRunOnTheMainThread() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + // The real setCategory/setActive block on IPC to the audio server + // (>1 s observed under contention, tripping the system gesture gate + // on PTT press) — every call must land on the coordinator's queue. + let token = try await coordinator.acquire(.capture) {} + coordinator.release(token) + await coordinator.drain() + + #expect(session.calls.count == 3) // setCategory + activate + deactivate + #expect(session.callsOnMainThread == [false, false, false]) + } + + @Test func failedActivationDoesNotRegisterAHolder() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + session.nextError = MockSessionError() + await #expect(throws: MockSessionError.self) { + try await coordinator.acquire(.playback) {} + } + + // The failed acquire left no holder behind: the next one is 0->1 + // again and activates. + let token = try await coordinator.acquire(.playback) {} + #expect(session.activationCalls == [true]) + coordinator.release(token) + await coordinator.drain() + #expect(session.activationCalls == [true, false]) + } + + @Test func failedActivationRollsBackEscalatedCategory() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + // setCategory(.playAndRecord) succeeds, setActive throws (e.g. a + // phone call owns the hardware). + session.nextActivationError = MockSessionError() + await #expect(throws: MockSessionError.self) { + try await coordinator.acquire(.capture) {} + } + + // With no holder registered the escalated category must not stick: + // the next playback-only acquire runs under .playback, not the + // leftover .playAndRecord. + let token = try await coordinator.acquire(.playback) {} + #expect(session.categoryCalls == [.playAndRecord, .playback]) + // And the failed acquire left no holder behind: this one was 0->1. + #expect(session.activationCalls == [true]) + coordinator.release(token) + await coordinator.drain() + #expect(session.activationCalls == [true, false]) + } + + // MARK: - Category escalation + + @Test func captureWhilePlaybackEscalatesExactlyOnceAndNeverDowngrades() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + let playback = try await coordinator.acquire(.playback) {} + #expect(session.categoryCalls == [.playback]) + + let capture = try await coordinator.acquire(.capture) {} + #expect(session.categoryCalls == [.playback, .playAndRecord]) + + // More clients of either use don't touch the category again. + let secondCapture = try await coordinator.acquire(.capture) {} + let secondPlayback = try await coordinator.acquire(.playback) {} + #expect(session.categoryCalls == [.playback, .playAndRecord]) + + // Capture ending must not downgrade the route under live playback. + coordinator.release(capture) + coordinator.release(secondCapture) + await coordinator.drain() + #expect(session.categoryCalls == [.playback, .playAndRecord]) + + // Even a fresh playback acquire stays on playAndRecord while held. + let thirdPlayback = try await coordinator.acquire(.playback) {} + #expect(session.categoryCalls == [.playback, .playAndRecord]) + + coordinator.release(playback) + coordinator.release(secondPlayback) + coordinator.release(thirdPlayback) + await coordinator.drain() + } + + @Test func categoryResetsAfterAllHoldersRelease() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + let capture = try await coordinator.acquire(.capture) {} + coordinator.release(capture) + await coordinator.drain() + #expect(session.categoryCalls == [.playAndRecord]) + + // With no holders left the next playback-only session downgrades. + let playback = try await coordinator.acquire(.playback) {} + #expect(session.categoryCalls == [.playAndRecord, .playback]) + coordinator.release(playback) + await coordinator.drain() + } + + @Test func escalationNotifiesExistingHoldersSoEnginesCanRestart() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + var playbackInterruptions = 0 + var captureInterruptions = 0 + let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 } + let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 } + + // The pre-existing playback holder was reconfigured underneath (the + // fan-out is delivered before acquire returns); the newly acquiring + // capture client was not. + #expect(playbackInterruptions == 1) + #expect(captureInterruptions == 0) + + // A second capture doesn't change the category — nobody is notified. + let secondCapture = try await coordinator.acquire(.capture) {} + #expect(playbackInterruptions == 1) + #expect(captureInterruptions == 0) + + coordinator.release(playback) + coordinator.release(capture) + coordinator.release(secondCapture) + await coordinator.drain() + } + + @Test func escalationPrefersCategoryChangeCallbackOverInterruption() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + var escalations = 0 + var interruptions = 0 + let playback = try await coordinator.acquire( + .playback, + onInterrupted: { interruptions += 1 }, + onCategoryEscalated: { escalations += 1 } + ) + + // Escalation reaches the dedicated callback (the holder restarts and + // keeps playing) — not onInterrupted (which would stop it for good). + let capture = try await coordinator.acquire(.capture) {} + #expect(escalations == 1) + #expect(interruptions == 0) + + // A real interruption still stops it. + await coordinator.handleInterruptionBegan() + #expect(escalations == 1) + #expect(interruptions == 1) + + coordinator.release(playback) + coordinator.release(capture) + await coordinator.drain() + } + + // MARK: - Interruptions and route changes + + @Test func interruptionDuringAcquireHandoffCancelsAcquire() async throws { + let session = MockAudioSession() + let handoffGate = AsyncGate() + let coordinator = AudioSessionCoordinator( + session: session, + testingHooks: .init(beforeAcquireHandoff: { + await handoffGate.wait() + }) + ) + + var interruptionCount = 0 + let acquireTask = Task { @MainActor in + try await coordinator.acquire(.capture) { + interruptionCount += 1 + } + } + + // The session-queue registration is complete, but the caller has not + // received its token. An interruption here used to invoke the callback + // immediately, when capture clients could not release the token yet. + await handoffGate.waitUntilEntered() + await coordinator.handleInterruptionBegan() + #expect(interruptionCount == 0) + + await handoffGate.open() + await #expect(throws: CancellationError.self) { + try await acquireTask.value + } + await coordinator.drain() + #expect(interruptionCount == 0) + // The OS already deactivated the interrupted session; removing the + // provisional token must not issue a redundant setActive(false). + #expect(session.activationCalls == [true]) + + // The canceled acquire left no holder behind and the now-open test gate + // does not affect a subsequent ownership handoff. + let replacement = try await coordinator.acquire(.playback) {} + #expect(session.activationCalls == [true, true]) + coordinator.release(replacement) + await coordinator.drain() + #expect(session.activationCalls == [true, true, false]) + } + + @Test func releasedSnapshotCannotInterruptReacquiredToken() async throws { + let session = MockAudioSession() + let deliveryGate = AsyncGate() + let coordinator = AudioSessionCoordinator( + session: session, + testingHooks: .init(beforeCallbackDelivery: { + await deliveryGate.wait() + }) + ) + + // Model a single client whose callback acts on whichever token it owns + // now. If the old snapshot is delivered after reacquisition, it would + // incorrectly release the new session. + var activeToken: AudioSessionCoordinator.Token? + var interruptionCount = 0 + let onInterrupted: @MainActor () -> Void = { + interruptionCount += 1 + activeToken.map(coordinator.release) + } + + let first = try await coordinator.acquire(.playback, onInterrupted: onInterrupted) + activeToken = first + let interruptionTask = Task { + await coordinator.handleInterruptionBegan() + } + + // The queue snapshot contains `first`, but main-actor delivery is held. + await deliveryGate.waitUntilEntered() + coordinator.release(first) + activeToken = nil + await coordinator.drain() + + let second = try await coordinator.acquire(.playback, onInterrupted: onInterrupted) + activeToken = second + #expect(session.activationCalls == [true, true]) + + await deliveryGate.open() + await interruptionTask.value + await coordinator.drain() + #expect(interruptionCount == 0) + // A stale callback would have released `second` and appended false. + #expect(session.activationCalls == [true, true]) + + coordinator.release(second) + activeToken = nil + await coordinator.drain() + #expect(session.activationCalls == [true, true, false]) + } + + @Test func interruptionFansOutToAllHoldersAndResetsActiveState() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + var playbackInterruptions = 0 + var captureInterruptions = 0 + // Capture first so no escalation fan-out muddies the counters. + let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 } + let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 } + #expect(session.activationCalls == [true]) + + await coordinator.handleInterruptionBegan() + #expect(playbackInterruptions == 1) + #expect(captureInterruptions == 1) + // The OS deactivated the session; the coordinator must not issue its + // own setActive(false) on top of it. + #expect(session.activationCalls == [true]) + + // The active state was reset: the next acquire re-activates even + // though holders never released. + let resumed = try await coordinator.acquire(.playback) {} + #expect(session.activationCalls == [true, true]) + + coordinator.release(playback) + coordinator.release(capture) + coordinator.release(resumed) + await coordinator.drain() + #expect(session.activationCalls == [true, true, false]) + } + + @Test func interruptedHoldersReleasingDuringFanOutStaySafe() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + // Real clients release from within onInterrupted (stop() paths); + // release is fire-and-forget onto the coordinator's queue, so it is + // safe from inside the main-actor fan-out. + var tokens: [AudioSessionCoordinator.Token] = [] + for _ in 0..<2 { + var token: AudioSessionCoordinator.Token? + token = try await coordinator.acquire(.playback) { + token.map(coordinator.release) + } + tokens.append(token!) + } + + await coordinator.handleInterruptionBegan() + await coordinator.drain() + // Every holder released mid-fan-out; the session was already + // deactivated by the OS, so no redundant setActive(false). + #expect(session.activationCalls == [true]) + + // All holders are gone: a fresh acquire is 0->1 again. + let token = try await coordinator.acquire(.playback) {} + #expect(session.activationCalls == [true, true]) + coordinator.release(token) + await coordinator.drain() + #expect(session.activationCalls == [true, true, false]) + } + + @Test func routeDeviceUnavailableNotifiesHoldersButKeepsSessionActive() async throws { + let session = MockAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + + var interruptions = 0 + // Capture first so no escalation fan-out muddies the counter. + let capture = try await coordinator.acquire(.capture) { interruptions += 1 } + let playback = try await coordinator.acquire(.playback) { interruptions += 1 } + + await coordinator.handleRouteDeviceUnavailable() + #expect(interruptions == 2) + // Unlike an interruption, the session itself is still active — the + // last holder's release performs the deactivation. + coordinator.release(playback) + coordinator.release(capture) + await coordinator.drain() + #expect(session.activationCalls == [true, false]) + } +} diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index fe7624ec..a1d62cae 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -32,7 +32,7 @@ struct BLEServiceCoreTests { ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) let receivedFirst = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count == 1 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.longTimeout ) #expect(receivedFirst) @@ -114,7 +114,9 @@ struct BLEServiceCoreTests { } @Test - func ingressRejectsDirectAnnounceThatConflictsWithBoundLink() async throws { + func ingressAllowsDirectAnnounceThatConflictsWithBoundLink() async throws { + // Peer-ID rotation heal: the announce must reach signature + // verification, which decides whether the link rebinds. let ble = makeService() let boundPeer = PeerID(str: "1122334455667788") let claimedPeer = PeerID(str: "8899aabbccddeeff") @@ -128,9 +130,392 @@ struct BLEServiceCoreTests { ttl: 7 ) + #expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer)) + } + + @Test + func ingressRejectsRequestSyncThatConflictsWithBoundLink() async throws { + let ble = makeService() + let boundPeer = PeerID(str: "1122334455667788") + let claimedPeer = PeerID(str: "8899aabbccddeeff") + let packet = BitchatPacket( + type: MessageType.requestSync.rawValue, + senderID: Data(hexString: claimedPeer.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(), + signature: nil, + ttl: 0 + ) + #expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer)) } + @Test + func verifiedDirectAnnounceRebindsRotatedLinkAndRetiresOldPeer() async throws { + let ble = makeService() + let oldPeerID = PeerID(str: "1122334455667788") + let centralUUID = "central-rotation" + + // A connected peer whose link binding predates its relaunch. + ble._test_seedConnectedPeer(oldPeerID, nickname: "alice") + ble._test_bindCentral(centralUUID, to: oldPeerID) + + // The relaunched device re-announces its rotated identity over the + // still-open link. + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "alice", + noisePublicKey: signer.getStaticPublicKeyData(), + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode(), "Failed to encode announcement") + let newPeerID = PeerID(publicKey: announcement.noisePublicKey) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: newPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet") + + #expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID)) + ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false) + + let rebound = await TestHelpers.waitUntil( + { ble._test_centralBinding(centralUUID) == newPeerID }, + timeout: TestConstants.longTimeout + ) + #expect(rebound) + + let retired = await TestHelpers.waitUntil( + { + let peerIDs = ble.currentPeerSnapshots().map(\.peerID) + return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID) + }, + timeout: TestConstants.longTimeout + ) + #expect(retired) + } + + @Test + func replayedDirectAnnounceCannotStealBoundIdentity() async throws { + let ble = makeService() + let attackerPeerID = PeerID(str: "1122334455667788") + let victimLink = "central-victim" + let attackerLink = "central-attacker" + + // The victim's identity, genuinely bound on its own link. + let victimSigner = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "victim", + noisePublicKey: victimSigner.getStaticPublicKeyData(), + signingPublicKey: victimSigner.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode(), "Failed to encode announcement") + let victimPeerID = PeerID(publicKey: announcement.noisePublicKey) + let courierStore = CourierStore(persistsToDisk: false) + ble.courierStore = courierStore + let carriedEnvelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: announcement.noisePublicKey, + epochDay: CourierEnvelope.epochDay(for: Date()) + ), + expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), + ciphertext: Data(repeating: 0xA5, count: 128) + ) + #expect(courierStore.deposit( + carriedEnvelope, + from: Data(repeating: 0xC0, count: 32), + tier: .favorite + )) + let sprayRecipientKey = Data(repeating: 0xB4, count: 32) + let sprayEnvelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: sprayRecipientKey, + epochDay: CourierEnvelope.epochDay(for: Date()) + ), + expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), + ciphertext: Data(repeating: 0xB5, count: 128), + copies: 4 + ) + #expect(courierStore.deposit( + sprayEnvelope, + from: Data(repeating: 0xC0, count: 32), + tier: .favorite + )) + ble._test_seedConnectedPeer(victimPeerID, nickname: "victim") + ble._test_bindCentral(victimLink, to: victimPeerID) + ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker") + ble._test_bindCentral(attackerLink, to: attackerPeerID) + + // Preserve the hard case: a valid victim session still exists on the + // victim's own physical link when the announce is replayed elsewhere. + let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) + let message2 = try #require( + try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message1) + ) + let message3 = try #require( + try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2) + ) + _ = try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message3) + #expect(ble.canDeliverSecurely(to: victimPeerID)) + ble._test_markNoiseAuthenticatedCentral(victimLink, to: victimPeerID) + + // The victim's fresh signed announce replayed on the attacker's bound + // link with its direct TTL restored (TTL is excluded from signing, so + // the signature still verifies). + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: victimPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet") + + #expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink)) + ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false) + + // The rebind must be refused: the identity already owns a live link. + let stolen = await TestHelpers.waitUntil( + { ble._test_centralBinding(attackerLink) == victimPeerID }, + timeout: 0.3 + ) + #expect(!stolen) + #expect(ble._test_centralBinding(attackerLink) == attackerPeerID) + #expect(ble._test_centralBinding(victimLink) == victimPeerID) + // A valid signature authenticates the announce contents, not the + // unsigned direct TTL. Without a Noise-authenticated session on the + // ingress link, the replay must not retire mail or consume spray state. + #expect(!courierStore.isEmpty) + await Task.yield() + await Task.yield() + let stillEligibleForSpray = courierStore.takeSprayCopies(for: announcement.noisePublicKey) + #expect(stillEligibleForSpray.map(\.copies) == [2]) + #expect(courierStore.takeEnvelopes(for: announcement.noisePublicKey) == [carriedEnvelope]) + #expect(ble.canDeliverSecurely(to: victimPeerID)) + // And the replay must not retire the link's real bound peer. + #expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID)) + } + + @Test + func replayedDirectAnnounceForAbsentPeerNeverYieldsSecureDelivery() async throws { + // Residual heal-path gap: the victim has NO live link, so the + // identity-owns-a-link containment cannot refuse the rebind. The + // replay steals the link binding, and because a successful rebind + // promotes its new owner to connected (a legitimate rotation heal + // requires that), the absent victim may read as connected. That + // forged presence is display-only and accepted — the invariant that + // holds is that the stolen link can never produce an established + // Noise session, so MessageRouter's canDeliverSecurely gate routes + // DMs through retain + courier instead of trusting it outright. + let ble = makeService() + let attackerPeerID = PeerID(str: "1122334455667788") + let attackerLink = "central-attacker-absent-victim" + ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker") + ble._test_bindCentral(attackerLink, to: attackerPeerID) + + // The absent victim's fresh signed announce, replayed on the + // attacker's bound link with its direct TTL restored. + let victimSigner = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "victim", + noisePublicKey: victimSigner.getStaticPublicKeyData(), + signingPublicKey: victimSigner.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode(), "Failed to encode announcement") + let victimPeerID = PeerID(publicKey: announcement.noisePublicKey) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: victimPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet") + + #expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink)) + ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false) + + // The rebind steals the link (no live link owns the victim's + // identity, so containment cannot refuse) … + let rebound = await TestHelpers.waitUntil( + { ble._test_centralBinding(attackerLink) == victimPeerID }, + timeout: TestConstants.longTimeout + ) + #expect(rebound) + // … and the promote marks the absent victim connected: the accepted, + // display-only forged-presence residue (documented at + // BLEAnnounceHandler's linkBoundToOtherPeer check) … + let forgedPresence = await TestHelpers.waitUntil( + { ble.isPeerConnected(victimPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(forgedPresence) + // … but secure delivery stays impossible — the DM gate holds, and + // MessageRouter retains + couriers instead of trusting the link. + #expect(!ble.canDeliverSecurely(to: victimPeerID)) + } + + @Test + func replayedDirectAnnounceWithStaleVictimSessionCannotBridgeThroughForeignLink() async throws { + let ble = makeService() + // Keep announce handling out of the carried-mail path: the regression + // is specifically BridgeCourierService's direct delivery preflight. + ble.courierStore = CourierStore(persistsToDisk: false) + let attackerPeerID = PeerID(str: "1122334455667788") + let attackerLink = "central-attacker-stale-victim-session" + ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker") + ble._test_bindCentral(attackerLink, to: attackerPeerID) + + let victim = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "victim", + noisePublicKey: victim.getStaticPublicKeyData(), + signingPublicKey: victim.getSigningPublicKeyData(), + directNeighbors: nil + ) + let victimPeerID = PeerID(publicKey: announcement.noisePublicKey) + + // Establish a real peer-level victim session without associating it + // with the attacker's physical link. This is the stale-session case + // that a plain `canDeliverSecurely` check cannot distinguish. + let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) + let message2 = try #require( + try victim.processHandshakeMessage(from: ble.myPeerID, message: message1) + ) + let message3 = try #require( + try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2) + ) + _ = try victim.processHandshakeMessage(from: ble.myPeerID, message: message3) + #expect(ble.canDeliverSecurely(to: victimPeerID)) + + let payload = try #require(announcement.encode(), "Failed to encode announcement") + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: victimPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce") + #expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink)) + ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false) + + let rebound = await TestHelpers.waitUntil( + { ble._test_centralBinding(attackerLink) == victimPeerID }, + timeout: TestConstants.longTimeout + ) + #expect(rebound) + #expect(ble.canDeliverSecurely(to: victimPeerID)) + + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = { outbound.record($0) } + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: announcement.noisePublicKey, + epochDay: CourierEnvelope.epochDay(for: Date()) + ), + expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), + ciphertext: Data(repeating: 0xA5, count: 128) + ) + + #expect(!ble.deliverBridgedEnvelope(envelope, to: victimPeerID)) + // Reject before even entering the outbound pipeline: otherwise a + // real attacker CBCentral could accept the opaque courier packet and + // cause the relay drop's persisted seen ID to be consumed forever. + #expect(outbound.count(ofType: .courierEnvelope) == 0) + } + + /// A legitimate rotation announce necessarily arrives on a link still + /// bound to the OLD ID, so its registry upsert stores the new peer + /// disconnected. The successful rebind must promote it: a healed + /// rotation with a live link has to read as connected again for routing + /// and outbox flushes. + @Test + func rotationHealPromotesRotatedPeerToConnected() async throws { + let ble = makeService() + let oldPeerID = PeerID(str: "1122334455667788") + let centralUUID = "central-rotation-promote" + + ble._test_seedConnectedPeer(oldPeerID, nickname: "alice") + ble._test_bindCentral(centralUUID, to: oldPeerID) + + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "alice", + noisePublicKey: signer.getStaticPublicKeyData(), + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode(), "Failed to encode announcement") + let newPeerID = PeerID(publicKey: announcement.noisePublicKey) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: newPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet") + + #expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID)) + ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false) + + let rebound = await TestHelpers.waitUntil( + { ble._test_centralBinding(centralUUID) == newPeerID }, + timeout: TestConstants.longTimeout + ) + #expect(rebound) + + let connected = await TestHelpers.waitUntil( + { ble.isPeerConnected(newPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(connected) + } + + /// Noise sessions are keyed by the short wire ID, but routers may key + /// sends by the full 64-hex Noise key (favorites resolution does). The + /// secure-delivery gate must normalize like isPeerConnected, or an + /// established session is misread as insecure and every DM needlessly + /// retains + couriers until an ack. + @Test + func canDeliverSecurelyNormalizesFullNoiseKeyPeerIDs() async throws { + let ble = makeService() + let remote = NoiseEncryptionService(keychain: MockKeychain()) + let remoteKey = remote.getStaticPublicKeyData() + let shortID = PeerID(publicKey: remoteKey) + let fullKeyID = PeerID(hexData: remoteKey) + #expect(fullKeyID.toShort() == shortID) + #expect(!ble.canDeliverSecurely(to: shortID)) + + // Full XX handshake; the local side keys the session by the short + // wire ID, exactly as packets present it in production. + let m1 = try ble._test_noiseInitiateHandshake(with: shortID) + let m2 = try #require(try remote.processHandshakeMessage(from: ble.myPeerID, message: m1)) + let m3 = try #require(try ble._test_noiseProcessHandshakeMessage(from: shortID, message: m2)) + _ = try remote.processHandshakeMessage(from: ble.myPeerID, message: m3) + + #expect(ble.canDeliverSecurely(to: shortID)) + #expect(ble.canDeliverSecurely(to: fullKeyID)) + } + @Test func ingressRejectsSelfLoopbackBeforeSpoofChecks() async throws { let ble = makeService() @@ -216,6 +601,69 @@ struct BLEServiceCoreTests { cachedServiceUUIDs: [BLEService.serviceUUID, otherService] )) } + + /// Pings are unsigned, so their claimed sender is attacker-controlled. + /// The pong budget must be keyed on the ingress link (the directly + /// connected peer that delivered the packet): rotating forged sender IDs + /// over one link exhausts one budget instead of resetting it, so a single + /// malicious link cannot turn /ping into an amplification primitive. + @Test + func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws { + let ble = makeService() + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + + let link = PeerID(str: "1122334455667788") + let budget = TransportConfig.meshPingInboundMaxPerLink + let myRecipientData = try #require(Data(hexString: ble.myPeerID.id)) + + for i in 0..<(budget * 2) { + // A fresh forged sender for every ping, all arriving on one link. + let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i)) + var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength) + nonce[0] = UInt8(i) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) + let packet = BitchatPacket( + type: MessageType.ping.rawValue, + senderID: Data(hexString: forgedSender.id) ?? Data(), + recipientID: myRecipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload.encode(), + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false) + } + + let reachedBudget = await TestHelpers.waitUntil( + { outbound.count(ofType: .pong) >= budget }, + timeout: TestConstants.longTimeout + ) + #expect(reachedBudget) + // Give any over-budget pong a chance to surface, then confirm the + // rotated sender IDs never bought a sixth response. + let exceededBudget = await TestHelpers.waitUntil( + { outbound.count(ofType: .pong) > budget }, + timeout: TestConstants.shortTimeout + ) + #expect(!exceededBudget) + #expect(outbound.count(ofType: .pong) == budget) + } +} + +/// Thread-safe capture of packets leaving the service under test. +private final class OutboundPacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func count(ofType type: MessageType) -> Int { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue }.count + } } private func makeService() -> BLEService { diff --git a/bitchatTests/CashuTokenDecoderTests.swift b/bitchatTests/CashuTokenDecoderTests.swift new file mode 100644 index 00000000..890a7eeb --- /dev/null +++ b/bitchatTests/CashuTokenDecoderTests.swift @@ -0,0 +1,350 @@ +// +// CashuTokenDecoderTests.swift +// bitchatTests +// +// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4 +// CBOR traversal, URI normalization, detection ranges, and adversarial +// (truncated / garbage / huge) input. The decoder renders attacker-controlled +// message content, so "never crash" matters as much as "decode correctly". +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +@testable import bitchat + +struct CashuTokenDecoderTests { + + // MARK: - Token Builders + + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func makeV3Token( + entries: [(mint: String, amounts: [Int])], + unit: String? = "sat", + memo: String? = nil + ) -> String { + var json: [String: Any] = [ + "token": entries.map { entry in + [ + "mint": entry.mint, + "proofs": entry.amounts.map { + ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any] + } + ] as [String: Any] + } + ] + if let unit { json["unit"] = unit } + if let memo { json["memo"] = memo } + let data = try! JSONSerialization.data(withJSONObject: json) + return "cashuA" + base64URL(data) + } + + /// Tiny deterministic CBOR encoder (definite lengths only) for building + /// V4 test tokens without depending on the decoder under test. + private enum CBOREncode { + static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { + switch value { + case 0...23: + return [(major << 5) | UInt8(value)] + case 24...0xFF: + return [(major << 5) | 24, UInt8(value)] + case 0x100...0xFFFF: + return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + default: + return [(major << 5) | 26, + UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + } + } + static func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + static func text(_ s: String) -> [UInt8] { + let utf8 = Array(s.utf8) + return head(3, UInt64(utf8.count)) + utf8 + } + static func array(_ items: [[UInt8]]) -> [UInt8] { + head(4, UInt64(items.count)) + items.flatMap { $0 } + } + static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { + head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } + } + } + + private func makeV4Token( + mint: String = "https://mint.example.com", + unit: String = "sat", + memo: String? = nil, + amounts: [UInt64] = [1, 4] + ) -> String { + var pairs: [(String, [UInt8])] = [ + ("m", CBOREncode.text(mint)), + ("u", CBOREncode.text(unit)) + ] + if let memo { pairs.append(("d", CBOREncode.text(memo))) } + let proofs = amounts.map { amount in + CBOREncode.map([ + ("a", CBOREncode.uint(amount)), + ("s", CBOREncode.text("secret")), + ("c", CBOREncode.bytes([0x02, 0xAB, 0xCD])) + ]) + } + pairs.append(("t", CBOREncode.array([ + CBOREncode.map([ + ("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])), + ("p", CBOREncode.array(proofs)) + ]) + ]))) + return "cashuB" + base64URL(Data(CBOREncode.map(pairs))) + } + + // MARK: - V3 Decode + + @Test func v3DecodeValidToken() { + let token = makeV3Token( + entries: [("https://mint.example.com", [2, 8])], + unit: "sat", + memo: "thanks!" + ) + let info = CashuTokenDecoder.decode(token) + #expect(info != nil) + #expect(info?.version == "A") + #expect(info?.amount == 10) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "thanks!") + #expect(info?.displayAmount == "10 sat") + } + + @Test func v3AmountSumsAcrossEntriesAndProofs() { + let token = makeV3Token(entries: [ + ("https://a.mint.example", [1, 2, 4]), + ("https://b.mint.example", [8, 16]) + ]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.amount == 31) + // First mint wins for the display host + #expect(info?.mintHost == "a.mint.example") + } + + @Test func v3MissingUnitDefaultsToSatForDisplay() { + let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil) + let info = CashuTokenDecoder.decode(token) + #expect(info?.unit == nil) + #expect(info?.displayAmount == "5 sat") + } + + @Test func v3RejectsNonsenseAmounts() { + // Negative and absurd amounts must not poison the sum + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [ + ["amount": -5, "id": "x", "secret": "s", "C": "c"], + ["amount": 3, "id": "x", "secret": "s", "C": "c"] + ] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == 3) + } + + @Test func v3MemoIsSanitizedForDisplay() { + let token = makeV3Token( + entries: [("https://mint.example.com", [1])], + memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300) + ) + let memo = CashuTokenDecoder.decode(token)?.memo + #expect(memo != nil) + #expect(memo?.contains("\n") == false) + #expect(memo?.contains("\u{0007}") == false) + #expect((memo?.count ?? 0) <= 80) + } + + // MARK: - V4 (CBOR) Decode + + @Test func v4DecodeValidToken() { + let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == 21) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "Thank you") + } + + @Test func v4UnparseableCBORDegradesToGenericToken() { + // Valid base64 payload, but not CBOR we can walk: still a token, + // rendered as a generic chip with no amount. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == nil) + #expect(info?.mintHost == nil) + } + + // MARK: - Strict Mode (used by the /pay SEND path) + + @Test func strictAcceptsValidV3WithPositiveAmount() { + let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "A") + #expect(info?.amount == 10) + } + + @Test func strictAcceptsValidDefiniteLengthV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "B") + #expect(info?.amount == 21) + } + + @Test func strictRejectsUnwalkableV4() { + // Valid base64, but not CBOR we can walk: permissive mode returns a + // generic chip, strict mode refuses it. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + #expect(CashuTokenDecoder.decode(token)?.version == "B") + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + @Test func strictRejectsTruncatedV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + // Lop off the tail of the base64 payload — CBOR can no longer be walked. + let truncated = String(token.prefix(token.count - 12)) + #expect(CashuTokenDecoder.decode(truncated, strict: true) == nil) + } + + @Test func strictRejectsAmountlessToken() { + // A well-formed V3 token that carries no positive proof amount. + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == nil) + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + // MARK: - URI Form and Normalization + + @Test func uriFormsDecode() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] { + #expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)") + #expect(CashuTokenDecoder.decode(wrapped)?.amount == 7) + } + } + + @Test func percentEncodedURIDecodes() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + #expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7) + } + + @Test func bareTokenRejectsNonTokens() { + #expect(CashuTokenDecoder.bareToken(from: "hello world") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short + } + + // MARK: - Adversarial Input (never crash, fail closed) + + @Test func truncatedTokensNeverCrash() { + let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo") + let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8]) + for token in [v3, v4] { + for length in stride(from: 0, to: token.count, by: 3) { + _ = CashuTokenDecoder.decode(String(token.prefix(length))) + } + } + // Truncating the payload must not produce a phantom V3 summary + #expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil) + } + + @Test func garbagePayloadsNeverCrash() { + var rng = SystemRandomNumberGenerator() + for _ in 0..<200 { + let length = Int.random(in: 0..<600, using: &rng) + let junk = Data((0.. Bool { routedReadReceipts.append((receipt.originalMessageID, peerID)) + return routeReadReceiptResult } func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { @@ -214,10 +216,10 @@ struct ChatLifecycleCoordinatorContextTests { // Same message under both keys: the read copy must win over sent. context.privateChats[peerID] = [ makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent), - makePrivateMessage(id: "m2", timestamp: t2), + makePrivateMessage(id: "m2", timestamp: t2) ] context.privateChats[stablePeerID] = [ - makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)), + makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)) ] let merged = coordinator.getPrivateChatMessages(for: peerID) @@ -245,7 +247,7 @@ struct ChatLifecycleCoordinatorContextTests { makePrivateMessage(id: "m1", senderPeerID: convKey), makePrivateMessage(id: "already-acked", senderPeerID: convKey), makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true), - makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID), + makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID) ] coordinator.markPrivateMessagesAsRead(from: convKey) @@ -328,7 +330,7 @@ struct ChatLifecycleCoordinatorContextTests { #expect(context.ownerLevelReadPasses == [peerID]) } @Test @MainActor - func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() { + func markPrivateMessagesAsRead_routesReceiptsForFavoritesAndNonFavorites() { let context = MockChatLifecycleContext() let coordinator = ChatLifecycleCoordinator(context: context) let noiseKey = Data(repeating: 0xAB, count: 32) @@ -339,7 +341,7 @@ struct ChatLifecycleCoordinatorContextTests { ) context.privateChats[peerID] = [ makePrivateMessage(id: "in-1", senderPeerID: peerID), - makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true), + makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true) ] coordinator.markPrivateMessagesAsRead(from: peerID) @@ -351,12 +353,15 @@ struct ChatLifecycleCoordinatorContextTests { #expect(context.routedReadReceipts.map(\.peerID) == [peerID]) #expect(context.sentReadReceipts.contains("in-1")) - // No favorite relationship (no Nostr key): the receipt pass is skipped. + // No favorite relationship: receipts still route — the router picks + // whatever transport can reach the peer (mesh included). Gating on a + // stored Nostr key silently starved mesh-connected non-favorites. let otherKey = Data(repeating: 0xCD, count: 32) let otherPeer = PeerID(hexData: otherKey) context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)] coordinator.markPrivateMessagesAsRead(from: otherPeer) - #expect(context.routedReadReceipts.map(\.messageID) == ["in-1"]) + #expect(context.routedReadReceipts.map(\.messageID) == ["in-1", "in-2"]) + #expect(context.sentReadReceipts.contains("in-2")) } } diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift new file mode 100644 index 00000000..18e25ef4 --- /dev/null +++ b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift @@ -0,0 +1,622 @@ +// +// ChatLiveVoiceCoordinatorTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +@MainActor +private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { + var nickname = "me" + var selectedPrivateChatPeer: PeerID? + var isViewingPublicMeshTimeline = false + var blockedPeers: Set = [] + + private(set) var handledPrivateMessages: [BitchatMessage] = [] + private(set) var appendedPublicMessages: [BitchatMessage] = [] + private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = [] + private(set) var upsertedPublicMessages: [BitchatMessage] = [] + private(set) var removedMessageIDs: [String] = [] + private(set) var talkerUpdates: [String?] = [] + + func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } + func resolveNickname(for peerID: PeerID) -> String { "alice" } + func handlePrivateMessage(_ message: BitchatMessage) { handledPrivateMessages.append(message) } + func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) } + func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { + upsertedMessages.append((message, peerID)) + } + func upsertPublicMeshMessage(_ message: BitchatMessage) { + upsertedPublicMessages.append(message) + } + @discardableResult + func removePrivateMessage(withID messageID: String) -> BitchatMessage? { + removedMessageIDs.append(messageID) + return nil + } + func removeMessage(withID messageID: String, cleanupFile: Bool) { + removedMessageIDs.append(messageID) + } + func setActivePublicVoiceTalker(_ nickname: String?) { + if talkerUpdates.last ?? nil != nickname { + talkerUpdates.append(nickname) + } + } + func notifyUIChanged() {} +} + +@MainActor +struct ChatLiveVoiceCoordinatorTests { + private let peer = PeerID(str: "aaaabbbbcccc0001") + + private func makeBurstID(_ fill: UInt8) -> Data { + Data(repeating: fill, count: VoiceBurstPacket.burstIDSize) + } + + private func send(_ packet: VoiceBurstPacket, to coordinator: ChatLiveVoiceCoordinator, from peerID: PeerID) { + coordinator.handleVoiceFramePayload(from: peerID, payload: packet.encode(), timestamp: Date()) + } + + private func captureSuffix(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> String { + "\(burstID.hexEncodedString())_\(peerID.id)_\(scope == .directMessage ? "dm" : "mesh").aac" + } + + /// Name of a capture still streaming in. + private func liveCaptureName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String { + "voice_live_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope) + } + + /// Name a finished capture is promoted to when it becomes the bubble's + /// replayable fallback. + private func fallbackName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String { + "voice_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope) + } + + private func incomingFileURL(named name: String) -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false + ) else { return nil } + return base + .appendingPathComponent("files/voicenotes/incoming", isDirectory: true) + .appendingPathComponent(name) + } + + private func incomingFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? { + incomingFileURL(named: liveCaptureName(burstID: burstID, peerID: peerID, scope: scope)) + } + + private func fallbackFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? { + incomingFileURL(named: fallbackName(burstID: burstID, peerID: peerID, scope: scope)) + } + + /// Fresh store rooted in its own temp directory so quota/sweep tests + /// never touch the shared application-support media directories. + private func makeTempStore() throws -> (store: BLEIncomingFileStore, incoming: URL, cleanup: () -> Void) { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("ptt-store-\(UUID().uuidString)", isDirectory: true) + let store = BLEIncomingFileStore(baseDirectory: base) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + return (store, incoming, { try? FileManager.default.removeItem(at: base) }) + } + + private func setModificationDate(_ date: Date, at url: URL) throws { + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + } + + @Test func burstCreatesBubbleAndPersistsFramesInOrder() throws { + let context = MockChatLiveVoiceContext() + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + let burstID = makeBurstID(0xA1) + defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } + + let frame1 = Data(repeating: 0x01, count: 60) + let frame2 = Data(repeating: 0x02, count: 60) + + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) + #expect(context.handledPrivateMessages.count == 1) + let bubble = try #require(context.handledPrivateMessages.first) + #expect(bubble.isPrivate) + #expect(bubble.senderPeerID == peer) + #expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString())_\(peer.id)_dm.aac") + #expect(coordinator.isLiveVoiceMessage(bubble)) + + // Deliver out of order: seq 2 buffers behind the seq-1 hole, then + // seq 1 releases both in order. + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .frames([frame2]))), to: coordinator, from: peer) + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame1]))), to: coordinator, from: peer) + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 3, kind: .end(totalDataPackets: 2, durationMs: 128))), to: coordinator, from: peer) + + // The finished capture is promoted off its voice_live_ name. + let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) + let written = try Data(contentsOf: url) + var expected = ADTSFramer.frame(frame1) + expected.append(ADTSFramer.frame(frame2)) + #expect(written == expected) + let liveURL = try #require(incomingFileURL(burstID: burstID, peerID: peer)) + #expect(!FileManager.default.fileExists(atPath: liveURL.path)) + + // Burst ended: no longer live, bubble republished pointing at the + // promoted file. + #expect(!coordinator.isLiveVoiceMessage(bubble)) + let republished = try #require(context.upsertedMessages.last { $0.message.id == bubble.id }) + #expect(republished.message.content == "[voice] \(fallbackName(burstID: burstID, peerID: peer))") + #expect(context.removedMessageIDs.isEmpty) + } + + @Test func absorbsFinalizedNoteIntoLiveBubble() throws { + let context = MockChatLiveVoiceContext() + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + let burstID = makeBurstID(0xB2) + let hex = burstID.hexEncodedString() + + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer) + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer) + let bubble = try #require(context.handledPrivateMessages.first) + + let note = BitchatMessage( + sender: "alice", + content: "[voice] voice_\(hex).m4a", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: peer + ) + #expect(coordinator.absorbFinalizedVoiceNote(note)) + + // The note replaced the live bubble in place: same message ID, new + // content, partial capture deleted. + let replacement = try #require(context.upsertedMessages.last) + #expect(replacement.message.id == bubble.id) + #expect(replacement.message.content == note.content) + #expect(replacement.peerID == peer) + // The promoted partial capture is deleted in favor of the note. + let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + + // Absorption is one-shot. + #expect(!coordinator.absorbFinalizedVoiceNote(note)) + } + + @Test func absorbIgnoresUnrelatedVoiceNotes() throws { + let context = MockChatLiveVoiceContext() + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + + // A classic voice note (date-stamped name) and a live-capture name + // must both pass through untouched. + let classic = BitchatMessage( + sender: "alice", content: "[voice] voice_20260708_1201.m4a", timestamp: Date(), + isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer + ) + #expect(!coordinator.absorbFinalizedVoiceNote(classic)) + let liveCapture = BitchatMessage( + sender: "alice", content: "[voice] voice_live_aabbccdd00112233.aac", timestamp: Date(), + isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer + ) + #expect(!coordinator.absorbFinalizedVoiceNote(liveCapture)) + // Unknown burst ID. + let unknown = BitchatMessage( + sender: "alice", content: "[voice] voice_ffffffffffffffff.m4a", timestamp: Date(), + isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer + ) + #expect(!coordinator.absorbFinalizedVoiceNote(unknown)) + } + + @Test func canceledBurstRemovesBubbleAndFile() throws { + let context = MockChatLiveVoiceContext() + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + let burstID = makeBurstID(0xC3) + + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 9, count: 40)]))), to: coordinator, from: peer) + let bubble = try #require(context.handledPrivateMessages.first) + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .canceled)), to: coordinator, from: peer) + + #expect(context.removedMessageIDs == [bubble.id]) + let url = try #require(incomingFileURL(burstID: burstID, peerID: peer)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(!coordinator.isLiveVoiceMessage(bubble)) + } + + @Test func emptyBurstLeavesNoBubble() throws { + let context = MockChatLiveVoiceContext() + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + let burstID = makeBurstID(0xD4) + + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) + let bubble = try #require(context.handledPrivateMessages.first) + send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .end(totalDataPackets: 0, durationMs: 0))), to: coordinator, from: peer) + + // Nothing audible arrived: the placeholder bubble is withdrawn. + #expect(context.removedMessageIDs == [bubble.id]) + } + + @Test func ignoresBlockedPeersAndUnknownControlPackets() throws { + let context = MockChatLiveVoiceContext() + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + + context.blockedPeers = [peer] + send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE5), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) + #expect(context.handledPrivateMessages.isEmpty) + + context.blockedPeers = [] + // END/CANCELED for a burst that never started must not create state. + send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE6), seq: 5, kind: .end(totalDataPackets: 4, durationMs: 256))), to: coordinator, from: peer) + send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE7), seq: 5, kind: .canceled)), to: coordinator, from: peer) + #expect(context.handledPrivateMessages.isEmpty) + } + + @Test func concurrentAssemblyCapDropsExtraBursts() throws { + let context = MockChatLiveVoiceContext() + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + + var cleanup: [Data] = [] + defer { + for burstID in cleanup { + incomingFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } + } + } + for i in 0.. [String] { [] } @@ -213,8 +213,6 @@ private final class MockChatNostrContext: ChatNostrContext { // Favorites & notifications var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] - private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = [] - private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = [] private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = [] func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { @@ -225,14 +223,6 @@ private final class MockChatNostrContext: ChatNostrContext { Array(favoriteRelationshipsByNoiseKey.values) } - func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { - addedFavorites.append((noiseKey, nostrPublicKey, nickname)) - } - - func postLocalNotification(title: String, body: String, identifier: String) { - postedLocalNotifications.append((title, body, identifier)) - } - func notifyGeohashActivity(geohash: String, bodyPreview: String) { geohashActivityNotifications.append((geohash, bodyPreview)) } @@ -249,24 +239,6 @@ private func drainMainQueue() async { } } -private func makeFavoriteRelationship( - noiseKey: Data, - nostrPublicKey: String? = nil, - nickname: String = "alice", - isFavorite: Bool = false, - theyFavoritedUs: Bool = false -) -> FavoritesPersistenceService.FavoriteRelationship { - FavoritesPersistenceService.FavoriteRelationship( - peerNoisePublicKey: noiseKey, - peerNostrPublicKey: nostrPublicKey, - peerNickname: nickname, - isFavorite: isFavorite, - theyFavoritedUs: theyFavoritedUs, - favoritedAt: Date(timeIntervalSince1970: 0), - lastUpdated: Date(timeIntervalSince1970: 0) - ) -} - // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no @@ -608,34 +580,6 @@ struct GeoPresenceTrackerTests { #expect(stamped > stale) #expect(context.appendedGeohashMessages.count == 1) } - @Test @MainActor - func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - let sender = try NostrIdentity.generate() - let noiseKey = Data(repeating: 0x42, count: 32) - // The favorites store bridges the sender's npub back to a Noise key. - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - nostrPublicKey: sender.npub - ) - - coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex) - - #expect(context.addedFavorites.count == 1) - #expect(context.addedFavorites.first?.noiseKey == noiseKey) - #expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex) - #expect(context.addedFavorites.first?.nickname == "alice") - #expect(context.postedLocalNotifications.count == 1) - #expect(context.postedLocalNotifications.first?.title == "New Favorite") - #expect(context.postedLocalNotifications.first?.body == "alice favorited you") - - // Unfavorite: no store write, but the removal notification still posts. - coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex) - #expect(context.addedFavorites.count == 1) - #expect(context.postedLocalNotifications.last?.title == "Favorite Removed") - #expect(context.postedLocalNotifications.last?.body == "alice unfavorited you") - } @Test @MainActor func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws { diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift index 6ed7c6da..34963f0a 100644 --- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -87,6 +87,11 @@ private final class MockChatOutgoingContext: ChatOutgoingContext { sentGeohashContexts.append(context) } + private(set) var bridgedMessages: [(content: String, senderPeerID: PeerID, timestamp: Date)] = [] + func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) { + bridgedMessages.append((content, senderPeerID, timestamp)) + } + // Geohash identity struct IdentityUnavailable: Error {} var deriveNostrIdentityError: Error? @@ -192,6 +197,9 @@ struct ChatOutgoingCoordinatorContextTests { context.isTeleported = true coordinator.sendMessage("hello geo") + // Geohash sends mine a NIP-13 nonce tag off-main before echoing and + // sending; await the send task, then drain the main queue. + await coordinator.geohashMiningTask?.value await drainMainActorTasks() // Local echo carries the geohash sender suffix (#last-4-of-pubkey) and @@ -215,4 +223,35 @@ struct ChatOutgoingCoordinatorContextTests { #expect(context.appendedPublicMessages.count == 1) #expect(context.sentGeohashContexts.count == 1) } + + @Test @MainActor + func sendMessage_onLocationChannel_serializesRapidSendsInSendOrder() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + let channel = GeohashChannel(level: .city, geohash: "u4pruydq") + context.activeChannel = .location(channel) + + // Two back-to-back sends. The first carries much larger content, so + // its NIP-13 mining hashes a bigger event per attempt and runs longer + // than the second's. Without serialization the second (faster) task + // could finish first and reorder both the local timeline and the + // relayed events. The coordinator chains the mining tasks — each send + // awaits the previous send's task before it echoes and relays — so the + // visible order must always match the send order. + let first = "first " + String(repeating: "x", count: 4000) + let second = "second" + coordinator.sendMessage(first) + coordinator.sendMessage(second) + + // The stored task is the second send, which awaits the first. + await coordinator.geohashMiningTask?.value + await drainMainActorTasks() + + // Local echoes land in send order… + #expect(context.appendedPublicMessages.map(\.message.content) == [first, second]) + // …and so do the relayed events (IDs match the echoes 1:1, in order). + #expect(context.sentGeohashContexts.count == 2) + #expect(context.sentGeohashContexts.map(\.event.id) + == context.appendedPublicMessages.map(\.message.id)) + } } diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index b756999b..bbdda609 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -29,7 +29,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { var unreadPrivateMessages: Set = [] var selectedPrivateChatPeer: PeerID? var selectedPrivateChatFingerprint: String? - var nickname = "me" var myPeerID = PeerID(str: "0011223344556677") var activeChannel: ChannelID = .mesh private(set) var notifyUIChangedCount = 0 diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 62d1c538..079da987 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -68,6 +68,13 @@ private final class MockChatPeerListContext: ChatPeerListContext { func notifyNetworkAvailable(peerCount: Int) { networkAvailableNotifications.append(peerCount) } + + // Sightings + private(set) var recordedSightings: [[PeerID]] = [] + + func recordMeshSightings(peerIDs: [PeerID]) { + recordedSightings.append(peerIDs) + } } // MARK: - Helpers @@ -154,18 +161,18 @@ struct ChatPeerListCoordinatorContextTests { peerID: currentPeer, noisePublicKey: Data(repeating: 0x01, count: 32), nickname: "alice" - ), + ) ] context.unreadPrivateMessages = [ currentPeer, staleShortPeer, geoDMWithMessages, geoDMWithoutMessages, - noiseKeyWithMessages, + noiseKeyWithMessages ] context.privateChats = [ geoDMWithMessages: [makeMessage(id: "geo-1")], - noiseKeyWithMessages: [makeMessage(id: "noise-1")], + noiseKeyWithMessages: [makeMessage(id: "noise-1")] ] coordinator.didUpdatePeerList([currentPeer]) diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 2a19afe9..5fad9fd0 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -80,7 +80,7 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC @discardableResult func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { - guard var chat = privateChats[peerID], + guard let chat = privateChats[peerID], let index = chat.firstIndex(where: { $0.id == messageID }) else { return false } @@ -100,11 +100,6 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC unreadPrivateMessages.remove(peerID) } - func removePrivateChat(_ peerID: PeerID) { - privateChats.removeValue(forKey: peerID) - unreadPrivateMessages.remove(peerID) - } - func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { migratedChats.append((oldPeerID, newPeerID)) guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return } @@ -177,23 +172,19 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC // Routing & acknowledgements private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, messageID: String)] = [] private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] - private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var meshReadReceipts: [(messageID: String, peerID: PeerID)] = [] private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] - private(set) var embeddedDeliveryAckMessageIDs: [String] = [] func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { routedPrivateMessages.append((content, peerID, messageID)) } - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + var routeReadReceiptResult = true + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { routedReadReceipts.append((receipt.originalMessageID, peerID)) - } - - func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - routedFavoriteNotifications.append((peerID, isFavorite)) + return routeReadReceiptResult } func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -212,10 +203,6 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC geoReadReceipts.append((messageID, recipientHex)) } - func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) { - embeddedDeliveryAckMessageIDs.append(message.id) - } - // Favorites & notifications var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = [] @@ -225,6 +212,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC favoriteRelationshipsByNoiseKey[noiseKey] } + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value + } + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey)) } @@ -234,17 +225,18 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC } // System messages - private(set) var systemMessages: [String] = [] private(set) var meshOnlySystemMessages: [String] = [] - func addSystemMessage(_ content: String) { - systemMessages.append(content) - } - func addMeshOnlySystemMessage(_ content: String) { meshOnlySystemMessages.append(content) } + private(set) var privateSystemMessages: [(content: String, peerID: PeerID)] = [] + + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { + privateSystemMessages.append((content, peerID)) + } + static let dummyIdentity = NostrIdentity( privateKey: Data(repeating: 0x11, count: 32), publicKey: Data(repeating: 0x22, count: 32), @@ -352,7 +344,7 @@ struct ChatPrivateConversationCoordinatorContextTests { context.displayNamesByPubkey[senderPubkey] = "alice#1234" context.privateChats[convKey] = [ makeIncomingMessage(id: "mine-1", sender: "me"), - makeIncomingMessage(id: "mine-2", sender: "me"), + makeIncomingMessage(id: "mine-2", sender: "me") ] coordinator.handleDelivered( @@ -549,14 +541,14 @@ struct ChatPrivateConversationCoordinatorContextTests { } @Test @MainActor - func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async { + func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async { let context = MockChatPrivateConversationContext() let coordinator = ChatPrivateConversationCoordinator(context: context) let noiseKey = Data(repeating: 0xAB, count: 32) let peerID = PeerID(hexData: noiseKey) // First [FAVORITED] flips theyFavoritedUs: store write + announcement. - coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") #expect(context.peerFavoritedUsUpdates.count == 1) #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) #expect(context.peerFavoritedUsUpdates.first?.favorited == true) @@ -568,16 +560,79 @@ struct ChatPrivateConversationCoordinatorContextTests { noiseKey: noiseKey, theyFavoritedUs: true ) - coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") #expect(context.peerFavoritedUsUpdates.count == 2) #expect(context.meshOnlySystemMessages == ["alice favorited you"]) // [UNFAVORITED] transition announces again. - coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice") + coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice") #expect(context.peerFavoritedUsUpdates.last?.favorited == false) #expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"]) } + /// A Nostr DM whose sender resolved to a known noise key must be labeled + /// with the favorite's nickname, not the geohash-scoped anon fallback. + @Test @MainActor + func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xDA, count: 32) + let convKey = PeerID(hexData: noiseKey) + let senderPubkey = "0badc0de00112233" + // No displayNamesByPubkey entry: the geo fallback would be "anon". + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1bob", + nickname: "bob", + isFavorite: true, + theyFavoritedUs: true + ) + + let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()! + let payload = NoisePayload(type: .privateMessage, data: payloadData) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + #expect(context.privateChats[convKey]?.first?.sender == "bob") + } + + /// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey + /// path; they must update the relationship, not render as chat text. + @Test @MainActor + func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xEE, count: 32) + // The inbound pipeline resolves known favorites to their noise-key ID. + let convKey = PeerID(hexData: noiseKey) + let senderPubkey = "feedface99887766" + context.displayNamesByPubkey[senderPubkey] = "alice#1234" + + let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()! + let payload = NoisePayload(type: .privateMessage, data: payloadData) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + #expect(context.peerFavoritedUsUpdates.count == 1) + #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) + #expect(context.peerFavoritedUsUpdates.first?.favorited == true) + #expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice") + #expect(context.privateChats[convKey, default: []].isEmpty) + #expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"]) + } + @Test @MainActor func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async { let context = MockChatPrivateConversationContext() @@ -599,22 +654,47 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.routedPrivateMessages.map(\.content) == ["hello bob"]) #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent) #expect(context.privateChats[peerID]?.first?.recipientNickname == "bob") - #expect(context.systemMessages.isEmpty) } + /// Same as above, but the conversation is keyed by the SHORT mesh ID — + /// the DM window was opened while the peer was on mesh, then they went + /// out of range. The favorite must resolve via the derived short ID and + /// route over Nostr instead of failing "peer not reachable". @Test @MainActor - func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async { + func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xCE, count: 32) + let shortID = PeerID(publicKey: noiseKey) + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1bob", + nickname: "bob", + isFavorite: true, + theyFavoritedUs: true + ) + + coordinator.sendPrivateMessage("hello again", to: shortID) + + #expect(context.routedPrivateMessages.map(\.content) == ["hello again"]) + #expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent) + #expect(context.privateChats[shortID]?.first?.recipientNickname == "bob") + } + + /// Field-found: pre-judging reachability here marked the message failed + /// without ever routing it, so the router's retained outbox, courier + /// deposits, and bridge drops never got a chance. A fully unreachable + /// non-favorite must still be routed and stay "sending" (the router's + /// callbacks later move it to carried/delivered or expire it as failed). + @Test @MainActor + func sendPrivateMessage_routesAndStaysSendingWhenOfflineWithoutMutualFavorite() async { let context = MockChatPrivateConversationContext() let coordinator = ChatPrivateConversationCoordinator(context: context) let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32)) coordinator.sendPrivateMessage("hello?", to: peerID) - #expect(context.routedPrivateMessages.isEmpty) - #expect(context.systemMessages.count == 1) - guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else { - Issue.record("expected .failed delivery status") - return - } + #expect(context.routedPrivateMessages.map(\.content) == ["hello?"]) + #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sending) } } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 9bcd2b93..54858217 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -58,11 +58,6 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon return true } - @discardableResult - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - appendPublicMessage(message, to: .geohash(geohash.lowercased())) - } - func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool { conversations[conversationID]?.contains(where: { $0.id == messageID }) == true } @@ -186,7 +181,7 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon // Inbound public message processing var blockedMessageIDs: Set = [] var rateLimitAllowed = true - private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = [] + private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = [] private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = [] var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) } var stablePeerIDs: [PeerID: PeerID] = [:] @@ -199,8 +194,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon blockedMessageIDs.contains(message.id) } - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { - rateLimitChecks.append((senderKey, contentKey)) + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { + rateLimitChecks.append((senderKey, contentKey, powBits)) return rateLimitAllowed } diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index d7e6c863..88d118f3 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -121,9 +121,11 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { // Routing & acknowledgements private(set) var flushedOutboxPeerIDs: [PeerID] = [] + private(set) var courierRetryPeerIDs: [PeerID] = [] private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) } + func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) } func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { meshDeliveryAcks.append((messageID, peerID)) } @@ -154,6 +156,31 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { verifyResponsePayloads.append((peerID, payload)) } + + // Group payloads + private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = [] + private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupInvitePayloads.append((peerID, payload)) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupKeyUpdatePayloads.append((peerID, payload)) + } + + private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleVouchPayload(from peerID: PeerID, payload: Data) { + vouchPayloads.append((peerID, payload)) + } + + // Live voice payloads + private(set) var voiceFramePayloads: [(peerID: PeerID, payload: Data, timestamp: Date)] = [] + + func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { + voiceFramePayloads.append((peerID, payload, timestamp)) + } } // MARK: - Helpers @@ -259,7 +286,7 @@ struct ChatTransportEventCoordinatorContextTests { context.privateChats[peerID] = [ makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID), makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), - makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID), + makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID) ] coordinator.didDisconnectFromPeer(peerID) await drainMainActorTasks() @@ -282,7 +309,7 @@ struct ChatTransportEventCoordinatorContextTests { context.unreadPrivateMessages = [peerID] context.privateChats[peerID] = [ makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID), - makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), + makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID) ] coordinator.didDisconnectFromPeer(peerID) diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 4ef7f9f7..326ab453 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -51,6 +51,9 @@ private final class MockChatVerificationContext: ChatVerificationContext { func saveIdentityState() { saveIdentityStateCount += 1 } + private(set) var vouchToConnectedVerifiedPeersCount = 0 + func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 } + // Encryption status private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] private(set) var updatedEncryptionStatusPeers: [PeerID] = [] @@ -265,7 +268,7 @@ struct ChatVerificationCoordinatorContextTests { context.verifiedFingerprints = ["fp-verified"] coordinator.setupNoiseCallbacks() - let callbacks = try? #require(context.installedCallbacks) + let callbacks = context.installedCallbacks // Authenticated with a verified fingerprint -> verified status and a // cached stable peer ID derived from the session key. diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 78365b66..fca755f2 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -67,6 +67,113 @@ struct ChatViewModelDeliveryStatusTests { }()) } + @Test @MainActor + func deliveryStatus_noDowngrade_carriedToSent() async { + // Regression: the optimistic `.sent` stamp the send path writes after + // routing must not clobber the `.carried` the router already set when + // it handed a copy to a courier/bridge (store-and-forward), or the + // offline-favorite flow shows "sent" instead of 📦 carried. + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-carried" + + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .carried + ) + viewModel.seedPrivateChat([message], for: peerID) + + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent) + + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .carried = currentStatus { return true } + return false + }()) + } + + @Test @MainActor + func deliveryStatus_noDowngrade_carriedToSending() async { + // Regression: a pre-handshake resend stamps `.sending`; it must not + // wipe the 📦 carried indicator (nor a delivered/read ack). + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-carried-sending" + + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .carried + ) + viewModel.seedPrivateChat([message], for: peerID) + + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sending) + + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .carried = currentStatus { return true } + return false + }()) + + #expect(Conversation.shouldSkipStatusUpdate(current: .delivered(to: "Peer", at: Date()), new: .sending)) + #expect(Conversation.shouldSkipStatusUpdate(current: .read(by: "Peer", at: Date()), new: .sending)) + #expect(Conversation.shouldSkipStatusUpdate( + current: .delivered(to: "Peer", at: Date()), + new: .failed(reason: "late transport failure") + )) + #expect(Conversation.shouldSkipStatusUpdate( + current: .read(by: "Peer", at: Date()), + new: .failed(reason: "late transfer failure") + )) + // A late async `.sending` (pre-handshake resend) must not visibly + // downgrade a truthful "Sent" either... + #expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending)) + // ...but a retry after a real failure stays visible. + #expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending)) + } + + @Test @MainActor + func deliveryStatus_upgrade_carriedToDelivered() async { + // A delivery ack must still promote a carried message. + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-carried-delivered" + + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .carried + ) + viewModel.seedPrivateChat([message], for: peerID) + + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) + + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .delivered = currentStatus { return true } + return false + }()) + } + @Test @MainActor func deliveryStatus_upgrade_sentToDelivered() async { let (viewModel, transport) = makeTestableViewModel() @@ -428,12 +535,13 @@ struct ChatViewModelDeliveryStatusTests { @Test @MainActor func statusRank_orderingIsCorrect() async { // This tests the implicit ordering used in refreshVisibleMessages - // failed < sending < sent < partiallyDelivered < delivered < read + // failed < sending < sent < carried < partiallyDelivered < delivered < read let statuses: [DeliveryStatus] = [ .failed(reason: "test"), .sending, .sent, + .carried, .partiallyDelivered(reached: 1, total: 3), .delivered(to: "B", at: Date()), .read(by: "C", at: Date()) @@ -446,9 +554,10 @@ struct ChatViewModelDeliveryStatusTests { case .failed: #expect(index == 0) case .sending: #expect(index == 1) case .sent: #expect(index == 2) - case .partiallyDelivered: #expect(index == 3) - case .delivered: #expect(index == 4) - case .read: #expect(index == 5) + case .carried: #expect(index == 3) + case .partiallyDelivered: #expect(index == 4) + case .delivered: #expect(index == 5) + case .read: #expect(index == 6) } } } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 3662f55a..0097a368 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -71,8 +71,11 @@ struct ChatViewModelPrivateChatExtensionTests { // Check MockTransport implementation... it might need update or verification } + /// An unreachable recipient no longer means instant failure: the message + /// is routed anyway so the router's outbox/courier/bridge machinery can + /// deliver it, and it stays "sending" until a router callback resolves it. @Test @MainActor - func sendPrivateMessage_unreachable_setsFailedStatus() async { + func sendPrivateMessage_unreachable_staysSendingForStoreAndForward() async { let (viewModel, _) = makeTestableViewModel() let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10" let peerID = PeerID(str: validHex) @@ -80,11 +83,7 @@ struct ChatViewModelPrivateChatExtensionTests { viewModel.sendPrivateMessage("Hello", to: peerID) #expect(viewModel.privateChats[peerID]?.count == 1) - let status = viewModel.privateChats[peerID]?.last?.deliveryStatus - #expect({ - if case .failed = status { return true } - return false - }()) + #expect(viewModel.privateChats[peerID]?.last?.deliveryStatus == .sending) } @Test @MainActor @@ -297,8 +296,23 @@ struct ChatViewModelNostrExtensionTests { let didAppend = await TestHelpers.waitUntil({ viewModel.publicMessagePipeline.flushIfNeeded() - return viewModel.messages.contains { $0.content == "Hello Geo" } - }) + if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true } + // LocationChannelManager is a process-wide singleton: a suite + // running in parallel (e.g. CommandProcessorTests) can flip the + // selected channel mid-test, which reroutes or drops the event + // permanently — no amount of waiting recovers it. Re-assert the + // channel and redeliver on each poll: every channel switch clears + // the processed-event set and the store dedups by message ID, so + // redelivery is idempotent and interference heals on the next + // poll while a genuine failure still times out. + if LocationChannelManager.shared.selectedChannel != channel { + LocationChannelManager.shared.select(channel) + } + if viewModel.activeChannel == channel { + viewModel.handleNostrEvent(signed) + } + return false + }, timeout: TestConstants.longTimeout) #expect(didAppend) } @@ -640,8 +654,10 @@ struct ChatViewModelNostrExtensionTests { #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey) } + /// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay + /// out of the conversation transcript. @Test @MainActor - func handleFavoriteNotification_updatesFavoriteAssociation() async throws { + func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws { let (viewModel, _) = makeTestableViewModel() let identity = try NostrIdentity.generate() let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) }) @@ -649,19 +665,33 @@ struct ChatViewModelNostrExtensionTests { FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noiseKey, peerNostrPublicKey: identity.npub, - peerNickname: "Before" + peerNickname: "Alice" ) - defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } + defer { + FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) + } - viewModel.handleFavoriteNotification( - content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice", - from: identity.publicKeyHex + // The inbound pipeline resolves a known sender to their noise-key ID. + let convKey = PeerID(hexData: noiseKey) + let payloadData = try #require( + PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode() + ) + let payload = NoisePayload(type: .privateMessage, data: payloadData) + + viewModel.handlePrivateMessage( + payload, + senderPubkey: identity.publicKeyHex, + convKey: convKey, + id: identity, + messageTimestamp: Date() ) let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) - #expect(relationship?.peerNickname == "Alice") + #expect(relationship?.theyFavoritedUs == true) + #expect(relationship?.isMutual == true) #expect(relationship?.peerNostrPublicKey == identity.npub) - #expect(relationship?.isFavorite == true) + #expect(viewModel.privateChats[convKey, default: []].isEmpty) } @Test @MainActor @@ -731,9 +761,11 @@ struct ChatViewModelGeoDMTests { viewModel.sendGeohashDM("hello", to: convKey) - #expect(viewModel.privateChats[convKey] == nil) - #expect(viewModel.messages.count == 1) - #expect(viewModel.messages.last?.sender == "system") + // The failure is surfaced inside the geoDM thread, not on the public + // timeline (matches the sibling in-thread errors from #1415). + #expect(viewModel.messages.isEmpty) + #expect(viewModel.privateChats[convKey]?.count == 1) + #expect(viewModel.privateChats[convKey]?.last?.sender == "system") } @Test @MainActor @@ -749,8 +781,10 @@ struct ChatViewModelGeoDMTests { #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) } + /// The blocked notice belongs in the DM thread the person is typing in, + /// not on the active location-channel timeline. @Test @MainActor - func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async { + func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessageInThread() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003" @@ -762,9 +796,11 @@ struct ChatViewModelGeoDMTests { viewModel.sendGeohashDM("hello", to: convKey) - #expect(viewModel.privateChats[convKey]?.count == 1) - #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) - #expect(viewModel.messages.contains(where: { $0.sender == "system" })) + let thread = viewModel.privateChats[convKey] ?? [] + #expect(thread.count == 2) + #expect(isFailed(status: thread.first?.deliveryStatus)) + #expect(thread.last?.sender == "system") + #expect(!viewModel.messages.contains(where: { $0.sender == "system" })) } @Test @MainActor @@ -1000,7 +1036,11 @@ struct ChatViewModelMediaTransferTests { viewModel.selectedPrivateChatPeer = peerID viewModel.sendVoiceNote(at: url) - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0) + // Media sends hop through Task.detached; the global executor is + // shared with every parallel test worker, so a loaded runner can + // exceed the 5s default. waitUntil returns as soon as the condition + // holds, so passing runs never pay the longer timeout. + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) @@ -1020,7 +1060,7 @@ struct ChatViewModelMediaTransferTests { let didFail = await TestHelpers.waitUntil({ isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) - }, timeout: 5.0) + }, timeout: TestConstants.longTimeout) #expect(didFail) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(transport.sentPrivateFiles.isEmpty) @@ -1036,7 +1076,7 @@ struct ChatViewModelMediaTransferTests { viewModel.selectedPrivateChatPeer = peerID viewModel.sendImage(from: sourceURL) - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") @@ -1057,7 +1097,7 @@ struct ChatViewModelMediaTransferTests { let didNotify = await TestHelpers.waitUntil({ viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) - }, timeout: 5.0) + }, timeout: TestConstants.longTimeout) #expect(didNotify) #expect(transport.sentPrivateFiles.isEmpty) #expect(viewModel.privateChats[peerID]?.isEmpty != false) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 3e9c2daa..5682cdfa 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -85,9 +85,14 @@ struct ChatViewModelInitializationTests { ) ]) + // The snapshot → allPeers binding hops the transport's unstructured + // Task, UnifiedPeerService, a receive(on: main), and another Task — + // all contending with every parallel worker, so a loaded CI runner + // can exceed defaultTimeout (observed: one 5s miss on a run where + // the whole suite took 10s instead of the usual ~4s). let updated = await TestHelpers.waitUntil({ viewModel.allPeers.contains { $0.peerID == peerID && $0.nickname == "Alice" } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.longTimeout) #expect(updated) } @@ -119,9 +124,10 @@ struct ChatViewModelIdentityTests { ) ]) + // Same multi-hop snapshot pipeline as above: longTimeout for load. let oldPeerBound = await TestHelpers.waitUntil({ viewModel.connectedPeers.contains(oldPeerID) - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.longTimeout) #expect(oldPeerBound) let existingMessage = BitchatMessage( @@ -155,7 +161,7 @@ struct ChatViewModelIdentityTests { let newPeerBound = await TestHelpers.waitUntil({ viewModel.connectedPeers.contains(newPeerID) && !viewModel.connectedPeers.contains(oldPeerID) - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.longTimeout) #expect(newPeerBound) viewModel.updatePrivateChatPeerIfNeeded() @@ -263,6 +269,59 @@ struct ChatViewModelCommandTests { #expect(transport.sentPrivateMessages.isEmpty) } } + + @Test @MainActor + func handleCommand_outputRoutesToOpenPrivateChat() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000002") + transport.simulateConnect(peerID, nickname: "Alice") + viewModel.selectedPrivateChatPeer = peerID + + viewModel.handleCommand("/help") + + #expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText) + #expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText }) + } + + @Test @MainActor + func handleCommand_errorRoutesToOpenPrivateChat() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000002") + transport.simulateConnect(peerID, nickname: "Alice") + viewModel.selectedPrivateChatPeer = peerID + + viewModel.handleCommand("/bogus") + + let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? [] + #expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") }) + #expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") }) + } + + @Test @MainActor + func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async { + let (viewModel, _) = makeTestableViewModel() + + viewModel.handleCommand("/bogus") + + #expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true) + } + + @Test @MainActor + func handleCommand_msgSuccessLandsInNewlyOpenedChat() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000002") + transport.simulateConnect(peerID, nickname: "Alice") + let resolved = await TestHelpers.waitUntil({ + viewModel.getPeerIDForNickname("Alice") == peerID + }, timeout: TestConstants.defaultTimeout) + #expect(resolved) + + viewModel.handleCommand("/msg Alice") + + #expect(viewModel.selectedPrivateChatPeer == peerID) + #expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice") + #expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" }) + } } // MARK: - Composer Tests @@ -701,6 +760,40 @@ struct ChatViewModelRateLimitingTests { struct ChatViewModelPublicConversationTests { + @Test @MainActor + func bridgeAliasReplacementDoesNotContentDedupAwayAuthenticatedRadioRow() { + let (viewModel, _) = makeTestableViewModel() + let content = "same bridge and radio payload" + let timestamp = Date() + let bridgeMessage = BitchatMessage( + id: "bridge-event-id", + sender: "remote#beef", + content: content, + timestamp: timestamp, + isRelay: false, + senderPeerID: PeerID(bridge: String(repeating: "a", count: 64)), + isBridged: true + ) + viewModel.handlePublicMessage(bridgeMessage) + viewModel.publicMessagePipeline.flushIfNeeded() + #expect(viewModel.publicConversationContainsMessage(withID: bridgeMessage.id, in: .mesh)) + + viewModel.removeBridgeInjectedPublicMessage(withID: bridgeMessage.id) + let radioMessage = BitchatMessage( + id: "radio-stable-id", + sender: "remote", + content: content, + timestamp: timestamp, + isRelay: false, + senderPeerID: PeerID(str: "1122334455667788") + ) + viewModel.handlePublicMessage(radioMessage) + viewModel.publicMessagePipeline.flushIfNeeded() + + #expect(!viewModel.publicConversationContainsMessage(withID: bridgeMessage.id, in: .mesh)) + #expect(viewModel.publicConversationContainsMessage(withID: radioMessage.id, in: .mesh)) + } + @Test @MainActor func addPublicSystemMessage_persistsAcrossTimelineRefresh() async { let (viewModel, _) = makeTestableViewModel() diff --git a/bitchatTests/ChatVouchCoordinatorContextTests.swift b/bitchatTests/ChatVouchCoordinatorContextTests.swift new file mode 100644 index 00000000..18175576 --- /dev/null +++ b/bitchatTests/ChatVouchCoordinatorContextTests.swift @@ -0,0 +1,353 @@ +// +// ChatVouchCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext` — +// proving the exchange policy (verified + capable peers only, batch cap, +// 24h rate limit) and the accept policy (verified senders only, real +// Ed25519 signature verification, expiry) without a `ChatViewModel`. +// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee +// cap) are covered by `SecureIdentityStateManagerVouchTests`. +// + +import CryptoKit +import Foundation +import BitFoundation +import Testing + +@testable import bitchat + +// MARK: - Mock Context + +@MainActor +private final class MockChatVouchContext: ChatVouchContext { + // Identity & trust state + var fingerprintsByPeerID: [PeerID: String] = [:] + var verifiedFingerprints: Set = [] + var signingKeysByFingerprint: [String: Data] = [:] + var recentVerified: [String] = [] + private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = [] + private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = [] + var recordVouchResult = true + var lastBatchSentAt: [String: Date] = [:] + private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = [] + + func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) } + func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] } + + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + recentVerifiedRequests.append((limit, fingerprint)) + return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit)) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp)) + return recordVouchResult + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + markedBatchSent.append((fingerprint, date)) + lastBatchSentAt[fingerprint] = date + } + + // Transport + var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:] + var mySigningKey = Curve25519.Signing.PrivateKey() + private(set) var installedObservers: [(PeerID, String) -> Void] = [] + private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = [] + + var connectedPeerIDList: [PeerID] = [] + + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] } + + func connectedPeerIDs() -> [PeerID] { connectedPeerIDList } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + installedObservers.append(handler) + } + + func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) } + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + sentVouchPayloads.append((payload, peerID)) + } + + // UI refresh + private(set) var trustChangedCount = 0 + + func notifyPeerTrustChanged() { trustChangedCount += 1 } +} + +// MARK: - Tests + +struct ChatVouchCoordinatorContextTests { + private let peerID = PeerID(str: "1122334455667788") + private let peerFingerprint = String(repeating: "0f", count: 32) + + @MainActor + private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) { + let context = MockChatVouchContext() + let coordinator = ChatVouchCoordinator(context: context) + context.fingerprintsByPeerID[peerID] = peerFingerprint + context.verifiedFingerprints.insert(peerFingerprint) + context.capabilitiesByPeerID[peerID] = [.vouch] + return (context, coordinator) + } + + // MARK: Exchange policy + + @Test @MainActor + func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)] + context.recentVerified = vouchees + for vouchee in vouchees { + context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32) + } + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + + // Candidates are requested most-recent-first, excluding the target. + #expect(context.recentVerifiedRequests.count == 1) + #expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount) + #expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint) + + let sent = try #require(context.sentVouchPayloads.first) + #expect(sent.peerID == peerID) + let attestations = VouchAttestation.decodeList(from: sent.payload) + #expect(attestations.map(\.voucheeFingerprintHex) == vouchees) + // Every attestation carries a valid signature under our signing key. + let myPublicKey = context.mySigningKey.publicKey.rawRepresentation + #expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) }) + + // The rate limit is stamped only after an actual send. + #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) + } + + @Test @MainActor + func peerAuthenticated_requiresVerificationAndCapability() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + // Not verified by me: nothing. + context.verifiedFingerprints.remove(peerFingerprint) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.isEmpty) + + // Verified but advertises a non-empty capability set lacking .vouch: + // nothing. (An *empty*/unknown set is race-tolerant and still sends — + // see `attemptVouch_sendsWhenCapabilitiesUnknown`.) + context.verifiedFingerprints.insert(peerFingerprint) + context.capabilitiesByPeerID[peerID] = [.prekeys] + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.isEmpty) + #expect(context.markedBatchSent.isEmpty) + } + + // MARK: Capability race tolerance & new triggers + + @Test @MainActor + func attemptVouch_sendsWhenCapabilitiesUnknown() { + // Capability set still empty at attempt time (the peer's .vouch bit + // arrives on a later announce): the batch must still go out. + let (context, coordinator) = makeVerifiedCapablePeer() + context.capabilitiesByPeerID[peerID] = [] + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.count == 1) + #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) + } + + @Test @MainActor + func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.connectedPeerIDList = [peerID] + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + // Session is already up (no peerAuthenticated re-fire); the verify pass + // is what makes the batch go out. + coordinator.vouchToConnectedVerifiedPeers() + let sent = context.sentVouchPayloads + #expect(sent.count == 1) + #expect(sent.first?.peerID == peerID) + #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) + } + + @Test @MainActor + func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.connectedPeerIDList = [peerID] + context.verifiedFingerprints.remove(peerFingerprint) + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + coordinator.vouchToConnectedVerifiedPeers() + #expect(context.sentVouchPayloads.isEmpty) + } + + @Test @MainActor + func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + // First announce before the .vouch bit is known: empty set is + // race-tolerant, so it already sends and stamps the throttle. + context.capabilitiesByPeerID[peerID] = [] + coordinator.peersUpdated([peerID]) + #expect(context.sentVouchPayloads.count == 1) + + // A later announce carrying .vouch must not double-send (throttled). + context.capabilitiesByPeerID[peerID] = [.vouch] + coordinator.peersUpdated([peerID]) + #expect(context.sentVouchPayloads.count == 1) + } + + @Test @MainActor + func peerAuthenticated_rateLimitsPerPeerPer24Hours() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + let now = Date() + context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) + #expect(context.sentVouchPayloads.isEmpty) + + // Once the interval has elapsed the batch goes out again. + context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) + #expect(context.sentVouchPayloads.count == 1) + } + + @Test @MainActor + func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() { + let (context, coordinator) = makeVerifiedCapablePeer() + let withKey = String(repeating: "01", count: 32) + let withoutKey = String(repeating: "02", count: 32) + context.recentVerified = [withoutKey, withKey] + context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32) + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload) + #expect(attestations.map(\.voucheeFingerprintHex) == [withKey]) + + // No signable candidates at all: nothing is sent or rate-stamped. + let freshPeer = PeerID(str: "aabbccddeeff0011") + let freshFingerprint = String(repeating: "0e", count: 32) + context.fingerprintsByPeerID[freshPeer] = freshFingerprint + context.verifiedFingerprints.insert(freshFingerprint) + context.capabilitiesByPeerID[freshPeer] = [.vouch] + context.recentVerified = [withoutKey] + coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint) + #expect(context.sentVouchPayloads.count == 1) + #expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint }) + } + + // MARK: Accept policy + + @MainActor + private func makeInboundBatch( + signedBy key: Curve25519.Signing.PrivateKey, + vouchee: String = String(repeating: "07", count: 32), + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) + ) throws -> Data { + let voucheeData = try #require(Data(hexString: vouchee)) + let attestation = try #require(VouchAttestation.build( + voucheeFingerprint: voucheeData, + voucheeSigningKey: Data(repeating: 0x44, count: 32), + timestampMs: timestampMs, + sign: { try? key.signature(for: $0) } + )) + return try #require(VouchAttestation.encodeList([attestation])) + } + + @Test @MainActor + func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + + let vouchee = String(repeating: "07", count: 32) + let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee) + coordinator.handleVouchPayload(from: peerID, payload: payload) + + #expect(context.recordedVouches.count == 1) + #expect(context.recordedVouches.first?.vouchee == vouchee) + #expect(context.recordedVouches.first?.voucher == peerFingerprint) + #expect(context.trustChangedCount == 1) + } + + @Test @MainActor + func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + let payload = try makeInboundBatch(signedBy: senderKey) + + // Sender's fingerprint is not in my verified set. + context.verifiedFingerprints.remove(peerFingerprint) + coordinator.handleVouchPayload(from: peerID, payload: payload) + #expect(context.recordedVouches.isEmpty) + + // Unknown peer entirely. + coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload) + #expect(context.recordedVouches.isEmpty) + #expect(context.trustChangedCount == 0) + } + + @Test @MainActor + func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + + // Signed by an imposter key: signature check against the sender's + // announce-bound key fails. + let imposter = Curve25519.Signing.PrivateKey() + let forged = try makeInboundBatch(signedBy: imposter) + coordinator.handleVouchPayload(from: peerID, payload: forged) + #expect(context.recordedVouches.isEmpty) + + // Correctly signed but expired. + let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000) + let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs) + coordinator.handleVouchPayload(from: peerID, payload: expired) + #expect(context.recordedVouches.isEmpty) + #expect(context.trustChangedCount == 0) + + // No signing key known for the sender: batch dropped. + context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint) + let valid = try makeInboundBatch(signedBy: senderKey) + coordinator.handleVouchPayload(from: peerID, payload: valid) + #expect(context.recordedVouches.isEmpty) + } + + @Test @MainActor + func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + context.recordVouchResult = false // e.g. self-vouch dropped by the store + + let payload = try makeInboundBatch(signedBy: senderKey) + coordinator.handleVouchPayload(from: peerID, payload: payload) + #expect(context.recordedVouches.count == 1) + #expect(context.trustChangedCount == 0) + } + + @Test @MainActor + func setupNoiseCallbacks_installsAdditiveObserver() { + let (context, coordinator) = makeVerifiedCapablePeer() + coordinator.setupNoiseCallbacks() + #expect(context.installedObservers.count == 1) + } +} diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 641fdfdd..e2d9d561 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -303,6 +303,50 @@ struct CommandProcessorTests { #expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) } + /// /fav must go through toggleFavorite (which persists by the real noise + /// key) — not write the hex peer ID into the favorites store, and not + /// send a second favorite notification. + @MainActor + @Test func favoriteCommandTogglesWithoutDirectStoreWrite() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + let processor = CommandProcessor( + contextProvider: context, + meshService: MockTransport(), + identityManager: identityManager + ) + let peerID = PeerID(str: "00aa00bb00cc00dd") + context.nicknameToPeerID["alice"] = peerID + + let result = await withSelectedChannel(.mesh, context: context) { + processor.process("/fav alice") + } + + switch result { + case .success(let message): + #expect(message == "added alice to favorites") + default: + Issue.record("Expected success result") + } + #expect(context.toggledFavorites == [peerID]) + #expect(context.favoriteNotifications.isEmpty) + // The 8-byte routing ID must never be stored as a "noise key". + let bogusKey = Data(hexString: peerID.id)! + #expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil) + + // Unfavoriting someone who is not a favorite is a no-op. + let unfavResult = await withSelectedChannel(.mesh, context: context) { + processor.process("/unfav alice") + } + switch unfavResult { + case .success(let message): + #expect(message == "alice is not a favorite") + default: + Issue.record("Expected success result") + } + #expect(context.toggledFavorites == [peerID]) + } + @MainActor @Test func favoriteCommandIsRejectedOutsideMesh() async { let identityManager = MockIdentityManager(MockKeychain()) @@ -326,6 +370,173 @@ struct CommandProcessorTests { } } + // MARK: - /pay + + @MainActor + @Test func payWithoutArgumentsPrintsUsage() { + let processor = makePayProcessor(context: MockCommandContextProvider()) + switch processor.process("/pay") { + case .success(let message): + #expect(message?.contains("usage: /pay") == true) + default: + Issue.record("Expected success (usage) result") + } + } + + @MainActor + @Test func payRejectsInvalidToken() { + let context = MockCommandContextProvider() + let processor = makePayProcessor(context: context) + for bad in ["/pay nonsense", "/pay cashuAshort", "/pay cashuA!!!!!!!!!!!!!!!!"] { + switch processor.process(bad) { + case .error: + break + default: + Issue.record("Expected error for \(bad)") + } + } + #expect(context.sentPrivateMessages.isEmpty) + #expect(context.sentPublicMessages.isEmpty) + } + + @MainActor + @Test func paySendsBareTokenInPrivateChat() { + let context = MockCommandContextProvider() + let peerID = PeerID(str: "abcd1234abcd1234") + context.selectedPrivateChatPeer = peerID + let processor = makePayProcessor(context: context) + + // cashu: URI form must be normalized to the bare token before sending + switch processor.process("/pay cashu:\(Self.validV3Token)") { + case .success(let message): + #expect(message?.contains("21 sat") == true) + default: + Issue.record("Expected success result") + } + #expect(context.sentPrivateMessages.count == 1) + #expect(context.sentPrivateMessages.first?.content == Self.validV3Token) + #expect(context.sentPrivateMessages.first?.peerID == peerID) + #expect(context.sentPublicMessages.isEmpty) + } + + @MainActor + @Test func payInPublicChannelRequiresExplicitConfirm() { + let context = MockCommandContextProvider() + let processor = makePayProcessor(context: context) + + switch processor.process("/pay \(Self.validV3Token)") { + case .error(let message): + #expect(message.contains("public") == true) + default: + Issue.record("Expected error without confirm") + } + #expect(context.sentPublicMessages.isEmpty) + + switch processor.process("/pay \(Self.validV3Token) public") { + case .success: + break + default: + Issue.record("Expected success with confirm") + } + #expect(context.sentPublicMessages == [Self.validV3Token]) + #expect(context.sentPrivateMessages.isEmpty) + } + + @MainActor + @Test func payRejectsTruncatedOrJunkV4Token() { + let context = MockCommandContextProvider() + context.selectedPrivateChatPeer = PeerID(str: "abcd1234abcd1234") + let processor = makePayProcessor(context: context) + + // Truncated V4 (definite-length CBOR can no longer be walked) and + // pure base64 junk under the cashuB prefix must both be refused. + let truncatedV4 = String(Self.validV4Token.prefix(Self.validV4Token.count - 12)) + let junkV4 = "cashuB" + String(repeating: "Q", count: 40) + for bad in ["/pay \(truncatedV4)", "/pay \(junkV4)"] { + switch processor.process(bad) { + case .error(let message): + #expect(message.contains("invalid cashu token") == true) + default: + Issue.record("Expected error for \(bad)") + } + } + #expect(context.sentPrivateMessages.isEmpty) + #expect(context.sentPublicMessages.isEmpty) + } + + @MainActor + @Test func paySendsValidDefiniteLengthV4Token() { + let context = MockCommandContextProvider() + let peerID = PeerID(str: "abcd1234abcd1234") + context.selectedPrivateChatPeer = peerID + let processor = makePayProcessor(context: context) + + switch processor.process("/pay \(Self.validV4Token)") { + case .success(let message): + #expect(message?.contains("21 sat") == true) + default: + Issue.record("Expected success result for valid V4 token") + } + #expect(context.sentPrivateMessages.count == 1) + #expect(context.sentPrivateMessages.first?.content == Self.validV4Token) + } + + /// 21-sat single-mint V3 token (proofs of 1+4+16). + private static let validV3Token: String = { + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [1, 4, 16].map { ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] } + ]], + "unit": "sat" + ] + let data = try! JSONSerialization.data(withJSONObject: json) + let b64 = data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "cashuA" + b64 + }() + + /// 21-sat single-mint definite-length V4 (CBOR) token (proofs of 1+4+16). + private static let validV4Token: String = { + func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { + switch value { + case 0...23: return [(major << 5) | UInt8(value)] + case 24...0xFF: return [(major << 5) | 24, UInt8(value)] + default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + } + } + func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) } + func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } } + func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } } + + let proofs = [UInt64(1), 4, 16].map { amount in + map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))]) + } + let cbor = map([ + ("m", text("https://mint.example.com")), + ("u", text("sat")), + ("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])])) + ]) + let b64 = Data(cbor).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "cashuB" + b64 + }() + + @MainActor + private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor { + CommandProcessor( + contextProvider: context, + meshService: MockTransport(), + identityManager: MockIdentityManager(MockKeychain()) + ) + } + @MainActor private func withSelectedChannel( _ channel: ChannelID, @@ -391,6 +602,8 @@ private final class MockCommandContextProvider: CommandContextProvider { private(set) var sentPublicRawMessages: [String] = [] private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = [] private(set) var publicSystemMessages: [String] = [] + private(set) var commandOutputs: [String] = [] + private(set) var commandOutputDestinations: [CommandOutputDestination] = [] private(set) var toggledFavorites: [PeerID] = [] private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] @@ -433,6 +646,11 @@ private final class MockCommandContextProvider: CommandContextProvider { sentPublicRawMessages.append(content) } + private(set) var sentPublicMessages: [String] = [] + func sendPublicMessage(_ content: String) { + sentPublicMessages.append(content) + } + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { localPrivateSystemMessages.append((content, peerID)) } @@ -441,11 +659,47 @@ private final class MockCommandContextProvider: CommandContextProvider { publicSystemMessages.append(content) } + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + return .meshTimeline + } + + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + commandOutputs.append(content) + commandOutputDestinations.append(destination) + } + func toggleFavorite(peerID: PeerID) { toggledFavorites.append(peerID) } - func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - favoriteNotifications.append((peerID, isFavorite)) + // Groups: record the parsed subcommand + argument the processor forwarded. + private(set) var groupCommands: [(subcommand: String, argument: String)] = [] + + func groupCreate(named name: String) -> CommandResult { + groupCommands.append(("create", name)) + return .handled + } + + func groupInvite(nickname: String) -> CommandResult { + groupCommands.append(("invite", nickname)) + return .handled + } + + func groupRemove(nickname: String) -> CommandResult { + groupCommands.append(("remove", nickname)) + return .handled + } + + func groupLeave() -> CommandResult { + groupCommands.append(("leave", "")) + return .handled + } + + func groupList() -> CommandResult { + groupCommands.append(("list", "")) + return .handled } } diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift new file mode 100644 index 00000000..ea90b6a7 --- /dev/null +++ b/bitchatTests/CourierStoreTests.swift @@ -0,0 +1,482 @@ +// +// CourierStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct CourierStoreTests { + + private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000) + + private func makeStore(now: Date = baseDate) -> CourierStore { + CourierStore(persistsToDisk: false, now: { now }) + } + + /// Store whose clock can be advanced by tests. + private final class Clock { + var now: Date + init(_ now: Date) { self.now = now } + } + + private func makeEnvelope( + recipientKey: Data = Data(repeating: 0xB0, count: 32), + sealedAt: Date = baseDate, + lifetime: TimeInterval = 60 * 60, + ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) }) + ) -> CourierEnvelope { + CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientKey, + epochDay: CourierEnvelope.epochDay(for: sealedAt) + ), + expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000), + ciphertext: ciphertext + ) + } + + private let depositorA = Data(repeating: 0xA1, count: 32) + private let depositorB = Data(repeating: 0xA2, count: 32) + + // MARK: - Deposit and handover + + @Test func depositThenTakeForRecipient() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + + #expect(store.deposit(envelope, from: depositorA)) + let taken = store.takeEnvelopes(for: recipientKey) + #expect(taken == [envelope]) + // Handover removes the envelope. + #expect(store.takeEnvelopes(for: recipientKey).isEmpty) + } + + @Test func takeIgnoresOtherRecipients() { + let store = makeStore() + let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32)) + store.deposit(envelope, from: depositorA) + #expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty) + #expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1) + } + + @Test func rejectedPhysicalHandoverRetainsEnvelopeUntilAcceptedRetry() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + #expect(store.deposit(envelope, from: depositorA)) + + var rejectedOffers: [CourierEnvelope] = [] + let rejected = store.handoverEnvelopes(for: recipientKey) { offered in + rejectedOffers.append(offered) + return false + } + + #expect(rejected == 0) + #expect(rejectedOffers == [envelope]) + #expect(!store.isEmpty) + + var acceptedOffers: [CourierEnvelope] = [] + let accepted = store.handoverEnvelopes(for: recipientKey) { offered in + acceptedOffers.append(offered) + return true + } + #expect(accepted == 1) + #expect(acceptedOffers == [envelope]) + #expect(store.isEmpty) + } + + @Test func midTrainFragmentRejectionRetainsDurableEnvelope() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + #expect(store.deposit(envelope, from: depositorA)) + + var attemptedFragments: [Int] = [] + let accepted = store.handoverEnvelopes(for: recipientKey) { _ in + BLEStrictFragmentAdmission.admitAll([0, 1, 2]) { fragment in + attemptedFragments.append(fragment) + return fragment != 1 + } + } + + #expect(accepted == 0) + #expect(attemptedFragments == [0, 1]) + #expect(!store.isEmpty) + #expect(store.takeEnvelopes(for: recipientKey) == [envelope]) + } + + @Test func duplicateDepositIsIdempotent() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + #expect(store.deposit(envelope, from: depositorA)) + #expect(store.deposit(envelope, from: depositorA)) + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + } + + // MARK: - Validity + + @Test func rejectsExpiredAndOversizedAndMalformed() { + let store = makeStore() + let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600) + #expect(!store.deposit(expired, from: depositorA)) + + let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)) + #expect(!store.deposit(oversized, from: depositorA)) + + let badTag = CourierEnvelope( + recipientTag: Data(repeating: 0, count: 4), + expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000), + ciphertext: Data(repeating: 1, count: 16) + ) + #expect(!store.deposit(badTag, from: depositorA)) + } + + @Test func rejectsExpiryBeyondPolicyLifetime() { + let store = makeStore() + let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60) + #expect(!store.deposit(pinned, from: depositorA)) + } + + // MARK: - Quotas + + @Test func perDepositorQuota() { + let store = makeStore() + for _ in 0.. give 1, keep 1). + let courierY = Data(repeating: 0xC2, count: 32) + let sprayedToY = store.takeSprayCopies(for: courierY) + #expect(sprayedToY.count == 1) + #expect(sprayedToY.first?.copies == 1) + + // Budget exhausted (carry-only): nothing left to spray. + #expect(store.takeSprayCopies(for: Data(repeating: 0xC3, count: 32)).isEmpty) + // The carried original is still deliverable. + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + } + + @Test func rejectedSprayTransferPreservesBudgetAndCourierEligibility() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let courier = Data(repeating: 0xC1, count: 32) + #expect(store.deposit(makeEnvelope(recipientKey: recipientKey).withCopies(4), from: depositorA)) + + var rejectedOffers: [CourierEnvelope] = [] + let rejected = store.transferSprayCopies(to: courier) { offered in + rejectedOffers.append(offered) + return false + } + + #expect(rejected == 0) + #expect(rejectedOffers.map(\.copies) == [2]) + + // The same courier remains eligible and receives the original half + // budget, proving neither `copies` nor `sprayedTo` changed on failure. + let acceptedRetry = store.takeSprayCopies(for: courier) + #expect(acceptedRetry.map(\.copies) == [2]) + let nextCourier = store.takeSprayCopies(for: Data(repeating: 0xC2, count: 32)) + #expect(nextCourier.map(\.copies) == [1]) + } + + @Test func carryOnlyEnvelopesAreNeverSprayed() { + let store = makeStore() + #expect(store.deposit(makeEnvelope(), from: depositorA)) + #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty) + } + + @Test func duplicateDepositKeepsLargerSprayBudget() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let ciphertext = Data(repeating: 0x42, count: 96) + let carryOnly = makeEnvelope(recipientKey: recipientKey, ciphertext: ciphertext) + #expect(store.deposit(carryOnly, from: depositorA)) + #expect(store.deposit(carryOnly.withCopies(4), from: depositorB)) + + let sprayed = store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)) + #expect(sprayed.first?.copies == 2) + } + + @Test func duplicateReplayCannotReplenishSpentSprayBudget() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let original = makeEnvelope(recipientKey: recipientKey).withCopies(8) + #expect(store.deposit(original, from: depositorA)) + + let courierX = Data(repeating: 0xC1, count: 32) + let courierY = Data(repeating: 0xC2, count: 32) + let courierZ = Data(repeating: 0xC3, count: 32) + let courierW = Data(repeating: 0xC4, count: 32) + #expect(store.takeSprayCopies(for: courierX).map(\.copies) == [4]) + + // Replaying the original signed deposit still accepts idempotently, + // but it cannot reset the local branch from 4 copies back to 8. + #expect(store.deposit(original, from: depositorA)) + #expect(store.takeSprayCopies(for: courierY).map(\.copies) == [2]) + #expect(store.deposit(original, from: depositorA)) + #expect(store.takeSprayCopies(for: courierZ).map(\.copies) == [1]) + #expect(store.deposit(original, from: depositorA)) + #expect(store.takeSprayCopies(for: courierW).isEmpty) + } + + // MARK: - Remote handover (relayed announces) + + @Test func remoteHandoverIsNonDestructiveAndCooledDown() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + + let first = store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600) + #expect(first.count == 1) + // The flooded copy carries no spray budget. + #expect(first.first?.copies == 1) + // Non-destructive: the envelope is still carried... + #expect(!store.isEmpty) + // ...and inside the cooldown it is not re-flooded. + #expect(store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600).isEmpty) + // A direct encounter still hands it over destructively. + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + #expect(store.isEmpty) + } + + // MARK: - Legacy persistence + + @Test func legacyPersistedFileLoadsAsFavoriteCarryOnly() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("courier-legacy-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + // Envelope persisted by a pre-tier/pre-spray build: no tier, copies, + // or spray bookkeeping fields. + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + let legacy: [[String: Any]] = [[ + "recipientTag": envelope.recipientTag.base64EncodedString(), + "expiry": envelope.expiry, + "ciphertext": envelope.ciphertext.base64EncodedString(), + "depositorNoiseKey": depositorA.base64EncodedString(), + "storedAt": Self.baseDate.timeIntervalSinceReferenceDate + ]] + let data = try JSONSerialization.data(withJSONObject: legacy) + try data.write(to: fileURL) + + let store = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) + // Carry-only, so never sprayed... + #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty) + // ...but still delivered on encounter. + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + } +} diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift new file mode 100644 index 00000000..ac68c145 --- /dev/null +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -0,0 +1,921 @@ +// +// CourierEndToEndTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import Combine +import CoreBluetooth +import BitFoundation +@testable import bitchat + +/// Three-node courier flow exercised through real BLEService instances with +/// packets ferried in-process: Alice deposits a sealed envelope with Carol +/// while Bob is unreachable; Carol hands it over when Bob announces; Bob +/// opens it and sees Alice's message in the right DM thread. +struct CourierEndToEndTests { + + // MARK: - Helpers + + private final class PacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func first(ofType type: MessageType) -> BitchatPacket? { + lock.lock(); defer { lock.unlock() } + return packets.first { $0.type == type.rawValue } + } + + func count(ofType type: MessageType) -> Int { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue }.count + } + + func all(ofType type: MessageType) -> [BitchatPacket] { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue } + } + } + + private final class NoiseCaptureDelegate: BitchatDelegate { + private let lock = NSLock() + private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = [] + + func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { + lock.lock(); payloads.append((peerID, type, payload)); lock.unlock() + } + + func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] { + lock.lock(); defer { lock.unlock() } + return payloads + } + + // Unused BitchatDelegate requirements. + func didReceiveMessage(_ message: BitchatMessage) {} + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} + } + + private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService { + let keychain = MockKeychain() + let identityManager = identityManager ?? MockIdentityManager(keychain) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + initializeBluetoothManagers: false + ) + service.courierStore = CourierStore(persistsToDisk: false) + return service + } + + /// Handling any packet from a peer preseeds it as a connected, + /// verified entry in the receiving service's registry. + private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) { + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: peer.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data("ping".utf8), + signature: nil, + ttl: 1 + ) + service._test_handlePacket(packet, fromPeerID: peer.myPeerID) + } + + /// Establishes the Noise session that proves the direct link's peer owns + /// its announced static identity. CoreBluetooth is disabled in this suite, + /// so the handshake is ferried in-process just like courier packets. + private func establishNoiseSession(between initiator: BLEService, and responder: BLEService) throws { + let message1 = try initiator._test_noiseInitiateHandshake(with: responder.myPeerID) + let message2 = try #require( + try responder._test_noiseProcessHandshakeMessage(from: initiator.myPeerID, message: message1) + ) + let message3 = try #require( + try initiator._test_noiseProcessHandshakeMessage(from: responder.myPeerID, message: message2) + ) + _ = try responder._test_noiseProcessHandshakeMessage(from: initiator.myPeerID, message: message3) + } + + // MARK: - Tests + + @Test func courierCarriesMessageAcrossDisjointConnectivity() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + // Alice and Carol are mutual favorites; trust policy is exercised + // separately in depositFromUntrustedPeerIsRejected. + carol.courierDepositPolicy = { _, _ in .favorite } + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + // Alice can see Carol; Bob is nowhere on the mesh. + preseedConnectedPeer(carol, in: alice) + + // 1. Alice seals to Bob's static key and deposits with Carol. + #expect(alice.sendCourierMessage( + "the camp moved north", + messageID: "courier-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + + // 2. Ferry the deposit to Carol; she carries it (opaque to her). + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + // 3. Later, Bob proves link ownership and announces near Carol → + // handover fires. + try establishNoiseSession(between: carol, and: bob) + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let announcePacket = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + // With CoreBluetooth disabled there is no physical link for the send + // planner to accept, so Carol truthfully retains the durable copy even + // though the packet tap lets us ferry the attempted handover below. + #expect(!carol.courierStore.isEmpty) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + #expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID) + + // 4. Ferry the handover to Bob; he opens the envelope. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.type == .privateMessage) + // Alice is absent from Bob's mesh, so the sender resolves to her + // full noise-key ID — the stable favorite conversation — not the + // short mesh ID (which Bob couldn't resolve to a nickname) and not + // the courier's identity. + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + #expect(delivered.peerID != carol.myPeerID) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.messageID == "courier-msg-1") + #expect(message.content == "the camp moved north") + } + + @Test func courieredMailFromBlockedSenderIsDropped() async throws { + let alice = makeService() + let carol = makeService() + let bobIdentity = MockIdentityManager(MockKeychain()) + let bob = makeService(identityManager: bobIdentity) + carol.courierDepositPolicy = { _, _ in .favorite } + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + preseedConnectedPeer(carol, in: alice) + + // Bob blocked Alice by her stable Noise identity while she was away. + bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true) + + #expect(alice.sendCourierMessage( + "you should not see this", + messageID: "courier-msg-blocked-sender", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + try establishNoiseSession(between: carol, and: bob) + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let announcePacket = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + + // Bob opens the envelope — but the sealed sender is blocked, and it + // must never reach the UI. The live block check can't cover this: the + // sender is absent from Bob's registry, so no fingerprint resolves at + // delivery time. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let delivered = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!delivered) + } + + @Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _, _ in .favorite } + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + preseedConnectedPeer(carol, in: alice) + + #expect(alice.sendCourierMessage( + "hold until verified", + messageID: "courier-msg-unverified-announce", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + let forgedAnnounce = try makeUnsignedAnnounce(from: bob) + carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!leakedOnUnverifiedAnnounce) + #expect(!carol.courierStore.isEmpty) + + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let verifiedAnnounce = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) == 1 }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + #expect(!carol.courierStore.isEmpty) + } + + @Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _, _ in .favorite } + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + preseedConnectedPeer(carol, in: alice) + + #expect(alice.sendCourierMessage( + "hold for a direct encounter", + messageID: "courier-msg-relayed-announce", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let directAnnounce = try #require(bobOut.first(ofType: .announce)) + + // A relayed copy has a decremented TTL but a still-valid signature + // (TTL is excluded from announce signatures). The recipient is + // multi-hop away, so a copy floods toward them speculatively while + // the carried original stays put for a future direct encounter. + var relayedAnnounce = directAnnounce + relayedAnnounce.ttl = directAnnounce.ttl - 1 + carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let remoteHandover = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) == 1 }, + timeout: TestConstants.defaultTimeout + ) + #expect(remoteHandover) + #expect(!carol.courierStore.isEmpty) + + // A second relayed announce inside the cooldown must not re-flood + // the same envelope. The original announce's dedup key is consumed + // (sender/timestamp/payload — TTL excluded), so use a fresh announce; + // wait out the 1s announce throttle first. + try await Task.sleep(nanoseconds: 1_100_000_000) + bob.sendBroadcastAnnounce() + let reannounced = await TestHelpers.waitUntil( + { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } }, + timeout: TestConstants.defaultTimeout + ) + #expect(reannounced) + let freshAnnounce = try #require( + bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp } + ) + var relayedFreshAnnounce = freshAnnounce + relayedFreshAnnounce.ttl = freshAnnounce.ttl - 1 + carol._test_handlePacket(relayedFreshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let refloodedInCooldown = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 1 }, + timeout: TestConstants.shortTimeout + ) + #expect(!refloodedInCooldown) + #expect(!carol.courierStore.isEmpty) + + // A peer-level Noise session is still insufficient without a physical + // ingress link that completed that handshake. This CoreBluetooth-free + // harness deliberately has no such link proof, so restoring the + // unsigned direct TTL cannot authorize destructive handover. + try establishNoiseSession(between: carol, and: bob) + try await Task.sleep(nanoseconds: 1_100_000_000) + bob.sendBroadcastAnnounce() + let announcedAgain = await TestHelpers.waitUntil( + { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } }, + timeout: TestConstants.defaultTimeout + ) + #expect(announcedAgain) + let directAgain = try #require( + bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } + ) + carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOverWithoutLinkProof = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 1 }, + timeout: TestConstants.shortTimeout + ) + #expect(!handedOverWithoutLinkProof) + #expect(!carol.courierStore.isEmpty) + } + + @Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws { + let alice = makeService() + let carol = makeService() + preseedConnectedPeer(carol, in: alice) + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + + #expect(!alice.sendCourierMessage( + "this cannot be sealed", + messageID: "courier-msg-invalid-key", + recipientNoiseKey: Data(repeating: 0x01, count: 8), + via: [carol.myPeerID] + )) + + let queuedPacket = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.shortTimeout + ) + #expect(!queuedPacket) + } + + @Test func depositFromUntrustedPeerIsRejected() async throws { + let carol = makeService() + carol.courierDepositPolicy = { _, _ in nil } // depositor is neither favorite nor verified + + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() + let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1")) + let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: bobKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: sealed + ) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let unsigned = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: carol.myPeerID.id), + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: try #require(envelope.encode()), + signature: nil, + ttl: 1 + ) + let packet = try #require(alice.signPacket(unsigned)) + + carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) + let stored = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!stored) + } + + @Test func unsignedDepositIsRejected() async throws { + let carol = makeService() + carol.courierDepositPolicy = { _, _ in .favorite } + + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() + let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m-unsigned")) + let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: bobKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: sealed + ) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + // Correct sender, willing policy — but no packet signature: the + // courier cannot authenticate the depositor, so it must not carry. + let packet = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: carol.myPeerID.id), + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: try #require(envelope.encode()), + signature: nil, + ttl: 1 + ) + + carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) + let stored = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!stored) + } + + @Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws { + let alice = makeService() + let carol = makeService() + let mallory = makeService() + preseedConnectedPeer(alice, in: carol) + preseedConnectedPeer(mallory, in: carol) + + let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data() + carol.courierDepositPolicy = { depositorKey, _ in + depositorKey == trustedAliceKey ? .favorite : nil + } + + let aliceNoise = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() + let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof")) + let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: bobKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: sealed + ) + let packet = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: alice.myPeerID.id) ?? Data(), + recipientID: Data(hexString: carol.myPeerID.id), + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: try #require(envelope.encode()), + signature: nil, + ttl: 1 + ) + + carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false) + let stored = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!stored) + } + + /// Regression for the relaunch amplification storm: redundant copies of + /// one message arrive as *distinct* envelopes (every seal uses a fresh + /// ephemeral key), so only the inner message ID can dedup them. The first + /// copy delivers; duplicates must stop right after the decrypt — no + /// second delivery, no second ack/handshake trigger downstream. + @Test func duplicateCourierCopiesDeliverInnerMessageOnce() async throws { + let alice = makeService() + let bob = makeService() + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + + let bobKey = bob.noiseStaticPublicKeyData() + let first = try #require(alice.sealBridgeCourierEnvelope("once", messageID: "dup-1", recipientNoiseKey: bobKey)) + let second = try #require(alice.sealBridgeCourierEnvelope("once", messageID: "dup-1", recipientNoiseKey: bobKey)) + // Distinct seals: envelope-level dedup can never catch this pair. + #expect(first.ciphertext != second.ciphertext) + + #expect(bob.openBridgedCourierEnvelope(first)) + #expect(bob.openBridgedCourierEnvelope(second)) + + let delivered = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(delivered) + // Give a duplicate delivery a chance to surface, then confirm the + // second copy never reached the delegate. + let duplicated = await TestHelpers.waitUntil( + { bobDelegate.snapshot().count > 1 }, + timeout: TestConstants.shortTimeout + ) + #expect(!duplicated) + #expect(bobDelegate.snapshot().count == 1) + } + + /// Acks for mail from absent senders (the usual couriered/bridged case) + /// queue for a future session instead of initiating a handshake + /// broadcast — otherwise every duplicate copy of every drop turns into a + /// mesh-wide handshake flood at an identity that cannot answer. + @Test func deliveryAckForAbsentPeerQueuesWithoutHandshake() async throws { + let ble = makeService() + let outbound = PacketTap() + ble._test_onOutboundPacket = outbound.record + + // Nobody by this ID on the mesh (not connected, not reachable). + ble.sendDeliveryAck(for: "msg-1", to: PeerID(str: "00000000000000ee")) + + let initiated = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseHandshake) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!initiated) + + // Control: a peer that is actually around still gets the handshake. + let present = PeerID(str: "00000000000000ef") + ble._test_seedConnectedPeer(present, nickname: "present") + ble.sendDeliveryAck(for: "msg-2", to: present) + let initiatedForPresent = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseHandshake) > 0 }, + timeout: TestConstants.defaultTimeout + ) + #expect(initiatedForPresent) + } + + private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Unsigned", + noisePublicKey: service.noiseStaticPublicKeyData(), + signingPublicKey: service.noiseSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + + return BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: service.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } +} + +// MARK: - Router courier selection + +/// Minimal transport stub for exercising MessageRouter's courier deposit +/// logic without BLE plumbing. +private final class CourierCaptureTransport: Transport { + weak var delegate: BitchatDelegate? + weak var eventDelegate: TransportEventDelegate? + weak var peerEventsDelegate: TransportPeerEventsDelegate? + + var snapshots: [TransportPeerSnapshot] = [] + private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = [] + private(set) var directSends: [String] = [] + + func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots } + + var myPeerID = PeerID(str: "00000000000000aa") + var myNickname = "stub" + func setNickname(_ nickname: String) {} + + func startServices() {} + func stopServices() {} + func emergencyDisconnectAll() {} + + func isPeerConnected(_ peerID: PeerID) -> Bool { + snapshots.contains { $0.peerID == peerID && $0.isConnected } + } + // Nostr-style reachability: claimed for peers with no live link (known + // npub), where prompt delivery additionally needs a relay connection. + var reachablePeers: Set = [] + var promptDelivery = true + func isPeerReachable(_ peerID: PeerID) -> Bool { + isPeerConnected(peerID) || reachablePeers.contains(peerID) + } + func canDeliverPromptly(to peerID: PeerID) -> Bool { + isPeerReachable(peerID) && promptDelivery + } + func peerNickname(peerID: PeerID) -> String? { nil } + func getPeerNicknames() -> [PeerID: String] { [:] } + + func getFingerprint(for peerID: PeerID) -> String? { nil } + func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } + func triggerHandshake(with peerID: PeerID) {} + + func sendMessage(_ content: String, mentions: [String]) {} + func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + directSends.append(messageID) + } + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} + func sendBroadcastAnnounce() {} + func sendDeliveryAck(for messageID: String, to peerID: PeerID) {} + + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + courierSends.append((messageID, recipientNoiseKey, couriers)) + return true + } +} + +struct MessageRouterCourierTests { + + @Test @MainActor + func unreachablePeerMessageGoesToTrustedCouriersOnly() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let carolKey = Data(repeating: 0xC0, count: 32) + let carolID = PeerID(publicKey: carolKey) + let daveKey = Data(repeating: 0xD0, count: 32) + let daveID = PeerID(publicKey: daveKey) + + let transport = CourierCaptureTransport() + transport.snapshots = [ + // Carol: connected mutual favorite → eligible courier. + TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()), + // Dave: connected but not trusted → never a courier. + TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date()) + ] + + let directory = CourierDirectory( + noiseKey: { peerID in peerID == bobID ? bobKey : nil }, + isTrustedCourier: { $0 == carolKey } + ) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1") + + #expect(transport.directSends.isEmpty) + #expect(transport.courierSends.count == 1) + #expect(transport.courierSends.first?.messageID == "m1") + #expect(transport.courierSends.first?.recipientKey == bobKey) + #expect(transport.courierSends.first?.couriers == [carolID]) + #expect(carried == ["m1"]) + } + + /// Field-found: a DM sent while the recipient sits in the BLE + /// reachability retention window (radio gone, still "reachable" for a + /// minute) trusts the mesh and skips every deposit — and nothing + /// retried. The periodic sweep must publish the bridge drop once the + /// window lapses. + @Test @MainActor + func sweepDropsQueuedMessageOnceReachabilityLapses() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let transport = CourierCaptureTransport() + transport.reachablePeers = [bobID] // retention window: stale but "reachable" + transport.promptDelivery = true + + let directory = CourierDirectory( + noiseKey: { peerID in peerID == bobID ? bobKey : nil }, + isTrustedCourier: { _ in false } + ) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var drops: [(content: String, messageID: String, key: Data)] = [] + router.bridgeCourierDeposit = { content, messageID, key, completion in + drops.append((content, messageID, key)) + completion(true) + } + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m9") + #expect(drops.isEmpty) // send trusted the (stale) mesh reachability + + // Still inside the window: the sweep must not spam relays. + router.retryBridgeCourierDeposits() + #expect(drops.isEmpty) + + // Window lapses; the sweep publishes the retained copy as a drop and + // the sender's message shows "carried" (its ack has no radio route). + transport.reachablePeers = [] + router.retryBridgeCourierDeposits() + #expect(drops.count == 1) + #expect(drops.first?.messageID == "m9") + #expect(drops.first?.content == "hi bob") + #expect(drops.first?.key == bobKey) + #expect(carried == ["m9"]) + } + + @Test @MainActor + func noCourierDepositWithoutKnownRecipientKey() { + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date()) + ] + let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true }) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2") + + #expect(transport.courierSends.isEmpty) + #expect(carried.isEmpty) + } + + /// The production directory must resolve both ID forms: a 64-hex + /// noise-key ID (offline favorite row) carries the key itself, and a + /// short 16-hex ID resolves through the favorites store. + @Test @MainActor + func favoritesBackedDirectoryResolvesBothIDForms() { + let directory = CourierDirectory.favoritesBacked() + let bobKey = Data(repeating: 0xB7, count: 32) + + #expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey) + + FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob") + defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) } + #expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey) + } + + @Test @MainActor + func reachablePeerSkipsCourier() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date()) + ] + let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true }) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + + router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3") + + #expect(transport.directSends == ["m3"]) + #expect(transport.courierSends.isEmpty) + } + + /// A peer can be "reachable" through a transport that cannot deliver + /// promptly (Nostr claims any favorite with a known npub, even with no + /// relay connection). The queued send must not shadow the courier: a + /// sealed copy goes to connected couriers in parallel, and receivers + /// dedup by message ID if both arrive. + @Test @MainActor + func queuedReachableSendAlsoDepositsWithCourier() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let carolKey = Data(repeating: 0xC0, count: 32) + let carolID = PeerID(publicKey: carolKey) + + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()) + ] + transport.reachablePeers = [bobID] + transport.promptDelivery = false + + let directory = CourierDirectory( + noiseKey: { peerID in peerID == bobID ? bobKey : nil }, + isTrustedCourier: { $0 == carolKey } + ) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m4") + + #expect(transport.directSends == ["m4"]) + #expect(transport.courierSends.count == 1) + #expect(transport.courierSends.first?.messageID == "m4") + #expect(transport.courierSends.first?.couriers == [carolID]) + #expect(carried == ["m4"]) + } + + /// When the reachable transport can deliver promptly (relays up), the + /// send is trusted and no courier quota is spent. + @Test @MainActor + func promptlyDeliverableReachablePeerSkipsCourier() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let carolKey = Data(repeating: 0xC0, count: 32) + let carolID = PeerID(publicKey: carolKey) + + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()) + ] + transport.reachablePeers = [bobID] + + let directory = CourierDirectory( + noiseKey: { peerID in peerID == bobID ? bobKey : nil }, + isTrustedCourier: { $0 == carolKey } + ) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m5") + + #expect(transport.directSends == ["m5"]) + #expect(transport.courierSends.isEmpty) + #expect(carried.isEmpty) + } +} diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift new file mode 100644 index 00000000..79bd28b5 --- /dev/null +++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift @@ -0,0 +1,404 @@ +// +// PrekeyEndToEndTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CoreBluetooth +import BitFoundation +@testable import bitchat + +/// Forward-secret courier flow through real BLEService instances: Bob gossips +/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time +/// prekey instead of Bob's static key, Carol carries the opaque envelope, and +/// Bob opens it with the matching prekey private. +struct PrekeyEndToEndTests { + + // MARK: - Helpers + + private final class PacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func first(ofType type: MessageType) -> BitchatPacket? { + lock.lock(); defer { lock.unlock() } + return packets.first { $0.type == type.rawValue } + } + } + + private final class NoiseCaptureDelegate: BitchatDelegate { + private let lock = NSLock() + private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = [] + + func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { + lock.lock(); payloads.append((peerID, type, payload)); lock.unlock() + } + + func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] { + lock.lock(); defer { lock.unlock() } + return payloads + } + + // Unused BitchatDelegate requirements. + func didReceiveMessage(_ message: BitchatMessage) {} + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} + } + + private func makeService() -> BLEService { + let keychain = MockKeychain() + let service = BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: MockIdentityManager(keychain), + initializeBluetoothManagers: false + ) + service.courierStore = CourierStore(persistsToDisk: false) + service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false) + return service + } + + private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) { + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: peer.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data("ping".utf8), + signature: nil, + ttl: 1 + ) + service._test_handlePacket(packet, fromPeerID: peer.myPeerID) + } + + /// Broadcast announce + prekey bundle from `peer` and return both packets. + private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) { + peer.sendBroadcastAnnounce() + let published = await TestHelpers.waitUntil( + { tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(published) + return ( + announce: try #require(tap.first(ofType: .announce)), + bundle: try #require(tap.first(ofType: .prekeyBundle)) + ) + } + + // MARK: - Tests + + @Test func prekeySealedMailTravelsViaCourierAndOpens() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _, _ in .favorite } + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + preseedConnectedPeer(carol, in: alice) + + // 1. While Bob is still around, Alice hears his verified announce + // (binding his signing key) and his gossiped prekey bundle. + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.defaultTimeout + ) + #expect(cached) + + // 2. Bob goes dark; Alice seals for him and deposits with Carol. + // The envelope must be v2: sealed to a one-time prekey. + #expect(alice.sendCourierMessage( + "burn after reading", + messageID: "prekey-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload)) + #expect(sealedEnvelope.prekeyID != nil) + + // 3. Carol carries it (opaque, prekey or not). + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + // 4. Bob resurfaces near Carol → handover, and the v2 discriminator + // survives the store round-trip. + bob.sendBroadcastAnnounce() + let reannounced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(reannounced) + let handoverTrigger = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload)) + #expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID) + + // 5. Bob opens it with the matching one-time prekey private and sees + // Alice as the authenticated sender. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.type == .privateMessage) + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.messageID == "prekey-msg-1") + #expect(message.content == "burn after reading") + + // 6. Redelivery tolerance: the same envelope arriving via another + // packet (spray-and-wait) still decrypts inside the prekey grace + // window (asserted at the crypto layer in NoisePrekeyTests), but + // the duplicate is absorbed before delivery — the receiver dedups + // on the inner message ID, so redundant courier copies never + // re-deliver (or re-ack) the same message. + let redelivery = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: carol.myPeerID.id) ?? Data(), + recipientID: handoverPacket.recipientID, + timestamp: handoverPacket.timestamp + 1, + payload: handoverPacket.payload, + signature: nil, + ttl: 1 + ) + bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID) + let redelivered = await TestHelpers.waitUntil( + { bobDelegate.snapshot().count == 2 }, + timeout: TestConstants.shortTimeout + ) + #expect(!redelivered) + #expect(bobDelegate.snapshot().count == 1) + } + + @Test func withoutBundleSealingFallsBackToStatic() async throws { + let alice = makeService() + let bob = makeService() + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + + // Bob is a connected "courier" who happens to be the recipient: the + // envelope reaches him directly and the recipient tag matches. + preseedConnectedPeer(bob, in: alice) + + // Alice never saw a bundle for Bob → v1 static-sealed envelope. + #expect(alice.sendCourierMessage( + "plain static seal", + messageID: "static-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [bob.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + let envelope = try #require(CourierEnvelope.decode(depositPacket.payload)) + #expect(envelope.prekeyID == nil) + + // Bob opens the v1 envelope exactly as before the prekey change. + // (No preseed: Alice is absent from Bob's mesh, so the sender should + // resolve to her full noise-key ID like the courier case.) + bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.content == "plain static seal") + } + + @Test func unverifiableBundleIsIgnored() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + // Alice receives Bob's bundle but never saw a verified announce, so + // no signing key is bound to his noise key: the bundle must not be + // cached or enter Alice's gossip store. + let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + } + + @Test func forgedBundleSignatureIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // Mallory tampers with the gossiped bundle in flight. + let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload)) + var forgedSignature = bundle.signature + forgedSignature[0] ^= 0x01 + let forged = PrekeyBundle( + noiseStaticPublicKey: bundle.noiseStaticPublicKey, + prekeys: bundle.prekeys, + generatedAt: bundle.generatedAt, + signature: forgedSignature + ) + let forgedPacket = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: bundlePacket.senderID, + recipientID: nil, + timestamp: bundlePacket.timestamp, + payload: try #require(forged.encode()), + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + } + + @Test func verifiedBundleEntersGossipStore() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.defaultTimeout + ) + #expect(cached) + // The verified bundle now participates in Alice's sync rounds. + #expect(alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + } + + @Test func spoofedSenderPrekeyBundleIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // A relay re-broadcasts Bob's genuine bundle under a fabricated sender + // ID (the DoS that would multiply cache/gossip entries and exhaust the + // per-owner cap). Attribution is by the bundle's own key and the outer + // signature is bound to Bob's sender ID, so the spoof is dropped — no + // cache entry, and no gossip entry under either the fake or real ID. + let fakeSender = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + let spoofed = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: fakeSender, + recipientID: nil, + timestamp: bundlePacket.timestamp + 5_000, + payload: bundlePacket.payload, + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(spoofed, fromPeerID: PeerID(hexData: fakeSender), preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + #expect(!alice._test_hasGossipPrekeyBundle(for: PeerID(hexData: fakeSender))) + } + + @Test func replayedPrekeyBundleWithFreshTimestampIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // Rewriting the outer timestamp (to defeat the freshness window) + // invalidates the packet signature, which covers senderID + timestamp. + let replay = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: bundlePacket.senderID, + recipientID: nil, + timestamp: bundlePacket.timestamp + 5_000, + payload: bundlePacket.payload, + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(replay, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + } +} diff --git a/bitchatTests/EndToEnd/PublicChatE2ETests.swift b/bitchatTests/EndToEnd/PublicChatE2ETests.swift index 6672e678..b67901d2 100644 --- a/bitchatTests/EndToEnd/PublicChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PublicChatE2ETests.swift @@ -18,9 +18,7 @@ struct PublicChatE2ETests { private let charlie: MockBLEService private let david: MockBLEService private let bus = MockBLEBus() - - private var receivedMessages: [String: [BitchatMessage]] = [:] - + init() { // Create mock services with unique peer IDs to avoid any collision alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus) diff --git a/bitchatTests/FontBitchatTests.swift b/bitchatTests/FontBitchatTests.swift index 729c2bf8..327c5efb 100644 --- a/bitchatTests/FontBitchatTests.swift +++ b/bitchatTests/FontBitchatTests.swift @@ -1,6 +1,5 @@ import SwiftUI import XCTest -@testable import bitchat final class FontBitchatTests: XCTestCase { // func testMonospacedMapping() { diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 53b4f0a7..ba0f82b7 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -35,19 +35,15 @@ struct FragmentationTests { // Use a small fragment size to ensure multiple pieces let fragments = fragmentPacket(original, fragmentSize: 400) - // Shuffle fragments to simulate out-of-order arrival - let shuffled = fragments.shuffled() + // Reverse deterministically to simulate out-of-order arrival without + // making a failure depend on a random permutation. + let outOfOrder = fragments.reversed() - // Send fragments sequentially with small delays (no fire-and-forget Tasks) - for (i, fragment) in shuffled.enumerated() { - if i > 0 { - try await Task.sleep(for: .milliseconds(5)) - } + for fragment in outOfOrder { ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey) } - // Wait for delegate callback with proper timeout - try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5)) + await ble._test_drainFragmentPipeline() #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 3_000) @@ -73,16 +69,11 @@ struct FragmentationTests { frags.insert(dup, at: 1) } - // Send fragments sequentially with small delays (no fire-and-forget Tasks) - for (i, fragment) in frags.enumerated() { - if i > 0 { - try await Task.sleep(for: .milliseconds(5)) - } + for fragment in frags { ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey) } - // Wait for delegate callback with proper timeout - try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5)) + await ble._test_drainFragmentPipeline() #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 2048) @@ -94,6 +85,11 @@ struct FragmentationTests { let capture = CaptureDelegate() ble.delegate = capture + // Broadcast file transfers must carry a valid sender signature (same + // gate as public messages), so sign the packet and preseed the + // sender's signing key into the registry. + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let signingKey = signer.getSigningPublicKeyData() let remoteID = PeerID(str: "CAFEBABECAFEBABE") let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes) let filePacket = BitchatFilePacket( @@ -104,28 +100,28 @@ struct FragmentationTests { ) let encoded = try #require(filePacket.encode(), "File packet encoding failed") - let packet = BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: Data(hexString: remoteID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encoded, - signature: nil, - ttl: 7, - version: 2 + let packet = try #require( + signer.signPacket(BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: remoteID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encoded, + signature: nil, + ttl: 7, + version: 2 + )), + "Failed to sign file transfer packet" ) let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false) #expect(!fragments.isEmpty) - for (i, fragment) in fragments.enumerated() { - if i > 0 { - try await Task.sleep(for: .milliseconds(5)) - } - ble._test_handlePacket(fragment, fromPeerID: remoteID) + for fragment in fragments { + ble._test_handlePacket(fragment, fromPeerID: remoteID, signingPublicKey: signingKey) } - try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(5)) + await ble._test_drainFragmentPipeline() let message = try #require(capture.receivedMessages.first, "Expected file transfer message") #expect(message.content.hasPrefix("[file]")) @@ -165,15 +161,11 @@ struct FragmentationTests { corrupted[0] = p } - for (i, fragment) in corrupted.enumerated() { - if i > 0 { - try await Task.sleep(for: .milliseconds(5)) - } + for fragment in corrupted { ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } - - // Allow async processing - try await sleep(0.5) + + await ble._test_drainFragmentPipeline() // Should not deliver since one fragment is invalid and reassembly can't complete #expect(capture.publicMessages.isEmpty) @@ -199,10 +191,6 @@ extension FragmentationTests { private let lock = NSLock() private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = [] private var _receivedMessages: [BitchatMessage] = [] - private var publicMessageContinuation: CheckedContinuation? - private var receivedMessageContinuation: CheckedContinuation? - private var expectedPublicMessageCount: Int = 0 - private var expectedReceivedMessageCount: Int = 0 private func withLock(_ body: () -> T) -> T { lock.lock() @@ -219,113 +207,11 @@ extension FragmentationTests { } func didReceiveMessage(_ message: BitchatMessage) { - lock.lock() - _receivedMessages.append(message) - let count = _receivedMessages.count - let expected = expectedReceivedMessageCount - let continuation = receivedMessageContinuation - lock.unlock() - - if count >= expected, let cont = continuation { - lock.lock() - receivedMessageContinuation = nil - lock.unlock() - cont.resume() - } + withLock { _receivedMessages.append(message) } } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { - lock.lock() - _publicMessages.append((peerID, nickname, content)) - let count = _publicMessages.count - let expected = expectedPublicMessageCount - let continuation = publicMessageContinuation - lock.unlock() - - if count >= expected, let cont = continuation { - lock.lock() - publicMessageContinuation = nil - lock.unlock() - cont.resume() - } - } - - /// Waits for the specified number of public messages to be received - func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws { - let isAlreadySatisfied = withLock { () -> Bool in - if _publicMessages.count >= count { - return true - } - expectedPublicMessageCount = count - return false - } - if isAlreadySatisfied { - return - } - - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await withCheckedContinuation { continuation in - let shouldResumeImmediately = self.withLock { - // Recheck count after acquiring lock to avoid race condition - // where message arrives between initial check and continuation install - if self._publicMessages.count >= count { - return true - } - self.publicMessageContinuation = continuation - return false - } - if shouldResumeImmediately { - continuation.resume() - } - } - } - group.addTask { - try await Task.sleep(for: timeout) - throw CancellationError() - } - try await group.next() - group.cancelAll() - } - } - - /// Waits for the specified number of received messages - func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws { - let isAlreadySatisfied = withLock { () -> Bool in - if _receivedMessages.count >= count { - return true - } - expectedReceivedMessageCount = count - return false - } - if isAlreadySatisfied { - return - } - - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await withCheckedContinuation { continuation in - let shouldResumeImmediately = self.withLock { - // Recheck count after acquiring lock to avoid race condition - // where message arrives between initial check and continuation install - if self._receivedMessages.count >= count { - return true - } - self.receivedMessageContinuation = continuation - return false - } - if shouldResumeImmediately { - continuation.resume() - } - } - } - group.addTask { - try await Task.sleep(for: timeout) - throw CancellationError() - } - try await group.next() - group.cancelAll() - } + withLock { _publicMessages.append((peerID, nickname, content)) } } func didConnectToPeer(_ peerID: PeerID) {} @@ -335,7 +221,6 @@ extension FragmentationTests { func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didUpdateBluetoothState(_ state: CBManagerState) {} - func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {} } // Helper: build a large message packet (unencrypted public message) diff --git a/bitchatTests/GCSFilterTests.swift b/bitchatTests/GCSFilterTests.swift index 464f368e..b544f843 100644 --- a/bitchatTests/GCSFilterTests.swift +++ b/bitchatTests/GCSFilterTests.swift @@ -41,6 +41,24 @@ struct GCSFilterTests { #expect(truncated.allSatisfy { full.contains($0) }) } + @Test func buildFilterReportsFullCoverageWhenBudgetFits() { + let ids = (0..<8).map { i in Data(repeating: UInt8(i), count: 16) } + let params = GCSFilter.buildFilter(ids: ids, maxBytes: 1024, targetFpr: 0.01) + #expect(params.includedCount == ids.count) + } + + @Test func buildFilterTrimsTailWhenBudgetExceeded() { + // A tight byte budget can't hold every ID, so the encoder trims from + // the input tail and reports how many it actually covered. + let ids = (0..<200).map { i in + Data((0..<16).map { UInt8((i &* 31 &+ $0) & 0xFF) }) + } + let params = GCSFilter.buildFilter(ids: ids, maxBytes: 32, targetFpr: 0.01) + #expect(params.includedCount > 0) + #expect(params.includedCount < ids.count) + #expect(params.data.count <= 32) + } + @Test func requestSyncPacketDecodeRejectsOversizedP() { let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02])) #expect(RequestSyncPacket.decode(from: valid.encode()) != nil) diff --git a/bitchatTests/GeoChannelCoordinatorContextTests.swift b/bitchatTests/GeoChannelCoordinatorContextTests.swift index 9345b341..ac6b135f 100644 --- a/bitchatTests/GeoChannelCoordinatorContextTests.swift +++ b/bitchatTests/GeoChannelCoordinatorContextTests.swift @@ -14,6 +14,7 @@ // import Testing +import Combine import Foundation import CoreLocation import Tor @@ -40,12 +41,17 @@ private final class StubLocationManaging: LocationStateManaging { weak var delegate: CLLocationManagerDelegate? var desiredAccuracy: CLLocationAccuracy = 0 var distanceFilter: CLLocationDistance = 0 - var authorizationStatus: CLAuthorizationStatus = .denied + var authorizationStatus: CLAuthorizationStatus + private(set) var stopUpdatingLocationCallCount = 0 + + init(authorizationStatus: CLAuthorizationStatus = .denied) { + self.authorizationStatus = authorizationStatus + } func requestWhenInUseAuthorization() {} func requestLocation() {} func startUpdatingLocation() {} - func stopUpdatingLocation() {} + func stopUpdatingLocation() { stopUpdatingLocationCallCount += 1 } } private final class StubLocationGeocoder: LocationStateGeocoding { @@ -61,7 +67,11 @@ private final class StubLocationGeocoder: LocationStateGeocoding { // MARK: - Helpers @MainActor -private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateManager { +private func makeLocationManager( + storage: UserDefaults? = nil, + authorizationStatus: CLAuthorizationStatus = .denied, + shouldInitializeCoreLocation: Bool = false +) -> LocationStateManager { let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)" let defaults = storage ?? UserDefaults(suiteName: suiteName)! if storage == nil { @@ -69,9 +79,9 @@ private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateM } return LocationStateManager( storage: defaults, - locationManager: StubLocationManaging(), + locationManager: StubLocationManaging(authorizationStatus: authorizationStatus), geocoder: StubLocationGeocoder(), - shouldInitializeCoreLocation: false + shouldInitializeCoreLocation: shouldInitializeCoreLocation ) } @@ -168,11 +178,109 @@ struct GeoChannelCoordinatorContextTests { #expect(await waitUntil { context.endSamplingCount > endCountBeforeRefresh }) } + @Test @MainActor + func buildingCellJoinsSamplingOnlyAfterNotesReveal() async { + TorManager.shared.setAppForeground(true) + let locationManager = makeLocationManager( + authorizationStatus: .authorizedAlways, + shouldInitializeCoreLocation: true + ) + #expect(await waitUntil { locationManager.permissionState == .authorized }) + let context = MockGeoChannelContext() + let revealed = CurrentValueSubject(false) + let notesEnabled = CurrentValueSubject(true) + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + notesRevealed: revealed.eraseToAnyPublisher(), + locationNotesEnabled: true, + locationNotesSettings: notesEnabled.eraseToAnyPublisher(), + context: context + ) + defer { withExtendedLifetime(coordinator) {} } + + // A location fix yields all six channel levels… + locationManager.locationManager( + CLLocationManager(), + didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] + ) + #expect(await waitUntil { + locationManager.availableChannels.contains { $0.level == .building } + }) + let building = locationManager.availableChannels.first { $0.level == .building }!.geohash + + // …but pre-reveal sampling must exclude the building-precision cell: + // a passive precision-8 REQ identifies a single address. + #expect(await waitUntil { + (context.beginSamplingCalls.last?.count ?? 0) == GeohashChannelLevel.allCases.count - 1 + }) + #expect(context.beginSamplingCalls.allSatisfy { !$0.contains(building) }) + + // The explicit notes act widens sampling to include it. + revealed.send(true) + #expect(await waitUntil { context.beginSamplingCalls.last?.contains(building) == true }) + #expect(context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count) + + // The app-info kill switch must narrow the already-live sampling set + // immediately, without waiting for another location update. + notesEnabled.send(false) + #expect(await waitUntil { + context.beginSamplingCalls.last?.contains(building) == false && + context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count - 1 + }) + } + + @Test @MainActor + func permissionRevocationEndsCachedRegionalSampling_butBookmarksRemainEligible() async { + TorManager.shared.setAppForeground(true) + let locationManager = makeLocationManager( + authorizationStatus: .authorizedAlways, + shouldInitializeCoreLocation: true + ) + #expect(await waitUntil { locationManager.permissionState == .authorized }) + let context = MockGeoChannelContext() + let revealed = CurrentValueSubject(true) + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + notesRevealed: revealed.eraseToAnyPublisher(), + locationNotesEnabled: true, + locationNotesSettings: Empty().eraseToAnyPublisher(), + context: context + ) + defer { withExtendedLifetime(coordinator) {} } + + locationManager.locationManager( + CLLocationManager(), + didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] + ) + #expect(await waitUntil { + context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count + }) + let cachedChannels = locationManager.availableChannels + let endCountBeforeRevocation = context.endSamplingCount + + locationManager.locationManager(CLLocationManager(), didChangeAuthorization: .denied) + + #expect(await waitUntil { + locationManager.permissionState == .denied && + context.endSamplingCount > endCountBeforeRevocation + }) + #expect(locationManager.availableChannels == cachedChannels) + + // A bookmark is an explicit remote scope and does not derive from + // the now-revoked device location. + locationManager.addBookmark("u4pru") + #expect(await waitUntil { context.beginSamplingCalls.last == ["u4pru"] }) + } + @Test @MainActor func releasedContext_isHeldWeaklyAndSafelyIgnored() async { let locationManager = makeLocationManager() var context: MockGeoChannelContext? = MockGeoChannelContext() - weak var weakContext = context + let weakContext = { [weak context] in context } let coordinator = GeoChannelCoordinator( locationManager: locationManager, bookmarksStore: locationManager, @@ -184,7 +292,7 @@ struct GeoChannelCoordinatorContextTests { // The coordinator must not keep the owner alive (it is owned by it). context = nil - #expect(weakContext == nil) + #expect(weakContext() == nil) // Events after the owner is gone are safely dropped. locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru"))) diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 54e65afc..2d39ea88 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -91,6 +91,47 @@ struct GossipSyncManagerTests { #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0) } + @Test func removePublicMessagesPurgesOnlyThatSender() throws { + // Block-time archive hygiene: purging a blocked sender's carried + // public messages must not touch other senders' messages or the + // blocked sender's announcement. + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager) + let blockedHex = "00112233445566aa" + let otherHex = "00112233445566bb" + let blockedData = try #require(Data(hexString: blockedHex)) + let otherData = try #require(Data(hexString: otherHex)) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + manager.onPublicPacketSeen(BitchatPacket( + type: MessageType.announce.rawValue, + senderID: blockedData, + recipientID: nil, + timestamp: nowMs, + payload: Data(), + signature: nil, + ttl: 1 + )) + for (index, sender) in [blockedData, blockedData, otherData].enumerated() { + manager.onPublicPacketSeen(BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs + UInt64(index), + payload: Data([UInt8(index)]), + signature: nil, + ttl: 1 + )) + } + #expect(manager._messageCount(for: PeerID(str: blockedHex)) == 2) + + manager.removePublicMessages(from: PeerID(str: blockedHex)) + + #expect(manager._messageCount(for: PeerID(str: blockedHex)) == 0) + #expect(manager._messageCount(for: PeerID(str: otherHex)) == 1) + #expect(manager._hasAnnouncement(for: PeerID(str: blockedHex))) + } + @Test func ignoresAnnounceOlderThanStaleTimeout() throws { var config = GossipSyncManager.Config() config.stalePeerTimeoutSeconds = 5 @@ -139,6 +180,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 1 config.fragmentSyncIntervalSeconds = 1 config.fileTransferSyncIntervalSeconds = 1 + config.prekeyBundleSyncIntervalSeconds = 1 config.maintenanceIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() @@ -194,15 +236,251 @@ struct GossipSyncManagerTests { manager._performMaintenanceSynchronously(now: Date()) + // One request per due schedule so each type group gets the full + // filter capacity: publicMessages, fragment, fileTransfer, and + // prekeyBundle. let sentPackets = delegate.packets - #expect(sentPackets.count == 1) + #expect(sentPackets.count == 4) let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) } - #expect(decoded.count == 1) - let types = try #require(decoded.first?.types) - #expect(types.contains(.announce)) - #expect(types.contains(.message)) - #expect(types.contains(.fragment)) - #expect(types.contains(.fileTransfer)) + #expect(decoded.count == 4) + let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) } + #expect(allTypes.contains(.announce)) + #expect(allTypes.contains(.message)) + #expect(allTypes.contains(.fragment)) + #expect(allTypes.contains(.fileTransfer)) + #expect(allTypes.contains(.prekeyBundle)) + #expect(allTypes.contains(.groupMessage)) + // The message schedule also asks for group messages (bit 10); + // responders that don't know the bit just ignore it. + #expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) }) + #expect(decoded.contains { $0.types == .fragment }) + #expect(decoded.contains { $0.types == .fileTransfer }) + #expect(decoded.contains { $0.types == .prekeyBundle }) + } + + @Test func truncatedFilterCarriesSinceCursor() throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 100 + config.gcsMaxBytes = 32 // caps the filter at 28 IDs (256 bits / 9 bits per element) + config.messageSyncIntervalSeconds = 1 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.maintenanceIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "1122334455667788")) + let baseTimestamp = UInt64(Date().timeIntervalSince1970 * 1000) + let totalMessages = 40 + for i in 0..= baseTimestamp + 12) + #expect(since < baseTimestamp + UInt64(totalMessages)) + } + + @Test func fullCoverageFilterOmitsSinceCursor() throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 100 + config.messageSyncIntervalSeconds = 1 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.maintenanceIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "1122334455667788")) + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(packet) + + manager._performMaintenanceSynchronously(now: Date()) + + let sent = try #require(delegate.packets.first) + let request = try #require(RequestSyncPacket.decode(from: sent.payload)) + #expect(request.sinceTimestamp == nil) + } + + @Test func handleRequestSyncHonorsSinceCursorButAlwaysSendsAnnounces() async throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 5 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + // Announce older than the cursor: must still be sent (identity is + // needed to verify everything else). + let announcePacket = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs - 50_000, + payload: Data(), + signature: nil, + ttl: 1 + ) + let oldMessage = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs - 60_000, + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + let newMessage = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs, + payload: Data([0x02]), + signature: nil, + ttl: 1 + ) + + manager.onPublicPacketSeen(announcePacket) + manager.onPublicPacketSeen(oldMessage) + manager.onPublicPacketSeen(newMessage) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + let request = RequestSyncPacket( + p: 7, + m: 1, + data: Data(), + types: .publicMessages, + sinceTimestamp: nowMs - 30_000 + ) + manager.handleRequestSync(from: peer, request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout) + // Barrier: flush the sync queue so a late third packet would be visible. + manager._performMaintenanceSynchronously(now: Date()) + let sentPackets = delegate.packets + #expect(sentPackets.count == 2) + #expect(sentPackets.contains { $0.type == MessageType.announce.rawValue }) + let sentMessages = sentPackets.filter { $0.type == MessageType.message.rawValue } + #expect(sentMessages.count == 1) + #expect(sentMessages.first?.payload == Data([0x02])) + #expect(sentPackets.allSatisfy { $0.isRSR }) + } + + @Test func handleRequestSyncSkipsAnnounceAlreadyInFilter() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let announcePacket = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(announcePacket) + + // A filter that already contains the announce's canonical ID must + // suppress the response — this only holds if the responder recomputes + // the ID the same way the filter was built (the dual-path bug would + // diff a stored hex string instead). + let announceID = PacketIdUtil.computeId(announcePacket) + let params = GCSFilter.buildFilter(ids: [announceID], maxBytes: 256, targetFpr: 0.01) + let request = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: .announce) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + manager.handleRequestSync(from: peer, request: request) + // Barrier: the async handler is enqueued, so this sync flush runs after it. + manager._performMaintenanceSynchronously(now: Date()) + #expect(delegate.packets.isEmpty) + } + + @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 5 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + config.responseRateLimitMaxResponses = 1 + config.responseRateLimitWindowSeconds = 60 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let messagePacket = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x10]), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(messagePacket) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .message) + manager.handleRequestSync(from: peer, request: request) + manager.handleRequestSync(from: peer, request: request) + + try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout) + // Barrier: both requests have been processed once this returns. + manager._performMaintenanceSynchronously(now: Date()) + #expect(delegate.packets.count == 1) } @Test func initialSyncCoalescesEnabledTypes() async throws { @@ -228,6 +506,7 @@ struct GossipSyncManagerTests { #expect(types.contains(.message)) #expect(types.contains(.fragment)) #expect(types.contains(.fileTransfer)) + #expect(types.contains(.prekeyBundle)) } @Test func handleRequestSyncHonorsTypeFilter() async throws { @@ -279,10 +558,263 @@ struct GossipSyncManagerTests { #expect(sentPackets.count == 1) #expect(sentPackets[0].type == MessageType.fragment.rawValue) } + + // MARK: - Fragment-ID filter (targeted resync) + + private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket { + // Fragment payload: 8-byte stream ID + index + total + original type. + var payload = fragmentID + payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) }) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) }) + payload.append(MessageType.fileTransfer.rawValue) + payload.append(Data([0xEE])) + return BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: sender, + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 1 + ) + } + + @Test func handleRequestSyncHonorsFragmentIdFilter() async throws { + var config = GossipSyncManager.Config() + config.fragmentCapacity = 10 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let wantedID = try #require(Data(hexString: "0102030405060708")) + let otherID = try #require(Data(hexString: "1112131415161718")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000) + let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs) + manager.onPublicPacketSeen(wanted) + manager.onPublicPacketSeen(other) + + // The since-cursor sits after both fragments; without the filter the + // responder would send nothing for `wanted`. The filter both bypasses + // the cursor and restricts the diff to exactly the named stream. + let request = RequestSyncPacket( + p: 7, + m: 1, + data: Data(), + types: .fragment, + sinceTimestamp: nowMs + 1, + fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID]) + ) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + // Barrier: flush the sync queue so a late second packet would be visible. + manager._performMaintenanceSynchronously(now: Date()) + let sentPackets = delegate.packets + #expect(sentPackets.count == 1) + let sent = try #require(sentPackets.first) + #expect(sent.type == MessageType.fragment.rawValue) + #expect(sent.payload.prefix(8) == wantedID) + #expect(sent.ttl == 0) + #expect(sent.isRSR) + } + + @Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")] + manager.delegate = delegate + + let stalledID = try #require(Data(hexString: "0102030405060708")) + manager.requestMissingFragments(fragmentIDs: [stalledID]) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let sent = try #require(delegate.packets.first) + #expect(sent.type == MessageType.requestSync.rawValue) + #expect(sent.ttl == 0) + let request = try #require(RequestSyncPacket.decode(from: sent.payload)) + #expect(request.types == .fragment) + let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)) + #expect(ids == Set([stalledID])) + } + + @Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + config.stalePeerCleanupIntervalSeconds = 0 + config.stalePeerTimeoutSeconds = 5 + + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingDelegate() + manager.delegate = delegate + + // Bundles are keyed by their authenticated identity (the noise static + // key), not the packet senderID, so the payload must be a real bundle. + let noiseKey = Data(repeating: 0xAB, count: 32) + let senderPeer = PeerID(publicKey: noiseKey) + let sender = try #require(Data(hexString: senderPeer.id)) + let bundle = PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x11, count: 32))], + generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), + signature: Data(count: PrekeyBundle.signatureLength) + ) + let bundlePacket = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: try #require(bundle.encode()), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(bundlePacket) + manager._performMaintenanceSynchronously(now: Date()) + #expect(manager._hasPrekeyBundle(for: senderPeer)) + + // Bundles outlive the owner's announce: a leave plus stale cleanup + // must not drop them (they exist to reach offline owners). + manager.removeAnnouncementForPeer(senderPeer) + manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)) + #expect(manager._hasPrekeyBundle(for: senderPeer)) + + // And a .prekeyBundle sync request is answered with the stored packet. + let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let served = try #require(delegate.packets.first) + #expect(served.type == MessageType.prekeyBundle.rawValue) + #expect(served.isRSR) + } + + @Test func prekeyBundleGossipIsKeyedByOwnerNotSenderID() { + // One valid bundle re-broadcast under many fabricated sender IDs must + // collapse to a single entry keyed by the bundle's own identity — the + // spray-to-exhaust-the-cap DoS produces one entry, not N. + let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager()) + let noiseKey = Data(repeating: 0xCD, count: 32) + let ownerPeer = PeerID(publicKey: noiseKey) + let bundle = PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x22, count: 32))], + generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), + signature: Data(count: PrekeyBundle.signatureLength) + ) + guard let payload = bundle.encode() else { return } + + for i in 0..<5 { + let fakeSender = Data((0..<8).map { j in UInt8(truncatingIfNeeded: i * 31 + j) }) + let packet = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: fakeSender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(i), + payload: payload, + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(packet) + manager._performMaintenanceSynchronously(now: Date()) + // No fabricated sender ID ever creates its own entry. + #expect(!manager._hasPrekeyBundle(for: PeerID(hexData: fakeSender))) + } + // Exactly the owner-keyed entry exists. + #expect(manager._hasPrekeyBundle(for: ownerPeer)) + } + + // MARK: - Archive persistence + + @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let senderID = try #require(Data(hexString: "1122334455667788")) + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: senderID, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01, 0x02]), + signature: nil, + ttl: 1 + ) + + let first = GossipSyncManager( + myPeerID: myPeerID, + requestSyncManager: RequestSyncManager(), + archive: GossipMessageArchive(fileURL: fileURL) + ) + first.onPublicPacketSeen(packet) + // Maintenance persists the dirty store to disk. + first._performMaintenanceSynchronously(now: Date()) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + // "App restart": a fresh manager over the same archive re-serves it. + let second = GossipSyncManager( + myPeerID: myPeerID, + requestSyncManager: RequestSyncManager(), + archive: GossipMessageArchive(fileURL: fileURL) + ) + let restored = await TestHelpers.waitUntil( + { second._messageCount(for: PeerID(hexData: senderID)) == 1 }, + timeout: TestConstants.shortTimeout + ) + #expect(restored) + } + + @Test func archiveDropsMessagesOlderThanPublicWindow() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + var config = GossipSyncManager.Config() + config.publicMessageMaxAgeSeconds = 60 + + let senderID = try #require(Data(hexString: "1122334455667788")) + let stale = BitchatPacket( + type: MessageType.message.rawValue, + senderID: senderID, + recipientID: nil, + timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000), + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + let archive = GossipMessageArchive(fileURL: fileURL) + archive.save([stale.toBinaryData(padding: false)!]) + + let manager = GossipSyncManager( + myPeerID: myPeerID, + config: config, + requestSyncManager: RequestSyncManager(), + archive: archive + ) + manager._performMaintenanceSynchronously(now: Date()) + #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0) + } + } private final class RecordingDelegate: GossipSyncManager.Delegate { var onSend: (() -> Void)? + var connectedPeers: [PeerID] = [] private(set) var lastPacket: BitchatPacket? private(set) var packets: [BitchatPacket] = [] private let lock = NSLock() @@ -304,6 +836,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return [] + return connectedPeers } } diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 52b4d2de..11ba1602 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -174,7 +174,7 @@ struct IntegrationTests { } // Encrypted path: use NoiseSessionManager explicitly - let plaintext = "Encrypted message".data(using: .utf8)! + let plaintext = Data("Encrypted message".utf8) let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID) helper.nodes["Bob"]!.packetDeliveryHandler = { packet in @@ -206,7 +206,7 @@ struct IntegrationTests { try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in // David tracks received messages - helper.nodes["David"]!.messageDeliveryHandler = { message in + helper.nodes["David"]!.messageDeliveryHandler = { _ in completion() } @@ -288,7 +288,7 @@ struct IntegrationTests { } do { - let plaintext = "After restart success".data(using: .utf8)! + let plaintext = Data("After restart success".utf8) let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID) let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index 277e1f52..d9e0b3a9 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -33,14 +33,6 @@ final class TestNetworkHelper { return node } - func getNode(_ name: String) -> MockBLEService? { - nodes[name] - } - - func getManager(_ name: String) -> NoiseSessionManager? { - noiseManagers[name] - } - // MARK: - Topology func connect(_ a: String, _ b: String) { @@ -121,4 +113,3 @@ final class TestNetworkHelper { _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) } } - diff --git a/bitchatTests/LocalizationCoverageTests.swift b/bitchatTests/LocalizationCoverageTests.swift new file mode 100644 index 00000000..c9d7c602 --- /dev/null +++ b/bitchatTests/LocalizationCoverageTests.swift @@ -0,0 +1,72 @@ +import Testing +import Foundation + +/// Guards against locale gaps in the string catalogs: every translatable key +/// must have a localization for every supported locale, so no user ever sees +/// an English fallback (see PR #1391 review). +struct LocalizationCoverageTests { + private static let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // bitchatTests + .deletingLastPathComponent() // repo root + + private struct Catalog { + /// key -> set of locales with a localization entry + let coverage: [String: Set] + /// all locales appearing anywhere in the catalog + var allLocales: Set { coverage.values.reduce(into: []) { $0.formUnion($1) } } + } + + private static func loadCatalog(_ relativePath: String) throws -> Catalog { + let url = repoRoot.appendingPathComponent(relativePath) + let data = try Data(contentsOf: url) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let strings = try #require(root["strings"] as? [String: Any]) + + var coverage: [String: Set] = [:] + for (key, value) in strings { + guard let entry = value as? [String: Any] else { continue } + if entry["shouldTranslate"] as? Bool == false { continue } + let localizations = entry["localizations"] as? [String: Any] ?? [:] + var locales: Set = [] + for (locale, loc) in localizations { + guard let loc = loc as? [String: Any] else { continue } + // A localization counts if it has a non-empty stringUnit value + // or uses variations/substitutions (plural forms). + if let unit = loc["stringUnit"] as? [String: Any], + let unitValue = unit["value"] as? String, !unitValue.isEmpty { + locales.insert(locale) + } else if loc["variations"] != nil || loc["substitutions"] != nil { + locales.insert(locale) + } + } + coverage[key] = locales + } + return Catalog(coverage: coverage) + } + + @Test func mainCatalogCoversAllLocalesForEveryKey() throws { + let catalog = try Self.loadCatalog("bitchat/Localizable.xcstrings") + let expected = catalog.allLocales + #expect(expected.count > 1, "catalog should declare more locales than the source language") + for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) { + let missing = expected.subtracting(locales).sorted() + #expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))") + } + } + + @Test func shareExtensionCatalogCoversAllLocalesForEveryKey() throws { + let catalog = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings") + let expected = catalog.allLocales + for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) { + let missing = expected.subtracting(locales).sorted() + #expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))") + } + } + + @Test func shareExtensionSupportsSameLocalesAsMainApp() throws { + let main = try Self.loadCatalog("bitchat/Localizable.xcstrings") + let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings") + let missing = main.allLocales.subtracting(shareExt.allLocales).sorted() + #expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))") + } +} diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index bf08fd84..abd16e3d 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -122,27 +122,6 @@ struct LocationNotesManagerTests { #expect(manager.notes.first?.content == "hi") } - @Test - func setGeohash_invalidValueIsIgnored() { - var subscribeCount = 0 - let deps = LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { _, _, _, _, _ in - subscribeCount += 1 - }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in try NostrIdentity.generate() }, - now: { Date() } - ) - - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) - manager.setGeohash("not-valid") - - #expect(manager.geohash == "u4pruydq") - #expect(subscribeCount == 1) - } - @Test func refreshAndCancel_manageSubscriptions() { var subscribeIDs: [String] = [] @@ -216,6 +195,199 @@ struct LocationNotesManagerTests { #expect(manager.errorMessage == nil) } + @Test + func ingestDropsExpiredNotesAndKeepsUnexpiredOnes() throws { + var storedHandler: ((NostrEvent) -> Void)? + let now = Date(timeIntervalSince1970: 1_700_000_000) + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, handler, _ in + storedHandler = handler + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { now } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + let identity = try NostrIdentity.generate() + + let expired = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: now.addingTimeInterval(-3600), + kind: .textNote, + tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]], + content: "gone" + ) + storedHandler?(try expired.sign(with: identity.schnorrSigningKey())) + + let live = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: now.addingTimeInterval(-3600), + kind: .textNote, + tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) + 3600)]], + content: "still here" + ) + storedHandler?(try live.sign(with: identity.schnorrSigningKey())) + + #expect(manager.notes.count == 1) + #expect(manager.notes.first?.content == "still here") + #expect(manager.notes.first?.expiresAt == Date(timeIntervalSince1970: TimeInterval(Int(now.timeIntervalSince1970) + 3600))) + } + + @Test + func postDrop_sendsExpiringNoteToGeoRelays() throws { + var sentEvents: [NostrEvent] = [] + let now = Date(timeIntervalSince1970: 1_700_000_000) + let identity = try NostrIdentity.generate() + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { event, _ in sentEvents.append(event) }, + deriveIdentity: { _ in identity }, + now: { now } + ) + + let posted = LocationNotesManager.postDrop( + content: " the coffee here is great ", + nickname: "scout", + geohash: "u4pruydq", + dependencies: deps + ) + + #expect(posted) + #expect(sentEvents.count == 1) + let event = try #require(sentEvents.first) + #expect(event.kind == NostrProtocol.EventKind.textNote.rawValue) + #expect(event.content == "the coffee here is great") + #expect(event.tags.contains(["g", "u4pruydq"])) + let expiration = event.tags.first { $0.first == "expiration" }?.last + let expected = Int(now.addingTimeInterval(TransportConfig.locationDropExpirySeconds).timeIntervalSince1970) + #expect(expiration == String(expected)) + } + + @Test + func postDrop_failsWithoutRelays() { + let deps = LocationNotesDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + + #expect(!LocationNotesManager.postDrop(content: "hi", nickname: "x", geohash: "u4pruydq", dependencies: deps)) + } + + @Test + func pruneExpiredNotes_dropsNotesWhoseExpiryPassed() throws { + var storedHandler: ((NostrEvent) -> Void)? + var currentNow = Date(timeIntervalSince1970: 1_700_000_000) + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, handler, _ in + storedHandler = handler + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { currentNow } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + let identity = try NostrIdentity.generate() + let note = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: currentNow, + kind: .textNote, + tags: [["g", "u4pruydq"], ["expiration", String(Int(currentNow.timeIntervalSince1970) + 60)]], + content: "short lived" + ) + storedHandler?(try note.sign(with: identity.schnorrSigningKey())) + #expect(manager.notes.count == 1) + + currentNow = currentNow.addingTimeInterval(120) + manager.pruneExpiredNotes() + + #expect(manager.notes.isEmpty) + } + + @Test + func eoseWithoutConnectedRelays_showsConnectingInsteadOfEmpty() { + var storedEOSE: (() -> Void)? + var deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, eose in storedEOSE = eose }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + deps.anyRelayConnected = { _ in false } + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + storedEOSE?() + + #expect(manager.state == .connecting) + #expect(manager.initialLoadComplete) + } + + @Test + func eoseWithConnectedRelayAndNoNotes_isReadyEmpty() { + var storedEOSE: (() -> Void)? + var deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, eose in storedEOSE = eose }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + deps.anyRelayConnected = { _ in true } + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + storedEOSE?() + + #expect(manager.state == .ready) + } + + @Test + func connectingState_retriesOnceARelayComesUp() { + var storedEOSE: (() -> Void)? + var subscribeCount = 0 + var relayUp = false + var deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, eose in + subscribeCount += 1 + storedEOSE = eose + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + deps.anyRelayConnected = { _ in relayUp } + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + #expect(subscribeCount == 1) + storedEOSE?() + #expect(manager.state == .connecting) + + // Relay still down: no retry. + manager.retryIfRelaysAvailable(relays: ["wss://relay.one"]) + #expect(subscribeCount == 1) + + // Relay up: re-subscribes for a fresh initial fetch. + relayUp = true + manager.retryIfRelaysAvailable(relays: ["wss://relay.one"]) + #expect(subscribeCount == 2) + #expect(manager.state == .loading) + } + private enum TestError: Error { case shouldNotDerive } diff --git a/bitchatTests/MessageDeduplicationServiceTests.swift b/bitchatTests/MessageDeduplicationServiceTests.swift index 251fe808..971c17f0 100644 --- a/bitchatTests/MessageDeduplicationServiceTests.swift +++ b/bitchatTests/MessageDeduplicationServiceTests.swift @@ -312,6 +312,30 @@ struct MessageDeduplicationServiceTests { #expect(service.contentTimestamp(forKey: key) == now) } + @Test func forgetContent_allowsAuthenticatedReplacementThroughNextBatch() { + let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) + let content = "bridge alias payload" + let timestamp = Date() + service.recordContent(content, timestamp: timestamp) + + service.forgetContent(content, ifRecordedAt: timestamp) + + #expect(service.contentTimestamp(for: content) == nil) + } + + @Test func forgetContent_doesNotEraseNewerSameContentMarker() { + let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) + let content = "repeated payload" + let old = Date(timeIntervalSince1970: 1_000) + let newer = Date(timeIntervalSince1970: 2_000) + service.recordContent(content, timestamp: old) + service.recordContent(content, timestamp: newer) + + service.forgetContent(content, ifRecordedAt: old) + + #expect(service.contentTimestamp(for: content) == newer) + } + @Test func normalizedContentKey_consistentWithNormalizer() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let content = "Hello World" diff --git a/bitchatTests/MessageRateLimiterTests.swift b/bitchatTests/MessageRateLimiterTests.swift new file mode 100644 index 00000000..c9959761 --- /dev/null +++ b/bitchatTests/MessageRateLimiterTests.swift @@ -0,0 +1,119 @@ +// +// MessageRateLimiterTests.swift +// bitchatTests +// +// Tests for the public-intake token buckets, including the NIP-13 +// proof-of-work relaxation of the per-sender bucket. +// + +import Foundation +import Testing +@testable import bitchat + +struct MessageRateLimiterTests { + + private func makeLimiter( + senderCapacity: Double = 2, + contentCapacity: Double = 100 + ) -> MessageRateLimiter { + MessageRateLimiter( + senderCapacity: senderCapacity, + senderRefillPerSec: 0.0001, + contentCapacity: contentCapacity, + contentRefillPerSec: 0.0001 + ) + } + + @Test func senderBucketBlocksAfterCapacity() { + var limiter = makeLimiter() + let now = Date() + + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) + let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now) + + #expect(first) + #expect(second) + #expect(!third) + #expect(otherSender) + } + + @Test func validPoWBypassesExhaustedSenderBucket() { + var limiter = makeLimiter() + let now = Date() + + // Exhaust the sender bucket with plain (no-PoW) messages. + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) + let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + + // A message carrying sufficient validated PoW still passes, and so + // does more-than-sufficient PoW; plain messages stay blocked. + let powExact = limiter.allow( + senderKey: "s", + contentKey: "c4", + powBits: NostrPoW.rateLimitBypassBits, + now: now + ) + let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now) + let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now) + + #expect(first) + #expect(second) + #expect(!exhausted) + #expect(powExact) + #expect(powHigh) + #expect(!plainAgain) + } + + @Test func lowPoWDoesNotBypassSenderBucket() { + var limiter = makeLimiter(senderCapacity: 1) + let now = Date() + + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let lowPow = limiter.allow( + senderKey: "s", + contentKey: "c2", + powBits: NostrPoW.rateLimitBypassBits - 1, + now: now + ) + let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now) + + #expect(first) + #expect(!lowPow) + #expect(!zeroPow) + } + + @Test func powDoesNotBypassContentFloodBucket() { + var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1) + let now = Date() + + let first = limiter.allow(senderKey: "a", contentKey: "same", now: now) + // Identical content spammed with PoW is still throttled by the + // content bucket: PoW only relaxes the per-sender limit. + let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now) + let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now) + + #expect(first) + #expect(!powSameContent) + #expect(powNewContent) + } + + @Test func powBypassDoesNotDrainSenderBucket() { + var limiter = makeLimiter(senderCapacity: 1) + let now = Date() + + // PoW messages don't consume sender tokens, so a subsequent plain + // message still has its full budget. + let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now) + let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now) + let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now) + + #expect(powFirst) + #expect(powSecond) + #expect(plain) + #expect(!plainExhausted) + } +} diff --git a/bitchatTests/MimeTypeTests.swift b/bitchatTests/MimeTypeTests.swift index a6b20b10..b30436f3 100644 --- a/bitchatTests/MimeTypeTests.swift +++ b/bitchatTests/MimeTypeTests.swift @@ -52,19 +52,19 @@ struct MimeTypeTests { @Test(arguments: [ // === Image types === (MimeType.jpeg, [0xFF, 0xD8, 0xFF]), - (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), - (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" + (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP" // === Audio types === - (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" - (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, + (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" + (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE" - (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" + (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" // === Application types === - (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" + (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" ]) func validSignatures(mime: MimeType, bytes: [UInt8]) throws { let data = Data(bytes) diff --git a/bitchatTests/Mocks/MockBLEBus.swift b/bitchatTests/Mocks/MockBLEBus.swift index c4b5cb24..7496d8db 100644 --- a/bitchatTests/Mocks/MockBLEBus.swift +++ b/bitchatTests/Mocks/MockBLEBus.swift @@ -8,7 +8,6 @@ import Foundation import BitFoundation -@testable import bitchat final class MockBLEBus { private var registry: [PeerID: MockBLEService] = [:] diff --git a/bitchatTests/Mocks/MockBLEService.swift b/bitchatTests/Mocks/MockBLEService.swift index 7437a8be..a127b3dd 100644 --- a/bitchatTests/Mocks/MockBLEService.swift +++ b/bitchatTests/Mocks/MockBLEService.swift @@ -48,10 +48,6 @@ final class MockBLEService: NSObject { set { myNickname = newValue } } - var nickname: String { - return myNickname - } - var peerID: PeerID { return myPeerID } @@ -62,12 +58,6 @@ final class MockBLEService: NSObject { self.bus = bus } - // MARK: - Methods matching BLEService - - func setNickname(_ nickname: String) { - self.myNickname = nickname - } - // MARK: - In-memory test bus (for E2E/Integration) /// Registers this instance on first use. @@ -92,10 +82,6 @@ final class MockBLEService: NSObject { return connectedPeers.contains(peerID) } - func peerNickname(peerID: String) -> String? { - "MockPeer_\(peerID)" - } - func getPeerNicknames() -> [PeerID: String] { var nicknames: [PeerID: String] = [:] for peer in connectedPeers { @@ -103,10 +89,6 @@ final class MockBLEService: NSObject { } return nicknames } - - func getPeers() -> [PeerID: String] { - return getPeerNicknames() - } /// Keep local echo synchronous so Swift Testing confirmations observe it deterministically. private func deliverLocalEcho(_ message: BitchatMessage) { @@ -155,14 +137,6 @@ final class MockBLEService: NSObject { } } - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { - // Tests currently ignore file transfer flows; keep stub for protocol conformance. - } - - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { - // Tests currently ignore file transfer flows; keep stub for protocol conformance. - } - func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) { let message = BitchatMessage( id: messageID, @@ -209,39 +183,6 @@ final class MockBLEService: NSObject { } } - func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { - // Mock implementation - } - - func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { - // Mock implementation - } - - func sendBroadcastAnnounce() { - // Mock implementation - } - - func getPeerFingerprint(_ peerID: String) -> String? { - return nil - } - - func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { - return .none - } - - func triggerHandshake(with peerID: String) { - // Mock implementation - } - - func emergencyDisconnectAll() { - connectedPeers.removeAll() - delegate?.didUpdatePeerList([]) - } - - func getFingerprint(for peerID: String) -> String? { - return nil - } - // MARK: - Test Helper Methods func simulateConnectedPeer(_ peerID: PeerID) { @@ -314,9 +255,6 @@ final class MockBLEService: NSObject { } } -// Backward compatibility for older tests -typealias MockSimplifiedBluetoothService = MockBLEService - // MARK: - Helpers extension MockBLEService { diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 633c388b..a3603017 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -11,18 +11,11 @@ import BitFoundation @testable import bitchat final class MockIdentityManager: SecureIdentityStateManagerProtocol { - private let keychain: KeychainManagerProtocol private var blockedFingerprints: Set = [] private var blockedNostrPubkeys: Set = [] private var socialIdentities: [String: SocialIdentity] = [:] - - init(_ keychain: KeychainManagerProtocol) { - self.keychain = keychain - } - - func loadIdentityCache() {} - - func saveIdentityCache() {} + + init(_: KeychainManagerProtocol) {} func forceSave() {} @@ -45,12 +38,6 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { } } - func getFavorites() -> Set { - Set() - } - - func setFavorite(_ fingerprint: String, isFavorite: Bool) {} - func isFavorite(fingerprint: String) -> Bool { false } @@ -99,20 +86,57 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { } func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} - - func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} - + func clearAllIdentityData() {} func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) {} - + func isVerified(fingerprint: String) -> Bool { true } - + func getVerifiedFingerprints() -> Set { Set() } + + // MARK: Vouching (transitive verification) + + private var vouchesByVouchee: [String: [VouchRecord]] = [:] + private var vouchBatchSentAt: [String: Date] = [:] + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + guard voucheeFingerprint != voucherFingerprint else { return false } + var records = vouchesByVouchee[voucheeFingerprint] ?? [] + records.removeAll { $0.voucherFingerprint == voucherFingerprint } + records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp)) + vouchesByVouchee[voucheeFingerprint] = records + return true + } + + func validVouchers(for fingerprint: String) -> [VouchRecord] { + vouchesByVouchee[fingerprint] ?? [] + } + + func isVouched(fingerprint: String) -> Bool { + !(vouchesByVouchee[fingerprint] ?? []).isEmpty + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + vouchBatchSentAt[fingerprint] + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + vouchBatchSentAt[fingerprint] = date + } + + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + nil + } + + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + [] + } } diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index 2c8d7c32..c12f2109 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -17,6 +17,7 @@ final class MockKeychain: KeychainManagerProtocol { // BCH-01-009: Configurable error simulation for testing var simulatedReadError: KeychainReadResult? var simulatedSaveError: KeychainSaveResult? + var simulatedGenericReadError: KeychainReadResult? func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData @@ -82,9 +83,23 @@ final class MockKeychain: KeychainManagerProtocol { serviceStorage[service]?[key] } + func loadWithResult(key: String, service: String) -> KeychainReadResult { + if let simulatedGenericReadError { + return simulatedGenericReadError + } + if let data = serviceStorage[service]?[key] { + return .success(data) + } + return .itemNotFound + } + func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } + + func deleteAll(service: String) { + serviceStorage.removeValue(forKey: service) + } } /// Typealias for backwards compatibility with tests using MockKeychainHelper @@ -198,4 +213,8 @@ final class TrackingMockKeychain: KeychainManagerProtocol { func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } + + func deleteAll(service: String) { + serviceStorage.removeValue(forKey: service) + } } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 0cdbe7a0..ad01a1c4 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -26,9 +26,6 @@ final class MockTransport: Transport { var myNickname: String = "TestUser" private let peerSnapshotSubject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) - var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { - peerSnapshotSubject.eraseToAnyPublisher() - } // MARK: - Recording Properties (for test assertions) @@ -42,16 +39,22 @@ final class MockTransport: Transport { private(set) var cancelledTransfers: [String] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] + private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = [] private(set) var startServicesCallCount = 0 private(set) var stopServicesCallCount = 0 private(set) var emergencyDisconnectCallCount = 0 private(set) var broadcastAnnounceCallCount = 0 private(set) var triggeredHandshakes: [PeerID] = [] + private(set) var purgedArchivePeers: [PeerID] = [] // MARK: - Configurable Mock State var connectedPeers: Set = [] var reachablePeers: Set = [] + /// Peers with an established secure session. `nil` mirrors the protocol + /// default (prompt delivery), so connected peers stay "secure" for tests + /// that never care about the distinction. + var securePeers: Set? var peerNicknames: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] @@ -89,6 +92,10 @@ final class MockTransport: Transport { reachablePeers.contains(peerID) || connectedPeers.contains(peerID) } + func canDeliverSecurely(to peerID: PeerID) -> Bool { + securePeers?.contains(peerID) ?? canDeliverPromptly(to: peerID) + } + func peerNickname(peerID: PeerID) -> String? { peerNicknames[peerID] } @@ -109,6 +116,10 @@ final class MockTransport: Transport { triggeredHandshakes.append(peerID) } + func purgeArchivedPublicMessages(from peerID: PeerID) { + purgedArchivePeers.append(peerID) + } + // Noise identity wrappers backed by a mock-keychain encryption service // (mirrors the previous `getNoiseService()` placeholder behavior: a real // identity, but no peer sessions). Exposed so tests can assert against @@ -189,6 +200,33 @@ final class MockTransport: Transport { sentVerifyResponses.append((peerID, noiseKeyHex, nonceA)) } + var courierSendResult = true + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers)) + return courierSendResult + } + + // MARK: - Mesh Diagnostics + + private(set) var sentMeshPings: [PeerID] = [] + var meshPingResult: MeshPingResult? + var meshPaths: [PeerID: [PeerID]] = [:] + var meshTopologySnapshot: MeshTopologySnapshot? + + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + sentMeshPings.append(peerID) + let result = meshPingResult + Task { @MainActor in completion(result) } + } + + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { + meshPaths[peerID] + } + + func currentMeshTopology() -> MeshTopologySnapshot? { + meshTopologySnapshot + } + // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions diff --git a/bitchatTests/NearbyNotesCounterTests.swift b/bitchatTests/NearbyNotesCounterTests.swift new file mode 100644 index 00000000..b1036778 --- /dev/null +++ b/bitchatTests/NearbyNotesCounterTests.swift @@ -0,0 +1,466 @@ +import Combine +import CoreLocation +import XCTest +@testable import bitchat + +/// Tap-to-reveal privacy contract: the nearby-notes counter must not open a +/// building-precision relay REQ until one explicit act calls `reveal()`, and +/// the pooled subscription must come up exactly once and go down exactly once. +@MainActor +final class NearbyNotesCounterTests: XCTestCase { + private var previousNotesEnabled: Any? + + override func setUp() { + super.setUp() + previousNotesEnabled = UserDefaults.standard.object(forKey: "locationNotes.enabled") + UserDefaults.standard.set(true, forKey: "locationNotes.enabled") + } + + override func tearDown() { + if let previous = previousNotesEnabled as? Bool { + UserDefaults.standard.set(previous, forKey: "locationNotes.enabled") + } else { + UserDefaults.standard.removeObject(forKey: "locationNotes.enabled") + } + super.tearDown() + } + + func test_counterOnlySubscribesAfterReveal_countsUnexpiredNotes_andUnsubscribesOnDeactivate() async throws { + let relays = SubscriptionRecorder() + let locationManager = try await makeAuthorizedLocationManager() + let buildingGeohash = try XCTUnwrap( + locationManager.availableChannels.first(where: { $0.level == .building })?.geohash + ) + + let counter = NearbyNotesCounter( + locationManager: locationManager, + managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, + releaseManager: { $0?.cancel() } + ) + + counter.activate() + // Let the availableChannels replay and any queued retargets land: + // being active is not consent, so still no REQ. + try await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(relays.subscribeCount, 0) + XCTAssertFalse(counter.revealed) + + counter.reveal() + + XCTAssertTrue(counter.revealed) + XCTAssertEqual(relays.subscribeCount, 1) + let gTags = try XCTUnwrap(geohashTagFilter(of: XCTUnwrap(relays.lastFilter))) + XCTAssertEqual(gTags.count, 9) + XCTAssertEqual( + Set(gTags), + Set([buildingGeohash] + Geohash.neighbors(of: buildingGeohash)), + "REQ must cover the building geohash plus its 8 neighbors" + ) + + // An expired NIP-40 note must never count. + let identity = try NostrIdentity.generate() + let now = Date() + let expired = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: now.addingTimeInterval(-3600), + kind: .textNote, + tags: [["g", buildingGeohash], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]], + content: "gone" + ) + relays.lastHandler?(try expired.sign(with: identity.schnorrSigningKey())) + try await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(counter.noteCount, 0) + + // A live matching kind-1 note drives the count to 1. + let live = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: now, + kind: .textNote, + tags: [["g", buildingGeohash]], + content: "still here" + ) + relays.lastHandler?(try live.sign(with: identity.schnorrSigningKey())) + let counted = await waitUntil { counter.noteCount == 1 } + XCTAssertTrue(counted) + // Still exactly one REQ after all the async retarget re-entries. + XCTAssertEqual(relays.subscribeCount, 1) + + counter.deactivate() + + XCTAssertEqual(relays.unsubscribeCount, 1) + XCTAssertEqual(counter.noteCount, 0) + } + + func test_permissionRevocation_releasesBuildingSubscriptionDespiteCachedChannels() async throws { + let relays = SubscriptionRecorder() + let locationManager = try await makeAuthorizedLocationManager() + let counter = NearbyNotesCounter( + locationManager: locationManager, + managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, + releaseManager: { $0?.cancel() } + ) + + counter.activate() + counter.reveal() + XCTAssertEqual(relays.subscribeCount, 1) + + locationManager.locationManager(CLLocationManager(), didChangeAuthorization: .denied) + + let denied = await waitUntil { locationManager.permissionState == .denied } + XCTAssertTrue(denied) + let released = await waitUntil { relays.unsubscribeCount == 1 } + XCTAssertTrue(released) + XCTAssertTrue( + locationManager.availableChannels.contains { $0.level == .building }, + "the privacy boundary must not depend on cached channels being cleared" + ) + XCTAssertEqual(counter.noteCount, 0) + + counter.deactivate() + XCTAssertEqual(relays.unsubscribeCount, 1, "revocation already released the manager") + } + + func test_locationNotesKillSwitch_releasesAndCanReacquireBuildingSubscription() async throws { + let relays = SubscriptionRecorder() + let locationManager = try await makeAuthorizedLocationManager() + let counter = NearbyNotesCounter( + locationManager: locationManager, + managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, + releaseManager: { $0?.cancel() } + ) + + counter.activate() + counter.reveal() + XCTAssertEqual(relays.subscribeCount, 1) + + LocationNotesSettings.enabled = false + + let released = await waitUntil { relays.unsubscribeCount == 1 } + XCTAssertTrue(released) + XCTAssertEqual(counter.noteCount, 0) + + LocationNotesSettings.enabled = true + + let reacquired = await waitUntil { relays.subscribeCount == 2 } + XCTAssertTrue(reacquired) + counter.deactivate() + XCTAssertEqual(relays.unsubscribeCount, 2) + } + + func test_checkNotesHint_requiresAuthorizedLocationPermission() { + let relays = SubscriptionRecorder() + let counter = NearbyNotesCounter( + locationManager: makeBareLocationManager(), + managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, + releaseManager: { $0?.cancel() } + ) + + // An unauthorized install must never see the hint: the tap can't + // subscribe (retarget requires authorization) and must not prompt, + // so offering it would be a silent dead-end for the session. + XCTAssertFalse(counter.offersRevealHint(permissionState: .notDetermined)) + XCTAssertFalse(counter.offersRevealHint(permissionState: .denied)) + XCTAssertFalse(counter.offersRevealHint(permissionState: .restricted)) + XCTAssertTrue(counter.offersRevealHint(permissionState: .authorized)) + + // The app-info kill switch hides it too. + LocationNotesSettings.enabled = false + XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized)) + LocationNotesSettings.enabled = true + + // Once revealed, the hint yields to the live strip and count. + counter.reveal() + XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized)) + XCTAssertEqual(relays.subscribeCount, 0, "reveal without authorization must not open a REQ") + } + + func test_noticesSheet_revealsOnlyOnExplicitGeoTabSelectionWithScope() { + // The sheet reveals the local counter only when the person actively + // picks the geo tab and the sheet actually has a geo scope — + // auto-landing on geo (initial tab) never calls this path at all. + XCTAssertTrue(NoticesView.revealsNearbyNotes(onSwitchingTo: .geo, geoGeohash: "u4pruydq")) + XCTAssertFalse(NoticesView.revealsNearbyNotes(onSwitchingTo: .geo, geoGeohash: nil)) + XCTAssertFalse(NoticesView.revealsNearbyNotes(onSwitchingTo: .mesh, geoGeohash: "u4pruydq")) + } + + func test_noticesGeoPresentation_disabledOverridesSelectedScopeAndHidesOnlyGeoComposer() { + XCTAssertEqual( + NoticesView.geoPresentationState(notesEnabled: false, geohash: "9q8yyk8y"), + .disabled, + "a selected remote channel must not leave a blank manager-less notes view" + ) + XCTAssertNil( + NoticesView.composerGeohash( + tab: .geo, + notesEnabled: false, + geoGeohash: "9q8yyk8y" + ), + "the dead geo composer must be hidden while the notes kill switch is off" + ) + XCTAssertEqual( + NoticesView.composerGeohash(tab: .mesh, notesEnabled: false, geoGeohash: nil), + "", + "the location-notes setting must not disable mesh notices" + ) + } + + func test_noticesGeoSession_revocationEndsLiveRefreshAndReleasesDeviceScope() { + let relays = SubscriptionRecorder() + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: relays.dependencies) + var beginCount = 0 + var endCount = 0 + var releaseCount = 0 + + let next = NoticesView.reconcileGeoSession( + tab: .geo, + needsDeviceLocation: true, + permissionState: .denied, + notesEnabled: true, + geohash: "u4pruydq", + manager: manager, + ownsLiveRefresh: true, + beginLiveRefresh: { beginCount += 1 }, + endLiveRefresh: { endCount += 1 }, + acquire: { _ in XCTFail("revocation must not acquire"); return manager }, + release: { + releaseCount += 1 + $0?.cancel() + } + ) + + XCTAssertNil(next.manager) + XCTAssertFalse(next.ownsLiveRefresh) + XCTAssertEqual(beginCount, 0) + XCTAssertEqual(endCount, 1) + XCTAssertEqual(releaseCount, 1) + XCTAssertEqual(relays.unsubscribeCount, 1) + } + + func test_noticesGeoSession_killSwitchClosesLocalScope_butDeniedRemoteScopeRemainsUsable() { + let relays = SubscriptionRecorder() + let localManager = LocationNotesManager(geohash: "u4pruydq", dependencies: relays.dependencies) + var endCount = 0 + var releaseCount = 0 + + let disabled = NoticesView.reconcileGeoSession( + tab: .geo, + needsDeviceLocation: true, + permissionState: .authorized, + notesEnabled: false, + geohash: "u4pruydq", + manager: localManager, + ownsLiveRefresh: true, + beginLiveRefresh: {}, + endLiveRefresh: { endCount += 1 }, + acquire: { _ in XCTFail("disabled notes must not acquire"); return localManager }, + release: { + releaseCount += 1 + $0?.cancel() + } + ) + + XCTAssertNil(disabled.manager) + XCTAssertFalse(disabled.ownsLiveRefresh) + XCTAssertEqual(endCount, 1) + XCTAssertEqual(releaseCount, 1) + + let remoteManager = LocationNotesManager(geohash: "9q8yyk8y", dependencies: relays.dependencies) + let remote = NoticesView.reconcileGeoSession( + tab: .geo, + needsDeviceLocation: false, + permissionState: .denied, + notesEnabled: true, + geohash: "9q8yyk8y", + manager: remoteManager, + ownsLiveRefresh: false, + beginLiveRefresh: {}, + endLiveRefresh: { endCount += 1 }, + acquire: { _ in XCTFail("matching remote manager should be reused"); return remoteManager }, + release: { _ in XCTFail("device permission must not release an explicit remote scope") } + ) + + XCTAssertTrue(remote.manager === remoteManager) + XCTAssertFalse(remote.ownsLiveRefresh) + XCTAssertEqual(endCount, 1) + } + + func test_pool_sharesOneManagerPerGeohash_andCancelsOnLastRelease() { + let relays = SubscriptionRecorder() + let pool = LocationNotesPool( + makeManager: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) } + ) + + let first = pool.acquire("u4pruydq") + let second = pool.acquire("U4PRUYDQ") + + XCTAssertTrue(first === second, "same geohash (case-insensitive) must share one manager") + XCTAssertEqual(relays.subscribeCount, 1) + + pool.release(first) + + XCTAssertEqual(relays.unsubscribeCount, 0, "first release keeps the shared REQ live") + XCTAssertNotEqual(first.state, .idle) + + pool.release(second) + + XCTAssertEqual(relays.unsubscribeCount, 1) + XCTAssertEqual(first.state, .idle) + + // An instance the pool never owned (test-injected) degrades to cancel. + let stray = LocationNotesManager(geohash: "u4pruydp", dependencies: relays.dependencies) + pool.release(stray) + XCTAssertEqual(relays.unsubscribeCount, 2) + XCTAssertEqual(stray.state, .idle) + } + + func test_pool_reacquireAfterFullRelease_bringsSubscriptionBackUp() { + let relays = SubscriptionRecorder() + let pool = LocationNotesPool( + makeManager: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) } + ) + + // The notices sheet's geo → mesh → geo cycle: switching to mesh + // releases (REQ goes down), switching back re-acquires (fresh REQ), + // with exactly one live REQ at any point. + let first = pool.acquire("u4pruydq") + XCTAssertEqual(relays.subscribeCount, 1) + + pool.release(first) + XCTAssertEqual(relays.unsubscribeCount, 1) + XCTAssertEqual(first.state, .idle) + + let second = pool.acquire("u4pruydq") + XCTAssertEqual(relays.subscribeCount, 2) + XCTAssertNotEqual(second.state, .idle) + + // Dismissal after the round trip releases once more — no double + // unsubscribe from the earlier tab-switch release. + pool.release(second) + XCTAssertEqual(relays.unsubscribeCount, 2) + } + + // MARK: - Helpers + + /// A LocationStateManager that never touches CoreLocation; for tests + /// that don't need channels or authorization. + private func makeBareLocationManager() -> LocationStateManager { + let suiteName = "NearbyNotesCounterTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + addTeardownBlock { + storage.removePersistentDomain(forName: suiteName) + } + return LocationStateManager( + storage: storage, + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: false + ) + } + + /// An authorized LocationStateManager whose availableChannels carry a + /// real building-level geohash (Honolulu), built over CoreLocation mocks. + private func makeAuthorizedLocationManager() async throws -> LocationStateManager { + let suiteName = "NearbyNotesCounterTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + addTeardownBlock { + storage.removePersistentDomain(forName: suiteName) + } + + let manager = LocationStateManager( + storage: storage, + locationManager: MockLocationManager(authorizationStatus: .authorizedAlways), + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + let authorized = await waitUntil { manager.permissionState == .authorized } + XCTAssertTrue(authorized) + + manager.locationManager( + CLLocationManager(), + didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] + ) + let channelsLoaded = await waitUntil { + manager.availableChannels.contains { $0.level == .building } + } + XCTAssertTrue(channelsLoaded) + return manager + } + + /// The filter's `g` tag values, read through its NIP-01 wire encoding + /// (the stored tag filters aren't visible outside the Nostr layer). + private func geohashTagFilter(of filter: NostrFilter) throws -> [String]? { + let data = try JSONEncoder().encode(filter) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return json["#g"] as? [String] + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +/// Stub relay layer: counts REQs, captures the last filter/handler, and never +/// touches the network. +@MainActor +private final class SubscriptionRecorder { + private(set) var subscribeCount = 0 + private(set) var unsubscribeCount = 0 + private(set) var lastFilter: NostrFilter? + private(set) var lastHandler: ((NostrEvent) -> Void)? + + var dependencies: LocationNotesDependencies { + LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { [weak self] filter, _, _, handler, _ in + self?.subscribeCount += 1 + self?.lastFilter = filter + self?.lastHandler = handler + }, + unsubscribe: { [weak self] _ in + self?.unsubscribeCount += 1 + }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in try NostrIdentity.generate() }, + now: { Date() } + ) + } +} + +private final class MockLocationManager: LocationStateManaging { + weak var delegate: CLLocationManagerDelegate? + var desiredAccuracy: CLLocationAccuracy = 0 + var distanceFilter: CLLocationDistance = 0 + var authorizationStatus: CLAuthorizationStatus + + init(authorizationStatus: CLAuthorizationStatus) { + self.authorizationStatus = authorizationStatus + } + + func requestWhenInUseAuthorization() {} + func requestLocation() {} + func startUpdatingLocation() {} + func stopUpdatingLocation() {} +} + +private final class MockLocationGeocoder: LocationStateGeocoding { + func cancelGeocode() {} + + func reverseGeocodeLocation( + _ location: CLLocation, + completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void + ) { + completionHandler(nil, nil) + } +} diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 5a178f73..ab3b8641 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -172,7 +172,7 @@ struct NoiseCoverageTests { Data(), Data(repeating: 0x00, count: 32), Data([0x01] + Array(repeating: 0x00, count: 31)), - Data(repeating: 0xFF, count: 32), + Data(repeating: 0xFF, count: 32) ] for invalidKey in invalidKeys { diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index 9c5f2dc1..f7e8e6be 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -31,11 +31,8 @@ struct NoiseTestVector: Codable { let init_prologue: String let init_static: String let init_ephemeral: String - let init_psks: [String]? - let resp_prologue: String let resp_static: String let resp_ephemeral: String - let resp_psks: [String]? let handshake_hash: String? let messages: [TestMessage] @@ -151,7 +148,7 @@ struct NoiseProtocolTests { @Test func basicEncryptionDecryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) - let plaintext = "Hello, Bob!".data(using: .utf8)! + let plaintext = Data("Hello, Bob!".utf8) // Alice encrypts let ciphertext = try aliceSession.encrypt(plaintext) @@ -167,13 +164,13 @@ struct NoiseProtocolTests { try performHandshake(initiator: aliceSession, responder: bobSession) // Alice -> Bob - let aliceMessage = "Hello from Alice".data(using: .utf8)! + let aliceMessage = Data("Hello from Alice".utf8) let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let bobReceived = try bobSession.decrypt(aliceCiphertext) #expect(bobReceived == aliceMessage) // Bob -> Alice - let bobMessage = "Hello from Bob".data(using: .utf8)! + let bobMessage = Data("Hello from Bob".utf8) let bobCiphertext = try bobSession.encrypt(bobMessage) let aliceReceived = try aliceSession.decrypt(bobCiphertext) #expect(aliceReceived == bobMessage) @@ -193,7 +190,7 @@ struct NoiseProtocolTests { } @Test func encryptionBeforeHandshake() { - let plaintext = "test".data(using: .utf8)! + let plaintext = Data("test".utf8) #expect(throws: NoiseSessionError.notEstablished) { try aliceSession.encrypt(plaintext) @@ -270,7 +267,7 @@ struct NoiseProtocolTests { try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Encrypt with manager - let plaintext = "Test message".data(using: .utf8)! + let plaintext = Data("Test message".utf8) let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID) // Decrypt with manager @@ -283,7 +280,7 @@ struct NoiseProtocolTests { @Test func tamperedCiphertextDetection() throws { try performHandshake(initiator: aliceSession, responder: bobSession) - let plaintext = "Secret message".data(using: .utf8)! + let plaintext = Data("Secret message".utf8) var ciphertext = try aliceSession.encrypt(plaintext) // Tamper with ciphertext @@ -304,7 +301,7 @@ struct NoiseProtocolTests { @Test func replayPrevention() throws { try performHandshake(initiator: aliceSession, responder: bobSession) - let plaintext = "Test message".data(using: .utf8)! + let plaintext = Data("Test message".utf8) let ciphertext = try aliceSession.encrypt(plaintext) // First decryption should succeed @@ -337,7 +334,7 @@ struct NoiseProtocolTests { try performHandshake(initiator: aliceSession2, responder: bobSession2) // Encrypt with session 1 - let plaintext = "Secret".data(using: .utf8)! + let plaintext = Data("Secret".utf8) let ciphertext1 = try aliceSession1.encrypt(plaintext) // Should not be able to decrypt with session 2 @@ -366,10 +363,10 @@ struct NoiseProtocolTests { try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Exchange some messages to establish nonce state - let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID) + let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID) _ = try bobManager.decrypt(message1, from: bobPeerID) - let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID) + let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID) _ = try aliceManager.decrypt(message2, from: alicePeerID) // Simulate Bob restart by creating new manager with same key @@ -391,7 +388,7 @@ struct NoiseProtocolTests { _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) // Should be able to exchange messages with new sessions - let testMessage = "After restart".data(using: .utf8)! + let testMessage = Data("After restart".utf8) let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) #expect(decrypted == testMessage) @@ -409,17 +406,17 @@ struct NoiseProtocolTests { // Exchange messages to advance nonces for i in 0..<5 { - let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!) + let msg = try aliceSession.encrypt(Data("Message \(i)".utf8)) _ = try bobSession.decrypt(msg) } // Simulate desynchronization by encrypting but not decrypting for i in 0..<3 { - _ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!) + _ = try aliceSession.encrypt(Data("Lost message \(i)".utf8)) } // With per-packet nonce carried, decryption should not throw here - let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!) + let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8)) #expect(throws: Never.self) { try bobSession.decrypt(desyncMessage) } @@ -434,12 +431,11 @@ struct NoiseProtocolTests { let messageCount = 100 - try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) - { completion in + try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in var encryptedMessages: [Int: Data] = [:] // Encrypt messages sequentially to avoid nonce races in manager for i in 0.. 0) // Test encryption from Alice to Bob - let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)! + let plaintext1 = Data("Hello from Alice after secureClear!".utf8) let ciphertext1 = try alice.encrypt(plaintext1) let decrypted1 = try bob.decrypt(ciphertext1) #expect(decrypted1 == plaintext1) // Test encryption from Bob to Alice - let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)! + let plaintext2 = Data("Hello from Bob after secureClear!".utf8) let ciphertext2 = try bob.encrypt(plaintext2) let decrypted2 = try alice.decrypt(ciphertext2) #expect(decrypted2 == plaintext2) // Test multiple messages to verify cipher state is correct for i in 1...10 { - let msg = "Message \(i) from Alice".data(using: .utf8)! + let msg = Data("Message \(i) from Alice".utf8) let cipher = try alice.encrypt(msg) let dec = try bob.decrypt(cipher) #expect(dec == msg) diff --git a/bitchatTests/NoiseCourierTests.swift b/bitchatTests/NoiseCourierTests.swift new file mode 100644 index 00000000..1403e520 --- /dev/null +++ b/bitchatTests/NoiseCourierTests.swift @@ -0,0 +1,107 @@ +// +// NoiseCourierTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +/// One-way Noise X envelopes: encryption to a known static key without an +/// interactive handshake, used by the courier store-and-forward path. +struct NoiseCourierTests { + + @Test func sealAndOpenRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + let payload = Data("meet at the north gate".utf8) + let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + + let opened = try bob.openCourierPayload(sealed) + #expect(opened.payload == payload) + // The X pattern authenticates the sender: Bob learns Alice's real static key. + #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) + } + + @Test func wrongRecipientCannotOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let carol = NoiseEncryptionService(keychain: MockKeychain()) + + let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + + #expect(throws: (any Error).self) { + _ = try carol.openCourierPayload(sealed) + } + } + + @Test func tamperedEnvelopeFailsToOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + sealed[sealed.count - 1] ^= 0x01 + + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(sealed) + } + } + + @Test func senderIdentityCannotBeForged() throws { + // The encrypted static key inside the envelope is bound by the ss DH; + // splicing one envelope's ephemeral prefix onto another must fail. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + let bobKey = bob.getStaticPublicKeyData() + let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) + let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) + + // e (32 bytes) from Mallory's envelope + rest from Alice's. + let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32) + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(Data(spliced)) + } + } + + @Test func sealRejectsInvalidRecipientKey() { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + #expect(throws: (any Error).self) { + _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32)) + } + #expect(throws: (any Error).self) { + _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8)) + } + } + + @Test func emptyAndLargePayloadsRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = bob.getStaticPublicKeyData() + + let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey) + #expect(try bob.openCourierPayload(empty).payload.isEmpty) + + let large = Data((0..<8192).map { UInt8($0 % 251) }) + let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey) + #expect(try bob.openCourierPayload(sealed).payload == large) + } + + @Test func envelopesAreNotLinkableAcrossSends() throws { + // Fresh ephemeral per seal: same payload to the same recipient must + // produce entirely different ciphertexts. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let payload = Data("same message".utf8) + + let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + #expect(a != b) + #expect(a.prefix(32) != b.prefix(32)) + } +} diff --git a/bitchatTests/NoiseEncryptionTests.swift b/bitchatTests/NoiseEncryptionTests.swift index 66996dfc..a1cb5780 100644 --- a/bitchatTests/NoiseEncryptionTests.swift +++ b/bitchatTests/NoiseEncryptionTests.swift @@ -70,7 +70,7 @@ struct NoiseEncryptionTests { } } -// TODO: Reuse +// Local error type for the keychain failure cases in this suite. private struct KeychainTestError: Error, CustomStringConvertible { let message: String init(_ message: String) { self.message = message } diff --git a/bitchatTests/Nostr/NostrPoWTests.swift b/bitchatTests/Nostr/NostrPoWTests.swift new file mode 100644 index 00000000..1905e0f0 --- /dev/null +++ b/bitchatTests/Nostr/NostrPoWTests.swift @@ -0,0 +1,222 @@ +// +// NostrPoWTests.swift +// bitchatTests +// +// Tests for NIP-13 proof-of-work: leading-zero-bit counting, commitment +// semantics, and nonce-tag mining for geohash (kind 20000) events. +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct NostrPoWTests { + + // MARK: - Leading zero bits + + @Test func leadingZeroBitsVectors() { + #expect(NostrPoW.leadingZeroBits(Data()) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0x80])) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0xFF, 0x00])) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0x40])) == 1) + #expect(NostrPoW.leadingZeroBits(Data([0x01])) == 7) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0xF0])) == 16) + #expect(NostrPoW.leadingZeroBits(Data(repeating: 0x00, count: 32)) == 256) + } + + @Test func leadingZeroBitsExactByteBoundaries() { + // Zero byte contributes exactly 8, then the next byte decides. + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0xFF])) == 8) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x80])) == 8) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x7F])) == 9) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x01])) == 15) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0x01])) == 23) + } + + @Test func leadingZeroBitsMatchesNIP13ExampleVector() throws { + // Worked example from the NIP-13 spec: this event ID has 36 leading + // zero bits. + let idHex = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d" + let idData = try #require(Data(hexString: idHex)) + #expect(NostrPoW.leadingZeroBits(idData) == 36) + } + + // MARK: - Commitment semantics + + /// An ID with exactly 16 leading zero bits. + private let id16 = "0000f000" + String(repeating: "ab", count: 28) + + @Test func committedTargetCountsNotActualDifficulty() { + // Claimed < actual: only the committed target is credited, so lucky + // extra zeroes earn nothing beyond the commitment. + let tags = [["g", "u4pruy"], ["nonce", "12345", "8"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 8) + } + + @Test func unmetCommitmentScoresZero() { + // Actual < claimed: the commitment is not met, so the claim is void. + let tags = [["nonce", "12345", "24"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 0) + } + + @Test func exactCommitmentIsCredited() { + let tags = [["nonce", "12345", "16"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 16) + } + + @Test func missingOrMalformedNonceTagScoresZero() { + // No nonce tag at all: leading zeroes without a commitment earn no + // credit (old clients simply keep the strict rate limits). + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["g", "u4pruy"]]) == 0) + // Nonce tag without a committed target. + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "12345"]]) == 0) + // Non-numeric or nonsensical targets. + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "high"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "0"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "-4"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "400"]]) == 0) + // Malformed event ID. + #expect(NostrPoW.validatedDifficulty(idHex: "not-hex", tags: [["nonce", "1", "8"]]) == 0) + } + + // MARK: - Mining + + @Test func minedNonceTagMeetsCommittedDifficulty() async throws { + let pubkey = String(repeating: "a", count: 64) + let createdAt = 1_700_000_000 + let baseTags = [["g", "u4pruydq"], ["n", "tester"]] + let content = "hello pow" + + let nonceTag = try #require(await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 8 + )) + + #expect(nonceTag.count == 3) + #expect(nonceTag.first == "nonce") + #expect(nonceTag[2] == "8") + + // Recompute the canonical NIP-01 event ID with the mined tag appended + // and verify the committed difficulty is genuinely met. + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= 8) + let idHex = idData.map { String(format: "%02x", $0) }.joined() + #expect(NostrPoW.validatedDifficulty(idHex: idHex, tags: baseTags + [nonceTag]) == 8) + } + + @Test func miningSurvivesContentThatNeedsEscaping() async throws { + // The in-place template mutation must stay correct when the content + // gets JSON-escaped — including content that contains hex runs that + // look exactly like the internal nonce placeholder. + let pubkey = String(repeating: "b", count: 64) + let createdAt = 1_700_000_123 + let content = "she said \"hi\"\n0000000000000000 / ffffffffffffffff 😀\\" + let baseTags = [["g", "9q8yy"]] + + let nonceTag = try #require(await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 4 + )) + + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= 4) + } + + @Test func minedGeohashEventValidatesEndToEnd() async throws { + let identity = try NostrIdentity.generate() + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( + content: "hello from a mined event", + geohash: "u4pruydq", + senderIdentity: identity, + nickname: "miner", + teleported: false + ) + + // The signed event's own ID (recomputed by sign()) carries the work. + #expect(event.isValidSignature()) + let idData = try #require(Data(hexString: event.id)) + #expect(NostrPoW.leadingZeroBits(idData) >= NostrPoW.targetBits) + #expect(NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) == NostrPoW.targetBits) + + // Mining must not disturb the regular geohash tags. + #expect(event.tags.contains(["g", "u4pruydq"])) + #expect(event.tags.contains(["n", "miner"])) + #expect(event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue) + } + + @Test func cancelledMiningStillProducesHonestCommitment() async throws { + // Cancelling the surrounding task expedites mining: it steps the + // committed target down and still returns a tag whose commitment the + // hash actually meets — the message is never dropped or dishonest. + let pubkey = String(repeating: "c", count: 64) + let createdAt = 1_700_000_456 + let baseTags = [["g", "gbsuv"]] + let content = "expedited" + + let miningTask = Task { + await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 240 // unreachable: forces the cap/cancel path + ) + } + miningTask.cancel() + + let nonceTag = try #require(await miningTask.value) + let committed = try #require(Int(nonceTag[2])) + #expect(committed >= 0) + #expect(committed < 240) + + if committed > 0 { + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= committed) + } + } + + // MARK: - Helpers + + /// Canonical NIP-01 event ID hash, computed independently of the + /// production code path. + private static func eventIDHash( + pubkey: String, + createdAt: Int, + kind: Int, + tags: [[String]], + content: String + ) throws -> Data { + let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] + let json = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + return Data(SHA256.hash(data: json)) + } +} diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index d237f5fc..1f9d21ab 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -156,6 +156,7 @@ struct NostrProtocolTests { } } + @Test func testAckRoundTripNIP44V2_Delivered() throws { // Identities let sender = try NostrIdentity.generate() diff --git a/bitchatTests/NotificationStreamAssemblerTests.swift b/bitchatTests/NotificationStreamAssemblerTests.swift index de76bda0..81bf8502 100644 --- a/bitchatTests/NotificationStreamAssemblerTests.swift +++ b/bitchatTests/NotificationStreamAssemblerTests.swift @@ -118,6 +118,7 @@ struct NotificationStreamAssemblerTests { #expect(decoded.timestamp == packet2.timestamp) } + @Test func testAssemblesCompressedLargeFrame() throws { var assembler = NotificationStreamAssembler() diff --git a/bitchatTests/PTTAudioTests.swift b/bitchatTests/PTTAudioTests.swift new file mode 100644 index 00000000..d523cb30 --- /dev/null +++ b/bitchatTests/PTTAudioTests.swift @@ -0,0 +1,100 @@ +// +// PTTAudioTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import AVFoundation +import Foundation +@testable import bitchat + +struct PTTAudioTests { + // MARK: - ADTS framing + + @Test func adtsHeaderEncodesFrameLengthAndFormat() { + let payload = Data(repeating: 0xAB, count: 100) + let framed = ADTSFramer.frame(payload) + #expect(framed.count == 107) + + // Syncword + MPEG-4 + layer 00 + no CRC. + #expect(framed[0] == 0xFF) + #expect(framed[1] == 0xF1) + // AAC-LC (01), sampling index 8 (16 kHz), channel config 1. + #expect(framed[2] == 0x60) + #expect(framed[3] == 0x40 | UInt8((107 >> 11) & 0x3)) + #expect(framed[4] == UInt8((107 >> 3) & 0xFF)) + #expect(framed[5] == UInt8((107 & 0x7) << 5) | 0x1F) + #expect(framed[6] == 0xFC) + #expect(Data(framed.dropFirst(7)) == payload) + } + + @Test func adtsStreamIsReadableByCoreAudio() throws { + // A receiver persists bursts as ADTS .aac; the file must be openable + // by the same machinery the voice-note UI uses (AVAudioFile). + let frames = try encodeSineFrames(seconds: 0.5) + #expect(frames.count >= 4) + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ptt-test-\(UUID().uuidString).aac") + defer { try? FileManager.default.removeItem(at: url) } + var stream = Data() + for frame in frames { + stream.append(ADTSFramer.frame(frame)) + } + try stream.write(to: url) + + let file = try AVAudioFile(forReading: url) + #expect(file.length > 0) + } + + // MARK: - Codec round trip + + @Test func encoderProducesRealtimeSizedFrames() throws { + let frames = try encodeSineFrames(seconds: 1.0) + // 1 s of 64 ms frames ≈ 15 (allow encoder priming slack). + #expect(frames.count >= 10) + // ~16 kbps -> ~130 bytes/frame; all frames must fit the wire budget. + for frame in frames { + #expect(frame.count > 0) + #expect(frame.count < TransportConfig.pttMaxBurstContentBytes) + } + } + + @Test func decoderRoundTripsEncodedAudio() throws { + let frames = try encodeSineFrames(seconds: 0.5) + let decoder = try #require(PTTFrameDecoder()) + + var decodedSamples = 0 + var energy: Float = 0 + for frame in frames { + guard let pcm = decoder.decode(frame) else { continue } // priming + decodedSamples += Int(pcm.frameLength) + if let channel = pcm.floatChannelData?[0] { + for i in 0..= 4 * Int(PTTAudioFormat.samplesPerFrame)) + #expect(energy > 1) + } + + // MARK: - Helpers + + private func encodeSineFrames(seconds: Double) throws -> [Data] { + let encoder = try #require(PTTFrameEncoder()) + let format = try #require(PTTAudioFormat.pcmFormat) + let totalFrames = AVAudioFrameCount(seconds * PTTAudioFormat.sampleRate) + let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: totalFrames)) + buffer.frameLength = totalFrames + let channel = try #require(buffer.floatChannelData?[0]) + for i in 0.. +// + +import Testing +import AVFoundation +import Foundation +@testable import bitchat + +/// Thread-safe: the coordinator invokes it on its private serial queue. +private final class StubAudioSession: SessionApplying, @unchecked Sendable { + private let lock = NSLock() + private var _setCategoryError: Error? + + var setCategoryError: Error? { + get { lock.withLock { _setCategoryError } } + set { lock.withLock { _setCategoryError = newValue } } + } + + func setCategory(_ category: AudioSessionCoordinator.Category) throws { + try lock.withLock { + if let error = _setCategoryError { + _setCategoryError = nil + throw error + } + } + } + + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {} +} + +private struct StubSessionError: Error {} + +private final class StubExclusivePlayback: ExclusivePlayback { + private(set) var pauseCount = 0 + + func pauseForExclusivity() { + pauseCount += 1 + } +} + +/// Blocks activation until released, so a test can land events inside the +/// window where the (off-main) session acquire is still in flight. +private final class GatedAudioSession: SessionApplying, @unchecked Sendable { + private let gate = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var _categoryCallCount = 0 + private var _activationCalls: [Bool] = [] + + /// Non-zero once the acquire has reached the session queue (setCategory + /// runs just before the gated setActive). + var categoryCallCount: Int { lock.withLock { _categoryCallCount } } + var activationCalls: [Bool] { lock.withLock { _activationCalls } } + + func open() { gate.signal() } + + func setCategory(_ category: AudioSessionCoordinator.Category) throws { + lock.withLock { _categoryCallCount += 1 } + } + + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { + if active { gate.wait() } + lock.withLock { _activationCalls.append(active) } + } +} + +@MainActor +private final class MockPlaybackEngine: PTTPlaybackEngine { + private(set) var startCount = 0 + private(set) var stopCount = 0 + private(set) var scheduledBuffers: [AVAudioPCMBuffer] = [] + private struct HeldCompletion { + let type: PTTPlaybackCompletionType + let callback: @Sendable (PTTPlaybackCompletionEvent) -> Void + } + private var heldCompletions: [HeldCompletion] = [] + private(set) var requestedCompletionTypes: [PTTPlaybackCompletionType] = [] + var startError: Error? + + // No object -> the player registers no configuration-change observer. + var configChangeObject: AnyObject? { nil } + + func start() throws { + if let error = startError { throw error } + startCount += 1 + } + + func play() {} + + func stop() { + stopCount += 1 + } + + func schedule( + _ buffer: AVAudioPCMBuffer, + completionType: PTTPlaybackCompletionType, + completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void + ) { + // Completions are held, not fired automatically: most tests exercise + // lifecycle, not drain-out. The mock models the important distinction + // between the node consuming bytes and audio actually playing out. + scheduledBuffers.append(buffer) + requestedCompletionTypes.append(completionType) + heldCompletions.append(HeldCompletion(type: completionType, callback: completionHandler)) + } + + /// Advances the node only to the point where it has consumed each + /// buffer. A `.dataPlayedBack` request must remain pending here. + func fireDataConsumedCallbacks() { + for completion in heldCompletions { + completion.callback(.dataConsumed) + } + } + + /// Advances all scheduled audio through audible playback. + func fireDataPlayedBackCallbacks() { + let completions = heldCompletions + heldCompletions = [] + for completion in completions { + completion.callback(.dataPlayedBack) + } + } + + /// Models AVAudioPlayerNode flushing its callbacks because an external + /// engine reconfiguration stopped the node before MainActor rebuilt it. + func firePlaybackStoppedCallbacks() { + let completions = heldCompletions + heldCompletions = [] + for completion in completions { + completion.callback(.playbackStopped) + } + } + + /// Plays only the oldest scheduled buffer, leaving the rest as an + /// audible tail that an engine rebuild must preserve. + func fireNextDataPlayedBackCallback() { + guard let index = heldCompletions.firstIndex(where: { $0.type == .dataPlayedBack }) else { return } + let completion = heldCompletions.remove(at: index) + completion.callback(.dataPlayedBack) + } +} + +@MainActor +struct PTTBurstPlayerTests { + private func makePlayer( + coordinator: AudioSessionCoordinator + ) throws -> (player: PTTBurstPlayer, engines: () -> [MockPlaybackEngine]) { + final class EngineBox { var engines: [MockPlaybackEngine] = [] } + let box = EngineBox() + // Fresh exclusivity slot: parallel tests must not steal this player's + // app-wide playback slot mid-test (the async session acquire opens + // suspension windows the old synchronous start never had). + let player = try #require(PTTBurstPlayer( + coordinator: coordinator, + exclusivity: VoiceNotePlaybackCoordinator(), + makeEngine: { + let engine = MockPlaybackEngine() + box.engines.append(engine) + return engine + } + )) + return (player, { box.engines }) + } + + /// Enough encoded audio to cross `TransportConfig.pttJitterBufferSeconds` + /// so playback starts without waiting for the deadline task. + private func encodeSineFrames(seconds: Double = 1.0) throws -> [Data] { + let encoder = try #require(PTTFrameEncoder()) + let format = try #require(PTTAudioFormat.pcmFormat) + let totalFrames = AVAudioFrameCount(seconds * PTTAudioFormat.sampleRate) + let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: totalFrames)) + buffer.frameLength = totalFrames + let channel = try #require(buffer.floatChannelData?[0]) + for i in 0.. Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !condition(), ContinuousClock.now < deadline { + await Task.yield() + try? await Task.sleep(nanoseconds: 1_000_000) + } + #expect(condition(), sourceLocation: sourceLocation) + } + + // MARK: - Talk-over (bidirectional) + + @Test func burstDrainWaitsForAudiblePlaybackNotDataConsumption() async throws { + let coordinator = AudioSessionCoordinator(session: StubAudioSession()) + let (player, engines) = try makePlayer(coordinator: coordinator) + + player.enqueue(try encodeSineFrames()) + await waitUntil { player.isPlaying } + let engine = try #require(engines().first) + #expect(engine.requestedCompletionTypes.allSatisfy { $0 == .dataPlayedBack }) + + player.finishAfterDrain() + engine.fireDataConsumedCallbacks() + await Task.yield() + #expect(!player.stopped) + #expect(player.isPlaying) + + engine.fireDataPlayedBackCallbacks() + await waitUntil { player.stopped } + #expect(!player.isPlaying) + } + + @Test func olderPTTSessionAcquireCannotStealNewerPlaybackReservation() async throws { + let session = GatedAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + let exclusivity = VoiceNotePlaybackCoordinator() + final class EngineBox { var engines: [MockPlaybackEngine] = [] } + let box = EngineBox() + let player = try #require(PTTBurstPlayer( + coordinator: coordinator, + exclusivity: exclusivity, + makeEngine: { + let engine = MockPlaybackEngine() + box.engines.append(engine) + return engine + } + )) + + player.enqueue(try encodeSineFrames()) + await waitUntil { session.categoryCallCount == 1 } + + // A user taps a voice note while the older inbound burst is blocked + // in audio-session activation. Its immediate play intent is newer. + let voiceNote = StubExclusivePlayback() + exclusivity.activate(voiceNote) + session.open() + + await waitUntil { player.stopped } + #expect(box.engines.count == 1) + #expect(box.engines[0].startCount == 0) + #expect(voiceNote.pauseCount == 0) + #expect(!player.isPlaying) + } + + @Test func categoryEscalationRestartsEngineAndKeepsStreaming() async throws { + let coordinator = AudioSessionCoordinator(session: StubAudioSession()) + let (player, engines) = try makePlayer(coordinator: coordinator) + + let frames = try encodeSineFrames() + player.enqueue(frames) + await waitUntil { player.isPlaying } + #expect(engines().count == 1) + #expect(engines()[0].startCount == 1) + #expect(!engines()[0].scheduledBuffers.isEmpty) + + // Push-to-talk pressed while the burst plays: capture escalates the + // session category. The playback engine must restart under the new + // configuration, not die. (Escalation fan-out is delivered before + // acquire returns, so no waiting is needed here.) + let capture = try await coordinator.acquire(.capture) {} + #expect(engines().count == 2) + #expect(engines()[0].stopCount == 1) + #expect(engines()[1].startCount == 1) + #expect(player.isPlaying) + + // Frames arriving after the restart keep playing on the new engine. + player.enqueue(frames) + #expect(!engines()[1].scheduledBuffers.isEmpty) + + coordinator.release(capture) + player.stop() + #expect(!player.isPlaying) + } + + @Test func categoryEscalationReplaysOnlyUnfinishedTailAfterBurstEnd() async throws { + let coordinator = AudioSessionCoordinator(session: StubAudioSession()) + let (player, engines) = try makePlayer(coordinator: coordinator) + + let frames = try encodeSineFrames() + player.enqueue(frames) + await waitUntil { player.isPlaying } + + let originalEngine = engines()[0] + let originallyScheduled = originalEngine.scheduledBuffers.count + try #require(originallyScheduled > 1) + + // One buffer has played, but deliberately do not yield for its + // MainActor completion task. The completion latch itself must keep + // the rebuild from replaying this already-completed prefix. + originalEngine.fireNextDataPlayedBackCallback() + player.finishAfterDrain() + + // A real AVAudioPlayerNode also invokes requested callbacks when its + // engine is stopped by a configuration change. Those callbacks must + // leave the unheard tail pending for the fresh engine. + originalEngine.firePlaybackStoppedCallbacks() + + // Capture joins after END while the remaining tail is still handed + // to the old engine. The fresh engine must replay that tail instead + // of looking empty and stopping immediately. + let capture = try await coordinator.acquire(.capture) {} + try #require(engines().count == 2) + let restartedEngine = engines()[1] + #expect(restartedEngine.scheduledBuffers.count == originallyScheduled - 1) + #expect(player.isPlaying) + #expect(!player.stopped) + + // Only the fresh engine's audible completions may stop this finished + // burst; the stop callbacks above did not drain it. + #expect(player.isPlaying) + #expect(!player.stopped) + + restartedEngine.fireDataPlayedBackCallbacks() + await waitUntil { player.stopped } + #expect(!player.isPlaying) + + coordinator.release(capture) + } + + @Test func realInterruptionStillStopsPlayback() async throws { + let coordinator = AudioSessionCoordinator(session: StubAudioSession()) + let (player, engines) = try makePlayer(coordinator: coordinator) + + let frames = try encodeSineFrames() + player.enqueue(frames) + await waitUntil { player.isPlaying } + + // A system interruption (phone call) is not an escalation: stop. + await coordinator.handleInterruptionBegan() + #expect(!player.isPlaying) + #expect(engines().count == 1) + #expect(engines()[0].stopCount == 1) + + // A stopped burst stays stopped. + let before = engines()[0].scheduledBuffers.count + player.enqueue(frames) + #expect(engines()[0].scheduledBuffers.count == before) + } + + // MARK: - Burst END racing the async session acquire + + /// Models production ownership (`ChatLiveVoiceCoordinator.finalize`): the + /// assembly — the player's sole strong owner — is discarded on burst END, + /// and only the parked draining reference keeps the player alive. The + /// audio must still play out and the session token must come back once + /// the drain finishes. + @Test func burstEndDuringSessionAcquireStillPlaysWithOnlyDrainOwner() async throws { + let session = GatedAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + final class EngineBox { var engines: [MockPlaybackEngine] = [] } + let box = EngineBox() + var owner: PTTBurstPlayer? = PTTBurstPlayer( + coordinator: coordinator, + exclusivity: VoiceNotePlaybackCoordinator(), + makeEngine: { + let engine = MockPlaybackEngine() + box.engines.append(engine) + return engine + } + ) + try #require(owner != nil) + let weakPlayer = { [weak owner] in owner } + + let frames = try encodeSineFrames() + owner?.enqueue(frames) + // END lands while activation is still blocked on the session queue. + // With nothing scheduled yet, the drain check must not mistake the + // not-yet-started burst for a played-out one and drop all its audio. + var draining: PTTBurstPlayer? = owner + owner?.onStopped = { draining = nil } + owner?.finishAfterDrain() + #expect(owner?.stopped == false) + owner = nil + + session.open() + await waitUntil { weakPlayer()?.isPlaying == true } + #expect(box.engines.count == 1) + #expect(box.engines[0].startCount == 1) + #expect(!box.engines[0].scheduledBuffers.isEmpty) + + // Play the tail out: the drain must stop the player, hand the token + // back (deactivation reaches the mock), and unpark the drain owner. + box.engines[0].fireDataPlayedBackCallbacks() + await waitUntil { session.activationCalls == [true, false] } + #expect(draining == nil) + #expect(weakPlayer() == nil) + } + + /// Backstop: if every owner drops the player before it stopped, `deinit` + /// must hand the registered session token back — the coordinator retains + /// tokens strongly, so a leaked one would keep the session active (and + /// pin any escalated category) for the app's lifetime. + @Test func ownerlessPlayerDeinitReleasesSessionToken() async throws { + let session = GatedAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + var player: PTTBurstPlayer? = PTTBurstPlayer( + coordinator: coordinator, + exclusivity: VoiceNotePlaybackCoordinator(), + makeEngine: { MockPlaybackEngine() } + ) + try #require(player != nil) + + let frames = try encodeSineFrames() + player?.enqueue(frames) + // Make sure the acquire is in flight (holding the player alive + // through its call frame) before the last external reference drops. + await waitUntil { session.categoryCallCount == 1 } + player?.finishAfterDrain() + player = nil + + session.open() + // The acquire task keeps the player alive just long enough to start; + // when it deallocates, deinit must release the freshly stored token. + await waitUntil { session.activationCalls == [true, false] } + } + + // MARK: - Session acquire failure + + @Test func sessionAcquireFailureDoesNotStartUnregisteredPlayback() async throws { + let session = StubAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + let (player, engines) = try makePlayer(coordinator: coordinator) + + // Playing without a registered holder would leave the engine exposed + // to another holder's last-release deactivating the session under it. + session.setCategoryError = StubSessionError() + let frames = try encodeSineFrames() + player.enqueue(frames) + await waitUntil { player.stopped } + + #expect(engines().count == 1) + #expect(engines()[0].startCount == 0) + #expect(!player.isPlaying) + + // The failed start latched the player off; later frames are ignored. + player.enqueue(frames) + #expect(engines()[0].scheduledBuffers.isEmpty) + #expect(!player.isPlaying) + } + + // MARK: - Engine start failure + + @Test func engineStartFailureRebuildsOnceBeforeGivingUp() async throws { + let coordinator = AudioSessionCoordinator(session: StubAudioSession()) + let (player, engines) = try makePlayer(coordinator: coordinator) + + // A capture racing the start can reconfigure the session while the + // engine spins up (its escalation fan-out no-ops on a never-started + // player): the player must rebuild once against the settled + // configuration instead of latching off. + engines()[0].startError = StubSessionError() + let frames = try encodeSineFrames() + player.enqueue(frames) + + await waitUntil { player.isPlaying } + #expect(engines().count == 2) + #expect(engines()[0].startCount == 0) + #expect(engines()[1].startCount == 1) + #expect(!engines()[1].scheduledBuffers.isEmpty) + player.stop() + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 57714e45..0cfb6f92 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -225,8 +225,9 @@ final class PerformanceBaselineTests: XCTestCase { /// `ConversationStore`'s message-ID → conversation map: 2000 public /// (split mesh + geohash to stay under the per-conversation cap) + 50x40 /// private messages, 500 status updates per pass. Statuses alternate - /// sent <-> delivered so every call performs a real update (never the - /// skip path). + /// between two `delivered` timestamps so every call performs a real update + /// (never the skip path). A sent <-> delivered alternation would now hit + /// the store's no-downgrade guard on the delivered -> sent half. func testDeliveryStatusIncrementalUpdates() { let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) let coordinator = ChatDeliveryCoordinator(context: context) @@ -234,12 +235,13 @@ final class PerformanceBaselineTests: XCTestCase { XCTAssertEqual(targetIDs.count, 500) let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001) var toggle = false var samples: [TimeInterval] = [] measure { toggle.toggle() - let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent + let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2) let start = Date() var updated = 0 for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) { @@ -267,13 +269,16 @@ final class PerformanceBaselineTests: XCTestCase { let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) XCTAssertEqual(targetIDs.count, 500) + // Alternate two delivered timestamps so every update is real; a + // sent <-> delivered swing would hit the no-downgrade guard. let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001) var toggle = false var samples: [TimeInterval] = [] measure { toggle.toggle() - let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent + let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2) let start = Date() var updated = 0 for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) { @@ -301,7 +306,7 @@ final class PerformanceBaselineTests: XCTestCase { ("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]), ("checking in from the harbor #bitchat #mesh", nil), ("@bob#0042 ping me when you get this", ["bob#0042"]), - ("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil), + ("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil) ] let batches: [[BitchatMessage]] = (0.. Bool { true } private(set) var handledPublicMessageCount = 0 - func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 } + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 } func checkForMentions(_ message: BitchatMessage) {} func sendHapticFeedback(for message: BitchatMessage) {} func parseMentions(from content: String) -> [String] { @@ -667,8 +672,6 @@ private final class PerfNostrContext: ChatNostrContext { func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil } func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] } - func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {} - func postLocalNotification(title: String, body: String, identifier: String) {} func notifyGeohashActivity(geohash: String, bodyPreview: String) {} } @@ -778,7 +781,6 @@ private final class PerfDeliveryContext: ChatDeliveryContext { @MainActor private final class PerfPipelineFixture { let viewModel: ChatViewModel - let transport: MockTransport let conversations: ConversationStore let privateInbox: PrivateInboxModel let publicChat: PublicChatModel @@ -789,15 +791,19 @@ private final class PerfPipelineFixture { let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let conversations = ConversationStore() + let locationSuite = "PerformanceBaselineTests.\(UUID().uuidString)" + let locationStorage = UserDefaults(suiteName: locationSuite) ?? .standard + locationStorage.removePersistentDomain(forName: locationSuite) + let locationManager = LocationChannelManager(storage: locationStorage) - self.transport = transport self.conversations = conversations self.viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport, - conversations: conversations + conversations: conversations, + locationManager: locationManager ) self.privateInbox = PrivateInboxModel(conversations: conversations) self.publicChat = PublicChatModel(conversations: conversations) diff --git a/bitchatTests/Prekeys/NoisePrekeyTests.swift b/bitchatTests/Prekeys/NoisePrekeyTests.swift new file mode 100644 index 00000000..44a39d11 --- /dev/null +++ b/bitchatTests/Prekeys/NoisePrekeyTests.swift @@ -0,0 +1,283 @@ +// +// NoisePrekeyTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys +/// instead of the recipient's identity static key. +struct NoisePrekeyTests { + + @Test func sealAndOpenRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let payload = Data("meet at the north gate".utf8) + let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + + let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + #expect(opened.payload == payload) + // The X pattern authenticates the sender: Bob learns Alice's real static key. + #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) + } + + @Test func wrongPrekeyIDCannotOpen() throws { + // The prologue binds the ciphertext to a specific prekey ID; opening + // with a different (existing) prekey must fail. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + #expect(bundle.prekeys.count >= 2) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0]) + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id) + } + } + + @Test func unknownPrekeyIDThrows() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + #expect(throws: NoiseEncryptionError.unknownPrekey) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF) + } + } + + @Test func wrongRecipientCannotOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let carol = NoiseEncryptionService(keychain: MockKeychain()) + let bobBundle = try #require(bob.currentPrekeyBundle()) + // Ensure Carol holds a prekey under the same ID as Bob's. + _ = try #require(carol.currentPrekeyBundle()) + let prekey = try #require(bobBundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + #expect(throws: (any Error).self) { + _ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id) + } + } + + @Test func tamperedCiphertextFailsToOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + sealed[sealed.count - 1] ^= 0x01 + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + } + } + + @Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws { + // Spray-and-wait can deliver the same ciphertext via several couriers + // days apart; the consumed private survives a grace window for that. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey) + let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + #expect(first.payload == second.payload) + } + + @Test func prekeyAndStaticSealsAreNotInterchangeable() throws { + // Domain-separated prologues: a static-sealed envelope must not open + // via the prekey path and vice versa, even with matching key material. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id) + } + + let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey) + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(prekeySealed) + } + } + + @Test func sealRejectsInvalidPrekeyPublicKey() { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + #expect(throws: (any Error).self) { + _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32))) + } + #expect(throws: (any Error).self) { + _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8))) + } + } + + @Test func sealsAreNotLinkableAcrossSends() throws { + // Fresh ephemeral per seal even to the same prekey. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + let payload = Data("same message".utf8) + + let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + #expect(a != b) + #expect(a.prefix(32) != b.prefix(32)) + } +} + +/// Local one-time prekey lifecycle: batch generation, consumption, the 48h +/// redelivery grace window, replenishment, and the panic wipe. +struct LocalPrekeyStoreTests { + + private final class Clock { + var now: Date + init(_ now: Date = Date()) { self.now = now } + } + + private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore { + LocalPrekeyStore(keychain: keychain, now: { clock.now }) + } + + private func bundle(noiseKey: Data, prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) -> PrekeyBundle { + PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: prekeys, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + } + + @Test func mintsFullBatchOnFirstUse() { + let store = makeStore(clock: Clock()) + let (prekeys, generatedAt) = store.currentBundlePrekeys() + #expect(prekeys.count == LocalPrekeyStore.Policy.batchSize) + #expect(generatedAt > 0) + #expect(Set(prekeys.map(\.id)).count == prekeys.count) + } + + @Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() { + let clock = Clock() + let store = makeStore(clock: clock) + let (initial, firstGeneratedAt) = store.currentBundlePrekeys() + + // Consuming down to the threshold does not regenerate... + let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold + for prekey in initial.dropLast(keepUnconsumed) { + store.markConsumed(prekey.id) + } + #expect(!store.replenishIfNeeded()) + #expect(store.unconsumedCount == keepUnconsumed) + + // ...one more consumption does, topping back up to a full batch with + // a newer generation stamp. + clock.now = clock.now.addingTimeInterval(60) + store.markConsumed(initial[initial.count - keepUnconsumed].id) + #expect(store.replenishIfNeeded()) + let (replenished, secondGeneratedAt) = store.currentBundlePrekeys() + #expect(replenished.count == LocalPrekeyStore.Policy.batchSize) + #expect(secondGeneratedAt > firstGeneratedAt) + // Surviving unconsumed prekeys stay in the fresh bundle. + let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id)) + #expect(survivorIDs.isSubset(of: Set(replenished.map(\.id)))) + } + + @Test func consumingAPrekeyRepublishesANewerBundlePeersAccept() { + // Codex P1: consuming a prekey (even above the replenish threshold) + // must republish a strictly newer bundle so a peer that cached the old + // one replaces it and stops assigning the consumed ID before its 48h + // grace lapses. + let clock = Clock() + let store = makeStore(clock: clock) + let noiseKey = Data(repeating: 0xC0, count: 32) + + // Owner publishes; a peer caches it and would assign the first prekey. + let (initial, firstGeneratedAt) = store.currentBundlePrekeys() + let peerCache = PrekeyBundleStore(persistsToDisk: false) + #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: initial, generatedAt: firstGeneratedAt))) + let consumedID = initial[0].id + #expect(peerCache.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == consumedID) + + // The owner opens mail sealed to that prekey: it's retired and the + // republished bundle is strictly newer and no longer offers the ID. + #expect(store.markConsumed(consumedID)) + let (afterConsume, secondGeneratedAt) = store.currentBundlePrekeys() + #expect(secondGeneratedAt > firstGeneratedAt) + #expect(!afterConsume.contains { $0.id == consumedID }) + + // The peer accepts the replacement (a same-generatedAt copy would be + // rejected) and stops assigning the consumed ID for new mail. + #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: afterConsume, generatedAt: secondGeneratedAt))) + #expect(peerCache.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id != consumedID) + + // 48h grace: the owner can still open a redelivery of the in-flight + // ciphertext sealed to the consumed ID until the window lapses. + clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) + #expect(store.privateKey(for: consumedID) != nil) + clock.now = clock.now.addingTimeInterval(120) + #expect(store.privateKey(for: consumedID) == nil) + } + + @Test func consumedPrivateSurvivesGraceWindowThenDies() { + let clock = Clock() + let store = makeStore(clock: clock) + let (prekeys, _) = store.currentBundlePrekeys() + let id = prekeys[0].id + + store.markConsumed(id) + // Within the grace window: still retrievable for redeliveries. + clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) + #expect(store.privateKey(for: id) != nil) + + // Past the grace window: gone (even before replenish prunes it). + clock.now = clock.now.addingTimeInterval(120) + #expect(store.privateKey(for: id) == nil) + store.replenishIfNeeded() + #expect(store.privateKey(for: id) == nil) + } + + @Test func persistsAcrossInstances() { + let keychain = MockKeychain() + let clock = Clock() + let first = LocalPrekeyStore(keychain: keychain, now: { clock.now }) + let (prekeys, generatedAt) = first.currentBundlePrekeys() + first.markConsumed(prekeys[0].id) + + let second = LocalPrekeyStore(keychain: keychain, now: { clock.now }) + let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys() + // Consuming a prekey shrinks the published bundle, so its generation + // stamp advances strictly (even without the clock moving) — peers must + // see a newer bundle to replace the one that still offered the + // consumed ID. + #expect(reloadedGeneratedAt > generatedAt) + #expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id))) + // The consumed key is still openable within grace after a relaunch. + #expect(second.privateKey(for: prekeys[0].id) != nil) + } + + @Test func wipeRemovesEverything() { + let keychain = MockKeychain() + let store = LocalPrekeyStore(keychain: keychain) + let (prekeys, _) = store.currentBundlePrekeys() + store.wipe() + #expect(store.privateKey(for: prekeys[0].id) == nil) + #expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil) + } +} diff --git a/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift new file mode 100644 index 00000000..319757f0 --- /dev/null +++ b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift @@ -0,0 +1,197 @@ +// +// PrekeyBundleStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest, +/// per-message prekey assignment (never reused across messages), expiry, and +/// the peer cap. +struct PrekeyBundleStoreTests { + + private func makeBundle( + noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation, + ids: [UInt32] = [0, 1, 2], + generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) + ) -> PrekeyBundle { + PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) }, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + } + + @Test func ingestKeepsLatestByGeneratedAt() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB0, count: 32) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000) + let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs) + + #expect(store.ingest(new)) + // Older (and equal) bundles never displace a newer one. + #expect(!store.ingest(old)) + #expect(!store.ingest(new)) + + let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + #expect(assigned?.id == 2) + } + + @Test func assignmentsConsumeDistinctPrekeysPerMessage() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB1, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11]))) + + let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) + #expect(first?.id == 10) + #expect(second?.id == 11) + // Exhausted: fall back to static sealing. + #expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil) + #expect(!store.hasUsableBundle(for: noiseKey)) + } + + @Test func redepositOfSameMessageReusesItsPrekey() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB2, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7]))) + + let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + #expect(first?.id == retry?.id) + #expect(first?.publicKey == retry?.publicKey) + // Only one prekey was burned. + let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) + #expect(next?.id == 6) + } + + @Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB3, count: 32) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000))) + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0) + + // The owner topped up: ID 1 survives (still unconsumed on their side), + // ID 0 rotated out, new IDs appear. + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs))) + // m1's assignment referenced a rotated-out ID; a re-deposit picks a + // fresh one rather than sealing to a dead key. + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) + #expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8) + } + + @Test func expiredBundleIsNeverUsed() { + var current = Date() + let store = PrekeyBundleStore(persistsToDisk: false, now: { current }) + let noiseKey = Data(repeating: 0xB4, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) + #expect(store.hasUsableBundle(for: noiseKey)) + + current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60) + #expect(!store.hasUsableBundle(for: noiseKey)) + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil) + } + + @Test func peerCapEvictsLeastRecentlyUpdated() { + var current = Date() + let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current }) + let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) } + + for key in keys { + #expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) + current = current.addingTimeInterval(1) + } + // Oldest entry evicted; the two most recent survive. + #expect(!store.hasUsableBundle(for: keys[0])) + #expect(store.hasUsableBundle(for: keys[1])) + #expect(store.hasUsableBundle(for: keys[2])) + } + + @Test func persistsAcrossInstancesAndWipes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true) + let fileURL = dir.appendingPathComponent("bundles.json") + defer { try? FileManager.default.removeItem(at: dir) } + + let noiseKey = Data(repeating: 0xB5, count: 32) + let first = PrekeyBundleStore(fileURL: fileURL) + #expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2]))) + #expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) + + // Consumption state survives a relaunch, so a restart can't reuse a prekey. + let second = PrekeyBundleStore(fileURL: fileURL) + #expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2) + + second.wipe() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + let third = PrekeyBundleStore(fileURL: fileURL) + #expect(!third.hasUsableBundle(for: noiseKey)) + } +} + +/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that +/// v1 decoders skip as unknown. +struct CourierEnvelopeV2Tests { + + @Test func prekeyIDRoundTrips() throws { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength), + expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, + ciphertext: Data("ciphertext".utf8), + copies: 4, + prekeyID: 0xAABB_CCDD + ) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + #expect(decoded.prekeyID == 0xAABB_CCDD) + } + + @Test func v1EnvelopeDecodesWithNilPrekeyID() throws { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength), + expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, + ciphertext: Data("legacy".utf8) + ) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.prekeyID == nil) + } + + @Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws { + // Static-sealed envelopes must stay on the pre-prekey wire format. + let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength) + let expiry: UInt64 = 1_800_000_000_000 + let ciphertext = Data("same".utf8) + let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode()) + let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode()) + #expect(v1 == v1Explicit) + // And a v2 envelope is the v1 bytes plus one trailing TLV a v1 + // decoder skips as unknown. + let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode()) + #expect(v2.prefix(v1.count) == v1) + #expect(v2.count == v1.count + 3 + 4) + } + + @Test func withCopiesPreservesPrekeyID() { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength), + expiry: 1, + ciphertext: Data([0x01]), + copies: 4, + prekeyID: 9 + ) + #expect(envelope.withCopies(2).prekeyID == 9) + } +} diff --git a/bitchatTests/Prekeys/PrekeyBundleTests.swift b/bitchatTests/Prekeys/PrekeyBundleTests.swift new file mode 100644 index 00000000..8d9b212b --- /dev/null +++ b/bitchatTests/Prekeys/PrekeyBundleTests.swift @@ -0,0 +1,133 @@ +// +// PrekeyBundleTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Wire format and signature binding for gossiped one-time prekey bundles. +struct PrekeyBundleTests { + + private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] { + (0.. { - subject.eraseToAnyPublisher() - } - func currentPeerSnapshots() -> [TransportPeerSnapshot] { subject.value } func setNickname(_ nickname: String) { myNickname = nickname } func startServices() {} @@ -50,14 +46,19 @@ private final class DefaultTransportProbe: Transport { struct ProtocolContractTests { @Test func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() { - #expect(CommandInfo.message.id == "dm") - #expect(CommandInfo.message.alias == "/dm") + // Aliases must match what CommandProcessor actually accepts — + // the suggestion panel is the only command-discovery surface. + #expect(CommandInfo.message.id == "msg") + #expect(CommandInfo.message.alias == "/msg") #expect(CommandInfo.message.placeholder != nil) #expect(CommandInfo.clear.placeholder == nil) #expect(CommandInfo.favorite.description.isEmpty == false) - #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false) - #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite)) - #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite)) + #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help)) + // Favorites are rejected by the processor in geohash contexts, so + // they are suggested only in mesh. + #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite)) + #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false) + #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false) } @Test @@ -102,6 +103,9 @@ struct ProtocolContractTests { #expect(probe.sentMessages.count == 1) #expect(probe.sentMessages.first?.content == "hello") #expect(probe.acceptPendingFile(id: "pending") == nil) + // Secure delivery defaults to prompt delivery (itself defaulting to + // reachability) for transports without a forgeable link layer. + #expect(probe.canDeliverSecurely(to: peerID) == false) } @Test diff --git a/bitchatTests/Protocols/BoardPacketsTests.swift b/bitchatTests/Protocols/BoardPacketsTests.swift new file mode 100644 index 00000000..9e1ff450 --- /dev/null +++ b/bitchatTests/Protocols/BoardPacketsTests.swift @@ -0,0 +1,211 @@ +// +// BoardPacketsTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +struct BoardPacketsTests { + + private let authorKey = Curve25519.Signing.PrivateKey() + + private func makeSignedPost( + geohash: String = "9q8yy", + content: String = "water point at the north gate", + nickname: String = "ranger", + createdAt: UInt64 = 1_700_000_000_000, + lifetimeMs: UInt64 = 24 * 60 * 60 * 1000, + flags: UInt8 = 0, + signWith key: Curve25519.Signing.PrivateKey? = nil, + claimKey: Data? = nil + ) throws -> BoardPostPacket { + let signer = key ?? authorKey + let publicKey = claimKey ?? signer.publicKey.rawRepresentation + let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) + let expiresAt = createdAt + lifetimeMs + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: publicKey, + authorNickname: nickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags + ) + let signature = try signer.signature(for: signingBytes) + return BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: publicKey, + authorNickname: nickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags, + signature: signature + ) + } + + private func makeSignedTombstone( + postID: Data, + deletedAt: UInt64 = 1_700_000_100_000, + signWith key: Curve25519.Signing.PrivateKey? = nil, + claimKey: Data? = nil + ) throws -> BoardTombstonePacket { + let signer = key ?? authorKey + let publicKey = claimKey ?? signer.publicKey.rawRepresentation + let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt)) + return BoardTombstonePacket( + postID: postID, + authorSigningKey: publicKey, + deletedAt: deletedAt, + signature: signature + ) + } + + // MARK: - Round trips + + @Test func postRoundTrip() throws { + let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag) + let encoded = BoardWire.post(post).encode() + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + guard case .post(let roundTripped) = decoded else { + Issue.record("expected a post") + return + } + #expect(roundTripped.isUrgent) + #expect(roundTripped.geohash == "9q8yy") + } + + @Test func meshLocalPostRoundTrip() throws { + let post = try makeSignedPost(geohash: "") + let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode())) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + } + + @Test func tombstoneRoundTrip() throws { + let post = try makeSignedPost() + let tombstone = try makeSignedTombstone(postID: post.postID) + let encoded = BoardWire.tombstone(tombstone).encode() + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .tombstone(tombstone)) + #expect(decoded.verifySignature()) + } + + // MARK: - Signature verification + + @Test func forgedPostSignatureFailsVerification() throws { + // Signed by an attacker's key but claiming the victim's key as author. + let attacker = Curve25519.Signing.PrivateKey() + let victim = Curve25519.Signing.PrivateKey() + let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation) + let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode())) + #expect(!decoded.verifySignature()) + } + + @Test func tamperedContentFailsVerification() throws { + let post = try makeSignedPost(content: "meet at noon") + let tampered = BoardPostPacket( + postID: post.postID, + geohash: post.geohash, + content: "meet at midnight", + authorSigningKey: post.authorSigningKey, + authorNickname: post.authorNickname, + createdAt: post.createdAt, + expiresAt: post.expiresAt, + flags: post.flags, + signature: post.signature + ) + let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode())) + #expect(!decoded.verifySignature()) + } + + @Test func forgedTombstoneSignatureFailsVerification() throws { + let post = try makeSignedPost() + let attacker = Curve25519.Signing.PrivateKey() + let forged = try makeSignedTombstone( + postID: post.postID, + signWith: attacker, + claimKey: post.authorSigningKey + ) + let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode())) + #expect(!decoded.verifySignature()) + } + + // MARK: - Decode validation + + @Test func rejectsExpiryBeyondSevenDays() throws { + let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1) + #expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil) + + let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs) + #expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil) + } + + @Test func rejectsExpiryBeforeCreation() throws { + let post = try makeSignedPost() + let inverted = BoardPostPacket( + postID: post.postID, + geohash: post.geohash, + content: post.content, + authorSigningKey: post.authorSigningKey, + authorNickname: post.authorNickname, + createdAt: post.expiresAt, + expiresAt: post.createdAt, + flags: post.flags, + signature: post.signature + ) + #expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil) + } + + @Test func rejectsOversizedContent() throws { + let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1)) + #expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil) + + let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes)) + #expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil) + } + + @Test func rejectsInvalidGeohashCharacters() throws { + let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32 + #expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil) + } + + @Test func toleratesUnknownTLVs() throws { + let post = try makeSignedPost() + var encoded = BoardWire.post(post).encode() + // Append an unknown TLV; decoders must skip it. + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + } + + @Test func rejectsTruncatedPayload() throws { + let post = try makeSignedPost() + let encoded = BoardWire.post(post).encode() + #expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil) + } + + // MARK: - Urgent flag peek + + @Test func urgentFlagPeekMatchesFullDecode() throws { + let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag) + let calm = try makeSignedPost() + let tombstone = try makeSignedTombstone(postID: calm.postID) + #expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode())) + #expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode())) + #expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode())) + #expect(!BoardWire.urgentFlag(in: Data())) + } +} diff --git a/bitchatTests/Protocols/BridgeWireFormatTests.swift b/bitchatTests/Protocols/BridgeWireFormatTests.swift new file mode 100644 index 00000000..1dc42e9a --- /dev/null +++ b/bitchatTests/Protocols/BridgeWireFormatTests.swift @@ -0,0 +1,201 @@ +// +// BridgeWireFormatTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Bridge wire formats") +struct BridgeWireFormatTests { + // MARK: - Announce bridgeGeohash TLV + + @Test func announceRoundTripsBridgeGeohash() throws { + let packet = AnnouncementPacket( + nickname: "gw", + noisePublicKey: Data(repeating: 1, count: 32), + signingPublicKey: Data(repeating: 2, count: 32), + directNeighbors: nil, + capabilities: [.bridge, .gateway], + bridgeGeohash: "u4pruy" + ) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.bridgeGeohash == "u4pruy") + #expect(decoded.capabilities?.contains(.bridge) == true) + } + + @Test func announceWithoutBridgeCellDecodesNil() throws { + let packet = AnnouncementPacket( + nickname: "plain", + noisePublicKey: Data(repeating: 1, count: 32), + signingPublicKey: Data(repeating: 2, count: 32), + directNeighbors: nil + ) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.bridgeGeohash == nil) + } + + @Test func announceRejectsOversizedBridgeCellAtEncode() throws { + let packet = AnnouncementPacket( + nickname: "gw", + noisePublicKey: Data(repeating: 1, count: 32), + signingPublicKey: Data(repeating: 2, count: 32), + directNeighbors: nil, + bridgeGeohash: String(repeating: "u", count: 13) + ) + // Oversized cell is silently omitted, not a hard failure. + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.bridgeGeohash == nil) + } + + // MARK: - Carrier directions + + @Test func bridgeCarrierDirectionsRoundTrip() throws { + for direction in [NostrCarrierPacket.Direction.toBridge, .fromBridge] { + let packet = try #require(NostrCarrierPacket( + direction: direction, + geohash: "u4pruy", + eventJSON: Data("{\"id\":\"x\"}".utf8) + )) + let encoded = try #require(packet.encode()) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + #expect(decoded.direction == direction) + #expect(decoded.geohash == "u4pruy") + } + } + + // MARK: - BitchatMessage bridged flag + // (Binary round-trip lives in BitFoundation's own tests — + // `toBinaryPayload` is internal to the package.) + + @Test func bridgedFlagSurvivesCodableRoundTrip() throws { + let message = BitchatMessage( + sender: "far-friend", + content: "hi", + timestamp: Date(), + isRelay: false, + isBridged: true + ) + let data = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(BitchatMessage.self, from: data) + #expect(decoded.isBridged) + } + + @Test func legacyJSONWithoutBridgedFlagDecodes() throws { + let plain = BitchatMessage( + sender: "old-client", + content: "hi", + timestamp: Date(), + isRelay: false + ) + let encoded = try JSONEncoder().encode(plain) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "isBridged") + let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(!decoded.isBridged) + } + + @Test func bridgePeerIDParsesAndClassifies() { + let peerID = PeerID(str: "bridge:deadbeefcafe0123") + #expect(peerID.isBridge) + #expect(peerID.bare == "deadbeefcafe0123") + #expect(!peerID.isGeoChat) + } +} + +@Suite("Mesh message identity") +struct MeshMessageIdentityTests { + @Test func stableIDIsDeterministicHex() { + let id = MeshMessageIdentity.stableID( + senderIDHex: "0011223344556677", + timestampMs: 1_750_000_000_123, + content: "hello mesh" + ) + // Pinned vector: first 32 hex chars of + // SHA256("0011223344556677|1750000000123|hello mesh"). Any drift + // breaks cross-device (and cross-version) dedup. + #expect(id == "b83f94d81dcdd1b0c0048f6645995dd4") + #expect(id == MeshMessageIdentity.stableID( + senderIDHex: "0011223344556677", + timestampMs: 1_750_000_000_123, + content: "hello mesh" + )) + } + + @Test func senderIDIsCaseInsensitive() { + let lower = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: "x") + let upper = MeshMessageIdentity.stableID(senderIDHex: "AABBCCDD00112233", timestampMs: 1, content: "x") + #expect(lower == upper) + } + + @Test func contentWhitespaceIsNormalized() { + // Senders bridge the trimmed content while the radio carries the + // original; both must derive the same key. + let raw = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: " hello mesh \n") + let trimmed = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: "hello mesh") + #expect(raw == trimmed) + } + + @Test func anyCoordinateChangeChangesTheID() { + let base = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 5, content: "x") + #expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112234", timestampMs: 5, content: "x")) + #expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 6, content: "x")) + #expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 5, content: "y")) + } + + @Test func millisecondTimestampTruncatesLikeTheWire() { + // Must match `BLEService.sendMessage`'s UInt64(seconds * 1000). + #expect(MeshMessageIdentity.millisecondTimestamp(Date(timeIntervalSince1970: 1_000.9996)) == 1_000_999) + #expect(MeshMessageIdentity.millisecondTimestamp(Date(timeIntervalSince1970: 1_000)) == 1_000_000) + } +} + +@Suite("Courier store bridge publish") +struct CourierStoreBridgePublishTests { + private func makeStore(now: @escaping () -> Date = Date.init) -> CourierStore { + CourierStore(persistsToDisk: false, now: now) + } + + private func makeEnvelope(now: Date = Date()) -> CourierEnvelope { + CourierEnvelope( + recipientTag: Data(repeating: 3, count: 16), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: Data(repeating: 9, count: 64), + copies: 4 + ) + } + + @Test func bridgePublishIsNonDestructiveAndCooledDown() { + var currentDate = Date() + let store = makeStore(now: { currentDate }) + #expect(store.deposit(makeEnvelope(now: currentDate), from: Data(repeating: 5, count: 32))) + + let first = store.envelopesForBridgePublish(cooldown: 600) + #expect(first.count == 1) + // The relay copy is carry-only regardless of stored spray budget. + #expect(first.first?.copies == 1) + // (Non-destructiveness is proven below: the same envelope is + // eligible again after the cooldown. `carriedCount` publishes + // asynchronously, so it is not asserted here.) + + // Merely offering the envelope does not start the cooldown; a relay + // rejection/timeout must remain immediately retryable. + #expect(store.envelopesForBridgePublish(cooldown: 600).count == 1) + store.markBridgePublished(first[0]) + + // A confirmed publish starts the cooldown. + #expect(store.envelopesForBridgePublish(cooldown: 600).isEmpty) + + // After cooldown: eligible again. + currentDate = currentDate.addingTimeInterval(601) + #expect(store.envelopesForBridgePublish(cooldown: 600).count == 1) + } +} diff --git a/bitchatTests/Protocols/NostrCarrierPacketTests.swift b/bitchatTests/Protocols/NostrCarrierPacketTests.swift new file mode 100644 index 00000000..50ffb287 --- /dev/null +++ b/bitchatTests/Protocols/NostrCarrierPacketTests.swift @@ -0,0 +1,96 @@ +// +// NostrCarrierPacketTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +@Suite("Nostr carrier packet TLV") +struct NostrCarrierPacketTests { + private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + @Test("round-trips both directions with the signed event intact") + func roundTrip() throws { + let event = try makeEvent() + for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + + #expect(decoded == packet) + #expect(decoded.direction == direction) + #expect(decoded.geohash == "u4pruy") + + // The carried event survives byte-exact: same ID, and the + // signature still verifies after the mesh hop. + let carried = try #require(decoded.event()) + #expect(carried.id == event.id) + #expect(carried.sig == event.sig) + #expect(carried.isValidSignature()) + } + } + + @Test("rejects an oversized event at construction and at decode") + func oversizedRejected() throws { + let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil) + + // Hand-build the TLV bytes to bypass the initializer's cap. + var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue]) + let geohash = Data("u4pruy".utf8) + data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)]) + data.append(geohash) + data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)]) + data.append(oversized) + #expect(NostrCarrierPacket.decode(data) == nil) + } + + @Test("rejects an over-length or empty geohash") + func geohashBoundsEnforced() throws { + let event = try makeEvent() + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil) + } + + @Test("skips unknown TLVs for forward compatibility") + func unknownTLVSkipped() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event)) + var encoded = try #require(packet.encode()) + // Append an unknown TLV (type 0x7F, 2-byte value). + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + #expect(decoded == packet) + } + + @Test("rejects truncated and missing-field payloads") + func malformedRejected() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + + // Truncation anywhere inside the last TLV fails cleanly. + #expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil) + #expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil) + #expect(NostrCarrierPacket.decode(Data()) == nil) + + // Direction TLV alone (missing geohash and event) fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil) + // Unknown direction value fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil) + } +} diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index 78c244de..2368925a 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation import Testing @@ -108,6 +109,42 @@ struct PacketsTests { #expect(decoded.directNeighbors == nil) } + @Test + func announcementPacketRoundTripsCapabilities() throws { + let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics] + let packet = AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil, + capabilities: capabilities + ) + + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.capabilities == capabilities) + } + + @Test + func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws { + let legacy = try #require( + AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil + ).encode() + ) + // The TLV is emitted only when capabilities are set, so legacy peers + // (and this packet) decode as nil rather than empty. + #expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil) + + var withFutureBits = legacy + withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01]))) + let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits)) + #expect(decoded.capabilities?.rawValue == 0x0180) + } + @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/bitchatTests/Protocols/VouchAttestationTests.swift b/bitchatTests/Protocols/VouchAttestationTests.swift new file mode 100644 index 00000000..064cf4d8 --- /dev/null +++ b/bitchatTests/Protocols/VouchAttestationTests.swift @@ -0,0 +1,176 @@ +import CryptoKit +import Foundation +import Testing + +@testable import bitchat + +struct VouchAttestationTests { + private let voucherKey = Curve25519.Signing.PrivateKey() + + private func makeAttestation( + fingerprint: Data = Data(repeating: 0xAA, count: 32), + signingKey: Data = Data(repeating: 0xBB, count: 32), + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), + signedBy key: Curve25519.Signing.PrivateKey? = nil + ) throws -> VouchAttestation { + let signer = key ?? voucherKey + return try #require( + VouchAttestation.build( + voucheeFingerprint: fingerprint, + voucheeSigningKey: signingKey, + timestampMs: timestampMs, + sign: { try? signer.signature(for: $0) } + ) + ) + } + + @Test + func roundTripsAndVerifiesSignature() throws { + let attestation = try makeAttestation() + let encoded = try #require(attestation.encode()) + let decoded = try #require(VouchAttestation.decode(from: encoded)) + + #expect(decoded == attestation) + #expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32)) + #expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + } + + @Test + func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws { + let attestation = try makeAttestation() + var encoded = try #require(attestation.encode()) + + // Unknown TLV appended: skipped for forward compatibility. + encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02]) + #expect(VouchAttestation.decode(from: encoded) == attestation) + + // Truncation and missing fields are rejected. + #expect(VouchAttestation.decode(from: encoded.dropLast()) == nil) + #expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil) + #expect(VouchAttestation.decode(from: Data()) == nil) + + // Wrong field sizes are rejected. + var wrongSize = Data([0x01, 0x10]) + wrongSize.append(Data(repeating: 0xAA, count: 16)) + #expect(VouchAttestation.decode(from: wrongSize) == nil) + } + + @Test + func buildRejectsWrongKeyAndFingerprintSizes() { + let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) } + #expect(VouchAttestation.build( + voucheeFingerprint: Data(repeating: 0xAA, count: 16), + voucheeSigningKey: Data(repeating: 0xBB, count: 32), + sign: sign + ) == nil) + #expect(VouchAttestation.build( + voucheeFingerprint: Data(repeating: 0xAA, count: 32), + voucheeSigningKey: Data(repeating: 0xBB, count: 16), + sign: sign + ) == nil) + } + + @Test + func forgedSignatureFailsVerification() throws { + let attestation = try makeAttestation() + let otherKey = Curve25519.Signing.PrivateKey() + + // Verifying against a key that didn't sign fails. + #expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation)) + + // An attestation signed by an imposter fails against the real key. + let forged = try makeAttestation(signedBy: otherKey) + #expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + #expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3))) + } + + @Test + func tamperedFieldsFailVerification() throws { + let attestation = try makeAttestation() + let publicKey = voucherKey.publicKey.rawRepresentation + + var tamperedFingerprint = attestation.voucheeFingerprint + tamperedFingerprint[0] ^= 0xFF + let tampered = VouchAttestation( + voucheeFingerprint: tamperedFingerprint, + voucheeSigningKey: attestation.voucheeSigningKey, + timestampMs: attestation.timestampMs, + signature: attestation.signature + ) + #expect(!tampered.verifySignature(voucherSigningKey: publicKey)) + + let backdated = VouchAttestation( + voucheeFingerprint: attestation.voucheeFingerprint, + voucheeSigningKey: attestation.voucheeSigningKey, + timestampMs: attestation.timestampMs - 1, + signature: attestation.signature + ) + #expect(!backdated.verifySignature(voucherSigningKey: publicKey)) + } + + @Test + func expiryWindowIsEnforced() throws { + let now = Date() + let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000)) + #expect(!fresh.isExpired(now: now)) + + let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60) + let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000)) + #expect(expired.isExpired(now: now)) + + let farFuture = now.addingTimeInterval(2 * 60 * 60) + let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000)) + #expect(fromTheFuture.isExpired(now: now)) + + // A verified-but-expired attestation still has a valid signature; the + // two checks are independent gates. + #expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + } + + @Test + func batchRoundTripsAndEnforcesCap() throws { + let attestations = try (0..<3).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + let payload = try #require(VouchAttestation.encodeList(attestations)) + #expect(VouchAttestation.decodeList(from: payload) == attestations) + + #expect(VouchAttestation.encodeList([]) == nil) + + let tooMany = try (0..<17).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + #expect(VouchAttestation.encodeList(tooMany) == nil) + } + + @Test + func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws { + let attestations = try (0..<17).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + // Hand-build an oversized batch that lies about its count. + var payload = Data([UInt8(attestations.count)]) + for attestation in attestations { + let encoded = try #require(attestation.encode()) + payload.append(UInt8(encoded.count >> 8)) + payload.append(UInt8(encoded.count & 0xFF)) + payload.append(encoded) + } + let decoded = VouchAttestation.decodeList(from: payload) + #expect(decoded.count == VouchAttestation.maxBatchCount) + #expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount))) + + // A malformed middle entry is dropped without killing the batch. + let good = try makeAttestation() + let goodEncoded = try #require(good.encode()) + var mixed = Data([2]) + mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE]) + mixed.append(UInt8(goodEncoded.count >> 8)) + mixed.append(UInt8(goodEncoded.count & 0xFF)) + mixed.append(goodEncoded) + #expect(VouchAttestation.decodeList(from: mixed) == [good]) + + #expect(VouchAttestation.decodeList(from: Data()) == []) + #expect(VouchAttestation.decodeList(from: Data([5])) == []) + } +} diff --git a/bitchatTests/PublicMessagePipelineTests.swift b/bitchatTests/PublicMessagePipelineTests.swift index 6d1458da..4ab270a4 100644 --- a/bitchatTests/PublicMessagePipelineTests.swift +++ b/bitchatTests/PublicMessagePipelineTests.swift @@ -27,28 +27,28 @@ private final class TestPipelineDelegate: PublicMessagePipelineDelegate { committed.filter { $0.conversationID == conversationID }.map(\.message) } - func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { + func pipeline(_: PublicMessagePipeline, normalizeContent content: String) -> String { dedupService.normalizedContentKey(content) } - func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { + func pipeline(_: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { dedupService.contentTimestamp(forKey: key) } - func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { + func pipeline(_: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { dedupService.recordContentKey(key, timestamp: timestamp) recordedContentKeys.append(key) } - func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { + func pipeline(_: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { guard !rejectedMessageIDs.contains(message.id) else { return false } committed.append((message, conversationID)) return true } - func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {} + func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) {} - func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { + func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) { batchingStates.append(isBatching) } } @@ -128,4 +128,18 @@ struct PublicMessagePipelineTests { #expect(delegate.messages(in: .mesh).isEmpty) #expect(delegate.recordedContentKeys.isEmpty) } + + @Test @MainActor + func removeMessage_discardsBridgeAliasBeforeBatchFlush() { + let pipeline = PublicMessagePipeline() + let delegate = TestPipelineDelegate() + pipeline.delegate = delegate + + pipeline.enqueue(makeMessage(id: "bridge-event", content: "same radio payload", timestamp: Date()), to: .mesh) + pipeline.removeMessage(withID: "bridge-event") + pipeline.flushIfNeeded() + + #expect(delegate.committed.isEmpty) + #expect(delegate.recordedContentKeys.isEmpty) + } } diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 80944ed4..9623573a 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -8,6 +8,7 @@ struct BLEAnnounceHandlerTests { var existingNoisePublicKey: Data? var signatureValid = true var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) + var linkBoundToOtherPeer = false var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) var dedupSeenIDs: Set = [] var shouldEmitReconnectLogResult = true @@ -41,6 +42,7 @@ struct BLEAnnounceHandlerTests { return recorder.signatureValid }, linkState: { _ in recorder.linkState }, + linkBoundToOtherPeer: { _, _ in recorder.linkBoundToOtherPeer }, withRegistryBarrier: { body in recorder.barrierCount += 1 body() @@ -98,8 +100,12 @@ struct BLEAnnounceHandlerTests { recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.peerID == peerID) + #expect(result?.announcement.noisePublicKey == noiseKey) + #expect(result?.isDirectAnnounce == true) + #expect(result?.isVerified == true) #expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) #expect(recorder.barrierCount == 1) @@ -161,8 +167,11 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.peerID == peerID) + #expect(result?.announcement.noisePublicKey == noiseKey) + #expect(result?.isVerified == false) #expect(recorder.verifySignatureCalls.isEmpty) #expect(recorder.barrierCount == 1) #expect(recorder.upsertCalls.isEmpty) @@ -197,8 +206,9 @@ struct BLEAnnounceHandlerTests { recorder.signatureValid = false let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isVerified == false) #expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.upsertCalls.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) @@ -222,8 +232,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -242,8 +253,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -263,8 +275,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -311,8 +324,10 @@ struct BLEAnnounceHandlerTests { recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isDirectAnnounce == false) + #expect(result?.isVerified == true) #expect(recorder.upsertCalls.count == 1) #expect(recorder.upsertCalls.first?.isConnected == false) #expect(recorder.uiEventDeliveries.count == 1) @@ -321,6 +336,105 @@ struct BLEAnnounceHandlerTests { #expect(recorder.afterglowDelays.count == 1) } + /// TTL is unsigned, so a replayed announce with its TTL restored looks + /// "direct" — but it arrives on a link another peer already owns. That + /// link must not shortcut the (possibly absent) claimed peer into + /// "connected". + @Test + func directAnnounceOnLinkBoundToAnotherPeerDoesNotMarkConnected() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x88, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.linkBoundToOtherPeer = true + recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) + let handler = makeHandler(recorder: recorder, now: now) + + let result = handler.handle(packet, from: peerID) + + #expect(result?.isDirectAnnounce == true) + #expect(result?.isVerified == true) + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.isConnected == false) + } + + /// A peer with its own live link stays connected even when a copy of its + /// announce arrives on someone else's link. + @Test + func directAnnounceOnForeignLinkKeepsPeerWithOwnLinkConnected() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x99, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.linkBoundToOtherPeer = true + recorder.linkState = (hasPeripheral: false, hasCentral: true) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.isConnected == true) + } + + /// Documents the handler's contract around the rebind: the + /// linkBoundToOtherPeer read reflects the binding BEFORE the rebind runs, + /// so a replayed "direct" announce on someone else's link is denied the + /// connected shortcut — presence only flips via the rebind itself + /// (BLEService promotes the new owner after a successful, containment- + /// checked rebind) or via a later announce arriving on the now-rebound + /// link, as simulated here. Either way the residue is presence display + /// only — DMs remain gated on canDeliverSecurely, so without a Noise + /// session they take the retain + courier path (see MessageRouterTests + /// .sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier). + @Test + func secondReplayedDirectAnnounceAfterRebindMarksAbsentPeerConnected() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0xA1, count: 32) + let victim = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: victim, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + + // First replay: the link still belongs to the replayer and the victim + // has no live link of its own — the connected shortcut is denied. + recorder.linkBoundToOtherPeer = true + recorder.linkState = (hasPeripheral: false, hasCentral: false) + handler.handle(packet, from: victim) + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.isConnected == false) + + // The rebind then binds the replayer's link to the victim's ID (the + // rotation-heal path, containment-checked in BLEService). A second + // replay now finds the link owned by the claimed peer … + recorder.linkBoundToOtherPeer = false + recorder.linkState = (hasPeripheral: false, hasCentral: true) + handler.handle(packet, from: victim) + + // … and the absent victim reads as connected: the residual gap. + #expect(recorder.upsertCalls.count == 2) + #expect(recorder.upsertCalls.last?.isConnected == true) + } + @Test func announceBackIsSkippedWhenAlreadyMarked() throws { let now = Date(timeIntervalSince1970: 1_000) @@ -384,8 +498,9 @@ struct BLEAnnounceHandlerTests { recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isVerified == false) #expect(recorder.upsertCalls.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) diff --git a/bitchatTests/Services/BLEAnnounceThrottleTests.swift b/bitchatTests/Services/BLEAnnounceThrottleTests.swift index 3ca46203..96dde95c 100644 --- a/bitchatTests/Services/BLEAnnounceThrottleTests.swift +++ b/bitchatTests/Services/BLEAnnounceThrottleTests.swift @@ -45,7 +45,7 @@ struct BLEAnnounceThrottleTests { let now = Date(timeIntervalSince1970: 100) var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) - throttle.shouldSend(force: false, now: now) + _ = throttle.shouldSend(force: false, now: now) #expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3) } diff --git a/bitchatTests/Services/BLEFanoutSelectorTests.swift b/bitchatTests/Services/BLEFanoutSelectorTests.swift index 43146365..5832b676 100644 --- a/bitchatTests/Services/BLEFanoutSelectorTests.swift +++ b/bitchatTests/Services/BLEFanoutSelectorTests.swift @@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests { #expect(selection.centralIDs == Set(["c2"])) } + @Test + func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["target-p", "bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: nil, + peripheralPeerBindings: [ + "target-p": target, + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["target-p"])) + #expect(selection.centralIDs.isEmpty) + } + + @Test + func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: nil, + peripheralPeerBindings: [ + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs.isEmpty) + #expect(selection.centralIDs == Set(["target-c"])) + } + + @Test + func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: .central("target-c"), + peripheralPeerBindings: [ + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs.isEmpty) + #expect(selection.centralIDs.isEmpty) + } + @Test func directedSendExcludesAllLinksToIngressPeer() { let selection = BLEFanoutSelector.selectLinks( @@ -119,6 +192,75 @@ struct BLEFanoutSelectorTests { #expect(selection.centralIDs == Set(["c-unbound"])) } + @Test + func duplicateBoundLinksToOnePeerCollapseToItsPreferredLink() { + // After a restore the same phone can hold several live links bound to + // one peer; broadcasts must go down exactly one — the most recently + // bound (preferred) one, not dictionary order. + let peer = PeerID(str: "1122334455667788") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p-stale", "p-preferred", "p-stale-2"], + centralIDs: ["c-bound"], + ingressLink: nil, + peripheralPeerBindings: [ + "p-stale": peer, + "p-preferred": peer, + "p-stale-2": peer + ], + centralPeerBindings: ["c-bound": peer], + preferredPeripheralPerPeer: [peer: "p-preferred"], + directedPeerHint: nil, + packetType: MessageType.fragment.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p-preferred"])) + #expect(selection.centralIDs.isEmpty) + } + + @Test + func directedSendCollapsesDuplicateBoundLinksToPreferred() { + let peer = PeerID(str: "1122334455667788") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p-stale", "p-preferred"], + centralIDs: [], + ingressLink: nil, + peripheralPeerBindings: [ + "p-stale": peer, + "p-preferred": peer + ], + preferredPeripheralPerPeer: [peer: "p-preferred"], + directedPeerHint: peer, + packetType: MessageType.noiseEncrypted.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p-preferred"])) + #expect(selection.centralIDs.isEmpty) + } + + @Test + func uncollapsedSelectionReachesEveryLinkOfADuplicatelyLinkedPeer() { + // Announce fanout (collapse bypassed by the planner): the announce is + // the packet that binds links, so every live link must receive it. + let peer = PeerID(str: "1122334455667788") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p1", "p2"], + centralIDs: ["c1"], + ingressLink: nil, + peripheralPeerBindings: ["p1": peer, "p2": peer], + centralPeerBindings: ["c1": peer], + preferredPeripheralPerPeer: [peer: "p1"], + collapseDuplicatePeerLinks: false, + directedPeerHint: nil, + packetType: MessageType.announce.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p1", "p2"])) + #expect(selection.centralIDs == Set(["c1"])) + } + @Test func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() { let selection = BLEFanoutSelector.selectLinks( diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 518c97c0..170aba9a 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -8,8 +8,10 @@ struct BLEFileTransferHandlerTests { var localNickname = "Me" var peers: [PeerID: BLEPeerInfo] = [:] var signedName: String? + var signatureVerifies = false var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf") + var signatureVerifyCount = 0 var signedNameQueries: [PeerID] = [] var trackedPackets: [BitchatPacket] = [] var quotaReservations: [Int] = [] @@ -20,12 +22,17 @@ struct BLEFileTransferHandlerTests { private let localPeerID = PeerID(str: "0102030405060708") private let remotePeerID = PeerID(str: "1122334455667788") + private let sampleSigningKey = Data(repeating: 0xAB, count: 32) private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler { let environment = BLEFileTransferHandlerEnvironment( localPeerID: { [localPeerID] in localPeerID }, localNickname: { recorder.localNickname }, peersSnapshot: { recorder.peers }, + verifyPacketSignature: { _, _ in + recorder.signatureVerifyCount += 1 + return recorder.signatureVerifies + }, signedSenderDisplayName: { _, peerID in recorder.signedNameQueries.append(peerID) return recorder.signedName @@ -53,13 +60,15 @@ struct BLEFileTransferHandlerTests { @Test func broadcastFileFromVerifiedPeerIsSavedAndDelivered() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let content = Data("%PDF-1.7".utf8) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content) - handler.handle(packet, from: remotePeerID) + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.signatureVerifyCount == 1) #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.count == 1) #expect(recorder.quotaReservations == [content.count]) @@ -77,6 +86,7 @@ struct BLEFileTransferHandlerTests { #expect(message?.isPrivate == false) #expect(message?.senderPeerID == remotePeerID) #expect(message?.timestamp == Date(timeIntervalSince1970: 900)) + #expect(message?.deliveryStatus == nil) } @Test @@ -85,7 +95,9 @@ struct BLEFileTransferHandlerTests { let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3) - handler.handle(packet, from: localPeerID) + // The relay pipeline already suppresses self-originated packets, so the + // handler reports "relayable" rather than treating the echo as forged. + #expect(handler.handle(packet, from: localPeerID)) expectNoSideEffects(recorder) } @@ -96,7 +108,7 @@ struct BLEFileTransferHandlerTests { let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - handler.handle(packet, from: remotePeerID) + #expect(!handler.handle(packet, from: remotePeerID)) #expect(recorder.signedNameQueries == [remotePeerID]) #expect(recorder.trackedPackets.isEmpty) @@ -104,20 +116,126 @@ struct BLEFileTransferHandlerTests { } @Test - func connectedUnverifiedPeerIsAccepted() throws { + func broadcastFromConnectedUnverifiedPeerWithoutSignatureIsDropped() throws { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - handler.handle(packet, from: remotePeerID) + // Failed sender authentication must also stop the packet from being + // relayed to downstream nodes. + #expect(!handler.handle(packet, from: remotePeerID)) - // Unlike public messages, file transfers accept connected-but-unverified peers. - #expect(recorder.signedNameQueries.isEmpty) + // Broadcast files carry an attacker-controllable senderID, so — like + // public messages — a connected-but-unverified peer must present a valid + // packet signature. No signing key + no signed identity means dropped. + #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func broadcastFromConnectedUnverifiedPeerWithSignedIdentityIsAccepted() throws { + let recorder = Recorder() + // Connected but nickname not yet verified and no registry signing key — + // the persisted-identity signature lookup still authenticates the + // sender, so the transfer is accepted under that verified name. + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] + recorder.signedName = "Bob" + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + + #expect(handler.handle(packet, from: remotePeerID)) + + #expect(recorder.signedNameQueries == [remotePeerID]) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.sender == "Bob") } + @Test + func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws { + // Our own broadcast file replayed via gossip sync arrives with ttl==0 + // (so it is not treated as a self-echo) and cannot be verified against + // the peer registry — it must still be accepted, matching + // BLEPublicMessageHandler's self exemption. + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: localPeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + ttl: 0 + ) + + #expect(handler.handle(packet, from: localPeerID)) + + #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.sender == "Me") + } + + @Test + func broadcastFromPeerNotInRegistryAcceptedViaSignedIdentity() throws { + let recorder = Recorder() + recorder.signedName = "Carol" + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + + #expect(handler.handle(packet, from: remotePeerID)) + + // Peer absent from the registry: fall back to the persisted-identity + // signature lookup (mirrors BLEPublicMessageHandler). + #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.sender == "Carol") + } + + @Test + func spoofedBroadcastVoiceNoteWithoutSignatureIsDropped() throws { + // Regression for the PR #1406 finding: an in-range peer that observed a + // public voice burst tries to overwrite the live bubble by broadcasting + // a `voice_.m4a` note under the talker's senderID. Without a + // valid signature the note never reaches the coordinator's absorption. + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)] + let handler = makeHandler(recorder: recorder) + let m4a = Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A ".utf8) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "audio/mp4", + content: m4a, + fileName: "voice_1122334455667788" + ) + + // The spoofed note must be dropped locally AND not relayed onward. + #expect(!handler.handle(packet, from: remotePeerID)) + + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func privateFileFromConnectedUnverifiedPeerIsAccepted() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + recipientID: Data(hexString: localPeerID.id) + ) + + #expect(handler.handle(packet, from: remotePeerID)) + + // Directed transfers keep the lenient connected-peer path (no broadcast + // exposure); no signature check is required. + #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.isPrivate == true) + } + @Test func fileDirectedToAnotherPeerIsIgnored() throws { let recorder = Recorder() @@ -130,7 +248,8 @@ struct BLEFileTransferHandlerTests { recipientID: Data(hexString: "AABBCCDDEEFF0011") ) - handler.handle(packet, from: remotePeerID) + // Not for us, but it must keep relaying toward the real recipient. + #expect(handler.handle(packet, from: remotePeerID)) #expect(recorder.trackedPackets.isEmpty) #expect(recorder.quotaReservations.isEmpty) @@ -150,19 +269,24 @@ struct BLEFileTransferHandlerTests { recipientID: Data(hexString: localPeerID.id) ) - handler.handle(packet, from: remotePeerID) + #expect(handler.handle(packet, from: remotePeerID)) // Directed transfers are not tracked for gossip sync. #expect(recorder.trackedPackets.isEmpty) #expect(recorder.lastSeenUpdates == [remotePeerID]) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.isPrivate == true) + // Must be explicit: BitchatMessage defaults private messages to + // .sending, which the media views render as an in-flight send + // (empty reveal mask, disabled reveal tap). + #expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900))) } @Test func malformedPayloadIsTrackedForSyncButDropped() { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = BitchatPacket( type: MessageType.fileTransfer.rawValue, @@ -174,7 +298,8 @@ struct BLEFileTransferHandlerTests { ttl: TransportConfig.messageTTLDefault ) - handler.handle(packet, from: remotePeerID) + // Local decode failures are not proof of forgery; the packet stays relayable. + #expect(handler.handle(packet, from: remotePeerID)) // Sync tracking happens before payload validation, matching the original order. #expect(recorder.trackedPackets.count == 1) @@ -186,11 +311,12 @@ struct BLEFileTransferHandlerTests { @Test func unsupportedMimeIsDroppedBeforeQuotaAndSave() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00])) - handler.handle(packet, from: remotePeerID) + #expect(handler.handle(packet, from: remotePeerID)) #expect(recorder.trackedPackets.count == 1) #expect(recorder.quotaReservations.isEmpty) @@ -201,12 +327,14 @@ struct BLEFileTransferHandlerTests { @Test func saveFailureSkipsDelivery() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true recorder.saveResult = nil let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - handler.handle(packet, from: remotePeerID) + // A local save failure must not stop the mesh relay. + #expect(handler.handle(packet, from: remotePeerID)) #expect(recorder.quotaReservations.count == 1) #expect(recorder.saveCalls.count == 1) @@ -214,6 +342,34 @@ struct BLEFileTransferHandlerTests { #expect(recorder.deliveredMessages.isEmpty) } + @Test + func quotaEvictionForFinalizedArrivalSkipsInFlightLiveCaptures() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("quota-live-capture-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: base) } + let store = BLEIncomingFileStore(baseDirectory: base) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + + // The in-flight partial is the LRU-oldest eviction candidate; without + // the voice_live_ pattern guard it would be deleted first, unlinking + // the inode under the coordinator's open FileHandle. + let inFlight = incoming.appendingPathComponent("voice_live_00112233445566ff_1122334455667788_dm.aac") + let evictable = incoming.appendingPathComponent("voice_old.m4a") + try Data(count: 51 * 1024 * 1024).write(to: inFlight) + try Data(count: 51 * 1024 * 1024).write(to: evictable) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -7200)], ofItemAtPath: inFlight.path) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -60)], ofItemAtPath: evictable.path) + + // 102 MB used against the 100 MB quota forces one eviction. This is + // the finalized-file arrival path (BLEFileTransferHandler via + // BLEService), which knows nothing about in-flight captures — the + // store itself must protect them. + store.enforceQuota(reservingBytes: 0) + + #expect(FileManager.default.fileExists(atPath: inFlight.path)) + #expect(!FileManager.default.fileExists(atPath: evictable.path)) + } + private func expectNoSideEffects(_ recorder: Recorder) { #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.isEmpty) @@ -227,14 +383,15 @@ struct BLEFileTransferHandlerTests { _ peerID: PeerID, nickname: String, isVerified: Bool, - isConnected: Bool = true + isConnected: Bool = true, + signingPublicKey: Data? = nil ) -> BLEPeerInfo { BLEPeerInfo( peerID: peerID, nickname: nickname, isConnected: isConnected, noisePublicKey: nil, - signingPublicKey: nil, + signingPublicKey: signingPublicKey, isVerifiedNickname: isVerified, lastSeen: Date(timeIntervalSince1970: 999) ) @@ -245,10 +402,11 @@ struct BLEFileTransferHandlerTests { mimeType: String?, content: Data, ttl: UInt8 = TransportConfig.messageTTLDefault, - recipientID: Data? = nil + recipientID: Data? = nil, + fileName: String = "sample" ) throws -> BitchatPacket { let filePacket = BitchatFilePacket( - fileName: "sample", + fileName: fileName, fileSize: UInt64(content.count), mimeType: mimeType, content: content diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift index ed7c66a1..a4423ec9 100644 --- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift +++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift @@ -135,6 +135,140 @@ struct BLEFragmentAssemblyBufferTests { } } + @Test + func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((1...8).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 256)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let first = try #require(BLEFragmentHeader(packet: fragments[0])) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) + + // Not yet stalled. + let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4)) + #expect(early.isEmpty) + + // Stalled: reported once, big-endian stream ID. + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(stalled == [fragmentID]) + + // Within the retry window: not re-reported. + let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8)) + #expect(repeated.isEmpty) + + // After the retry window it is requested again. + let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17)) + #expect(retried == [fragmentID]) + } + + @Test + func newFragmentResetsStallClockAndCompletionStopsRequests() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((10...17).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 384)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) } + #expect(headers.count >= 3) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0) + // A fragment arriving at t0+4 resets the stall clock. + _ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4)) + let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(afterProgress.isEmpty) + + // Completion removes the assembly entirely. + var result: BLEFragmentAssemblyBuffer.AppendResult? + for header in headers.dropFirst(2) { + result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) + } + guard case .complete = result else { + Issue.record("Expected assembly to complete") + return + } + let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) + #expect(afterCompletion.isEmpty) + } + + @Test + func duplicateFragmentsDoNotResetStallClock() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((20...27).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 256)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let first = try #require(BLEFragmentHeader(packet: fragments[0])) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) + + // Relay duplicates of the same index arrive every few seconds; they + // bring no new data, so they must not keep the stream "fresh". + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3)) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) + + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(stalled == [fragmentID]) + } + + @Test + func overflowStalledStreamsRotateAcrossPasses() throws { + var buffer = BLEFragmentAssemblyBuffer() + let cap = RequestSyncPacket.maxFragmentIdFilterCount + let streamCount = cap + 10 + let t0 = Date(timeIntervalSince1970: 100) + + // Incomplete broadcast assemblies with staggered last-fragment times + // (stream 0 is the oldest stall). + var ids: [Data] = [] + for i in 0..> 8), UInt8(i & 0xFF)]) + ids.append(fragmentID) + let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 0, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data([0x01]) + ))) + _ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i))) + } + + // All streams are stalled; only the cap's worth (oldest first) is + // requested and rate-limited, the overflow stays eligible. + let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5) + let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt) + #expect(firstPass == Array(ids.prefix(cap))) + + // Next pass picks up exactly the overflow streams. + let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1)) + #expect(secondPass == Array(ids.suffix(streamCount - cap))) + + // Nothing left until a retry window lapses. + let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2)) + #expect(thirdPass.isEmpty) + } + + @Test + func directedAssembliesAreNeverReportedAsStalled() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragment = makeFragmentPacket( + fragmentID: Data(repeating: 0x0A, count: 8), + index: 0, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data([0x01]), + recipientID: Data(hexString: "0102030405060708") + ) + let header = try #require(BLEFragmentHeader(packet: fragment)) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(header, maxInFlightAssemblies: 8, now: t0) + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) + #expect(stalled.isEmpty) + } + private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket { BitchatPacket( type: MessageType.message.rawValue, diff --git a/bitchatTests/Services/BLEFragmentHandlerTests.swift b/bitchatTests/Services/BLEFragmentHandlerTests.swift index d3694be0..e48a74ed 100644 --- a/bitchatTests/Services/BLEFragmentHandlerTests.swift +++ b/bitchatTests/Services/BLEFragmentHandlerTests.swift @@ -40,14 +40,17 @@ struct BLEFragmentHandlerTests { } @Test - func ownFragmentIsIgnored() { + func ownFragmentIsTrackedForSyncButNotAssembled() { let recorder = Recorder() let handler = makeHandler(recorder: recorder) let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2) handler.handle(packet, from: localPeerID) - #expect(recorder.trackedPackets.isEmpty) + // Sync replay hands own fragments back after a relaunch; they must + // re-enter the sync store (so the next round's filter covers them + // and redelivery stops) without being reassembled. + #expect(recorder.trackedPackets.count == 1) #expect(recorder.appendedHeaders.isEmpty) #expect(recorder.reinjectedPackets.isEmpty) } diff --git a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift index af8c2777..40532d19 100644 --- a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift +++ b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift @@ -88,12 +88,55 @@ struct BLEIngressLinkRegistryTests { } @Test - func packetContextRejectsDirectAnnounceMismatchOnBoundLink() { + func packetContextAttributesDirectAnnounceMismatchToClaimedSender() throws { + // A rotated peer re-announces its new ID on a link still bound to the + // old one. The announce must flow through (attributed to the claimed + // sender) so signature verification can decide whether to rebind. let localPeer = PeerID(str: "0011223344556677") let boundPeer = PeerID(str: "1122334455667788") let claimedPeer = PeerID(str: "8899aabbccddeeff") let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 7) + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: claimedPeer, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ))) + + #expect(context.receivedFromPeerID == claimedPeer) + #expect(context.validationPeerID == claimedPeer) + } + + @Test + func packetContextAttributesRelayedAnnounceMismatchToBoundPeer() throws { + // Relayed announces (ttl below direct) keep relayed attribution: the + // link peer forwarded someone else's announce. + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let claimedPeer = PeerID(str: "8899aabbccddeeff") + let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 6) + + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: claimedPeer, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ))) + + #expect(context.receivedFromPeerID == boundPeer) + #expect(context.validationPeerID == claimedPeer) + } + + @Test + func packetContextRejectsRequestSyncSenderMismatchOnBoundLink() { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let claimedPeer = PeerID(str: "8899aabbccddeeff") + let packet = makeRequestSyncPacket(sender: claimedPeer) + let result = BLEIngressLinkRegistry.packetContext( for: packet, claimedSenderID: claimedPeer, @@ -105,6 +148,23 @@ struct BLEIngressLinkRegistryTests { #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer))) } + @Test + func packetContextAllowsRequestSyncFromBoundPeer() throws { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let packet = makeRequestSyncPacket(sender: boundPeer) + + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: boundPeer, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ))) + + #expect(context.receivedFromPeerID == boundPeer) + } + @Test func packetContextUsesBoundPeerForRSRValidation() throws { let localPeer = PeerID(str: "0011223344556677") @@ -158,6 +218,18 @@ private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket { ) } +private func makeRequestSyncPacket(sender: PeerID) -> BitchatPacket { + BitchatPacket( + type: MessageType.requestSync.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: 1, + payload: Data(), + signature: nil, + ttl: 0 + ) +} + private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket { BitchatPacket( type: MessageType.announce.rawValue, diff --git a/bitchatTests/Services/BLEIngressPacketGuardTests.swift b/bitchatTests/Services/BLEIngressPacketGuardTests.swift index 754bc585..0d07ec1b 100644 --- a/bitchatTests/Services/BLEIngressPacketGuardTests.swift +++ b/bitchatTests/Services/BLEIngressPacketGuardTests.swift @@ -26,13 +26,13 @@ struct BLEIngressPacketGuardTests { #expect(context.validationPeerID == sender) } - @Test("self loopback and direct announce spoofing are rejected before timestamp checks") + @Test("self loopback and request-sync spoofing are rejected before timestamp checks") func linkBindingRejectionsWinBeforeTimestampChecks() { let local = PeerID(str: "0011223344556677") let bound = PeerID(str: "1122334455667788") let claimed = PeerID(str: "8899aabbccddeeff") let selfPacket = makePacket(sender: local, timestamp: 0) - let spoofedAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7) + let spoofedRequestSync = makePacket(type: .requestSync, sender: claimed, timestamp: 0, ttl: 0) let selfResult = BLEIngressPacketGuard.evaluate( packet: selfPacket, @@ -44,7 +44,7 @@ struct BLEIngressPacketGuardTests { isValidSyncResponse: { _ in false } ) let spoofResult = BLEIngressPacketGuard.evaluate( - packet: spoofedAnnounce, + packet: spoofedRequestSync, claimedSenderID: claimed, boundPeerID: bound, localPeerID: local, @@ -57,6 +57,44 @@ struct BLEIngressPacketGuardTests { #expect(spoofResult == .failure(.directSenderMismatch(boundPeerID: bound, claimedSenderID: claimed))) } + @Test("direct announce with a mismatched binding flows through to normal validation") + func directAnnounceMismatchStillValidatesTimestamp() throws { + // Rotation heal path: the announce passes the binding check attributed + // to the claimed sender, but stays subject to timestamp validation. + let local = PeerID(str: "0011223344556677") + let bound = PeerID(str: "1122334455667788") + let claimed = PeerID(str: "8899aabbccddeeff") + let freshAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 1_000_000, ttl: 7) + let staleAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7) + + let freshContext = try #require(success(BLEIngressPacketGuard.evaluate( + packet: freshAnnounce, + claimedSenderID: claimed, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 1_000_000, + isValidSyncResponse: { _ in false } + ))) + let staleResult = BLEIngressPacketGuard.evaluate( + packet: staleAnnounce, + claimedSenderID: claimed, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 1_000_000, + isValidSyncResponse: { _ in false } + ) + + #expect(freshContext.receivedFromPeerID == claimed) + #expect(freshContext.validationPeerID == claimed) + #expect(staleResult == .failure(.timestampSkew( + peerID: claimed, + skewMs: 1_000_000, + maxSkewMs: 120_000 + ))) + } + @Test("timestamp skew outside the window is rejected") func timestampSkewIsRejected() { let local = PeerID(str: "0011223344556677") diff --git a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift index 91b54399..722f3e33 100644 --- a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift @@ -44,6 +44,33 @@ struct BLEOutboundFragmentTransferSchedulerTests { } } + @Test + func strictDirectTransferIsRejectedWithoutBeingQueuedWhenSlotsAreFull() { + var scheduler = BLEOutboundFragmentTransferScheduler() + let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active") + let strict = makeRequest( + type: MessageType.fileTransfer.rawValue, + transferId: "strict", + requireDirectPeerLink: true + ) + + guard case .start = scheduler.submit(active, maxConcurrentTransfers: 1) else { + Issue.record("Expected active transfer to reserve the only slot") + return + } + + let result = scheduler.submit(strict, maxConcurrentTransfers: 1) + + if case let .rejectedStrict(request, transferId) = result { + #expect(request.requireDirectPeerLink) + #expect(transferId == "strict") + #expect(scheduler.activeCount == 1) + #expect(scheduler.pendingCount == 0) + } else { + Issue.record("Expected strict transfer to reject instead of entering the pending queue") + } + } + @Test func submitQueuesDuplicateActiveTransferAtFront() { var scheduler = BLEOutboundFragmentTransferScheduler() @@ -62,6 +89,114 @@ struct BLEOutboundFragmentTransferSchedulerTests { } } + @Test + func resendWithoutTransferIdOfActiveBroadcastContentIsDropped() { + // Field bug: a gossip-sync replay re-fragmented a 41KB voice file + // that was still being broadcast, sending two complete fragment + // streams. The resend path has no explicit transferId; drop it while + // a covering transfer of the same bytes is in flight. + var scheduler = BLEOutboundFragmentTransferScheduler() + let original = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "app-id", payload: "voice-file") + let resend = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "voice-file") + + _ = scheduler.submit(original, maxConcurrentTransfers: 2) + let result = scheduler.submit(resend, maxConcurrentTransfers: 2) + + if case let .droppedDuplicate(_, activeTransferId) = result { + #expect(activeTransferId == "app-id") + #expect(scheduler.activeCount == 1) + #expect(scheduler.pendingCount == 0) + } else { + Issue.record("Expected the transferId-less resend of in-flight broadcast content to be dropped") + } + } + + @Test + func directedResendToAnUncoveredAudienceStillRuns() { + // The in-flight copy is directed to one peer; a resend of the same + // bytes to a different peer is not redundant. + var scheduler = BLEOutboundFragmentTransferScheduler() + let toFirstPeer = makeRequest( + type: MessageType.fileTransfer.rawValue, + transferId: "app-id", + payload: "shared-file", + directedPeer: PeerID(str: "1122334455667788") + ) + let toSecondPeer = makeRequest( + type: MessageType.fileTransfer.rawValue, + transferId: nil, + payload: "shared-file", + directedPeer: PeerID(str: "8877665544332211") + ) + + _ = scheduler.submit(toFirstPeer, maxConcurrentTransfers: 2) + let result = scheduler.submit(toSecondPeer, maxConcurrentTransfers: 2) + + if case .start = result { + #expect(scheduler.activeCount == 2) + } else { + Issue.record("Expected a resend directed at an uncovered peer to start") + } + } + + @Test + func explicitTransferIdSendIsNeverDroppedAsDuplicate() { + // App-initiated sends carry a transferId the progress UI tracks; + // only transferId-less resend paths are deduplicated. + var scheduler = BLEOutboundFragmentTransferScheduler() + let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "send-1", payload: "same-bytes") + let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "send-2", payload: "same-bytes") + + _ = scheduler.submit(first, maxConcurrentTransfers: 2) + let result = scheduler.submit(second, maxConcurrentTransfers: 2) + + if case let .start(_, reservedTransferId?) = result { + #expect(reservedTransferId == "send-2") + } else { + Issue.record("Expected an explicit-transferId send to run despite identical content") + } + } + + @Test + func duplicateOfPendingContentIsDroppedAtSubmit() { + // A duplicate must not queue behind a pending copy of the same + // content and resend the whole file when the slot frees. + var scheduler = BLEOutboundFragmentTransferScheduler() + let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active", payload: "file-a") + let queuedContent = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "waiting", payload: "file-b") + let queuedDuplicate = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "file-b") + + _ = scheduler.submit(active, maxConcurrentTransfers: 1) + _ = scheduler.submit(queuedContent, maxConcurrentTransfers: 1) + + if case .droppedDuplicate = scheduler.submit(queuedDuplicate, maxConcurrentTransfers: 1) { + // Dropped immediately: the pending "waiting" transfer covers it. + } else { + Issue.record("Expected the duplicate of pending content to be dropped at submit") + } + #expect(scheduler.pendingCount == 1) + } + + @Test + func resendAfterCompletionIsAllowed() { + // Duplicate suppression only covers in-flight transfers: a peer that + // requests the file after the stream completed must get a resend. + var scheduler = BLEOutboundFragmentTransferScheduler() + let original = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "app-id", payload: "voice-file") + let resend = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "voice-file") + + _ = scheduler.submit(original, maxConcurrentTransfers: 1) + let didActivate = scheduler.activateReservedTransfer(id: "app-id", totalFragments: 1, workItems: []) + #expect(didActivate) + #expect(scheduler.markFragmentSent(transferId: "app-id") == .complete(sentFragments: 1, totalFragments: 1)) + + if case .start = scheduler.submit(resend, maxConcurrentTransfers: 1) { + #expect(scheduler.activeCount == 1) + } else { + Issue.record("Expected a resend after completion to start") + } + } + @Test func cancelActiveTransferReturnsScheduledWorkItems() { var scheduler = BLEOutboundFragmentTransferScheduler() @@ -128,21 +263,28 @@ struct BLEOutboundFragmentTransferSchedulerTests { #expect(scheduler.pendingCount == 0) } - private func makeRequest(type: UInt8, transferId: String?) -> BLEOutboundFragmentTransferRequest { + private func makeRequest( + type: UInt8, + transferId: String?, + payload: String? = nil, + directedPeer: PeerID? = nil, + requireDirectPeerLink: Bool = false + ) -> BLEOutboundFragmentTransferRequest { BLEOutboundFragmentTransferRequest( packet: BitchatPacket( type: type, senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), recipientID: nil, timestamp: 0x0102030405, - payload: Data((transferId ?? "payload").utf8), + payload: Data((payload ?? transferId ?? "payload").utf8), signature: nil, ttl: 3 ), pad: false, maxChunk: nil, - directedPeer: nil, - transferId: transferId + directedPeer: directedPeer, + transferId: transferId, + requireDirectPeerLink: requireDirectPeerLink ) } } diff --git a/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift b/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift index 9d10e218..e57d0140 100644 --- a/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift +++ b/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift @@ -46,6 +46,33 @@ struct BLEOutboundLinkPlannerTests { ) #expect(plan.fragmentChunkSize == BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: smallestLimit)) + #expect(plan.selectedLinks.peripheralIDs == Set(["p1"])) + #expect(plan.selectedLinks.centralIDs == Set(["c1"])) + #expect(!plan.shouldSpoolDirectedPacket) + } + + @Test + func oversizedDirectedCourierDoesNotUseUnrelatedPeersMTUAsHandoffSuccess() { + let recipient = PeerID(str: "1122334455667788") + let unrelated = PeerID(str: "8877665544332211") + let packet = makePacket(type: .courierEnvelope, recipient: recipient) + + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: 512, + peripheralIDs: ["unrelated-link"], + peripheralWriteLimits: [64], + centralIDs: [], + centralNotifyLimits: [], + ingressRecord: nil, + excludedLinks: [], + peripheralPeerBindings: ["unrelated-link": unrelated], + directedOnlyPeer: recipient, + requireDirectPeerLink: true + ) + + #expect(plan.directedPeerHint == recipient) + #expect(plan.fragmentChunkSize == nil) #expect(plan.selectedLinks.peripheralIDs.isEmpty) #expect(plan.selectedLinks.centralIDs.isEmpty) #expect(!plan.shouldSpoolDirectedPacket) @@ -93,6 +120,28 @@ struct BLEOutboundLinkPlannerTests { #expect(plan.shouldSpoolDirectedPacket) } + @Test + func bridgeCourierPacketDoesNotTurnProcessLocalSpoolIntoHandoffSuccess() { + let recipient = PeerID(str: "1122334455667788") + let packet = makePacket(type: .courierEnvelope, recipient: recipient) + + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: 32, + peripheralIDs: [], + peripheralWriteLimits: [], + centralIDs: [], + centralNotifyLimits: [], + ingressRecord: nil, + excludedLinks: [], + directedOnlyPeer: recipient + ) + + #expect(plan.selectedLinks.peripheralIDs.isEmpty) + #expect(plan.selectedLinks.centralIDs.isEmpty) + #expect(!plan.shouldSpoolDirectedPacket) + } + @Test func publicBroadcastDoesNotSpoolWhenNoLinksAreAvailable() { let packet = makePacket(type: .message) @@ -113,6 +162,45 @@ struct BLEOutboundLinkPlannerTests { #expect(!plan.shouldSpoolDirectedPacket) } + @Test + func directAnnounceBypassesDuplicateLinkCollapseButRelayedAnnounceDoesNot() { + let peer = PeerID(str: "1122334455667788") + let bindings: [String: PeerID] = ["p1": peer, "p2": peer] + + let direct = BLEOutboundLinkPlanner.plan( + packet: makePacket(type: .announce), + dataCount: 32, + peripheralIDs: ["p1", "p2"], + peripheralWriteLimits: [128, 128], + centralIDs: [], + centralNotifyLimits: [], + ingressRecord: nil, + excludedLinks: [], + peripheralPeerBindings: bindings, + preferredPeripheralPerPeer: [peer: "p1"], + directedOnlyPeer: nil + ) + // A direct announce is the link-binding packet: it must reach every + // live link, including a peer's duplicate connections. + #expect(direct.selectedLinks.peripheralIDs == Set(["p1", "p2"])) + + let relayed = BLEOutboundLinkPlanner.plan( + packet: makePacket(type: .announce, ttl: TransportConfig.messageTTLDefault - 1), + dataCount: 32, + peripheralIDs: ["p1", "p2"], + peripheralWriteLimits: [128, 128], + centralIDs: [], + centralNotifyLimits: [], + ingressRecord: nil, + excludedLinks: [], + peripheralPeerBindings: bindings, + preferredPeripheralPerPeer: [peer: "p1"], + directedOnlyPeer: nil + ) + // Relayed announces keep the per-peer collapse (relay hygiene). + #expect(relayed.selectedLinks.peripheralIDs == Set(["p1"])) + } + @Test func minimumLinkLimitUsesTheSmallestPresentRoleLimit() { #expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [80, 120], centralNotifyLimits: []) == 80) @@ -123,7 +211,8 @@ struct BLEOutboundLinkPlannerTests { private func makePacket( type: MessageType, sender: PeerID = PeerID(str: "8877665544332211"), - recipient: PeerID? = nil + recipient: PeerID? = nil, + ttl: UInt8 = TransportConfig.messageTTLDefault ) -> BitchatPacket { BitchatPacket( type: type.rawValue, @@ -132,7 +221,7 @@ struct BLEOutboundLinkPlannerTests { timestamp: 1234, payload: Data([0x01, 0x02]), signature: nil, - ttl: TransportConfig.messageTTLDefault + ttl: ttl ) } } diff --git a/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift b/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift index 4dced392..f440ba3c 100644 --- a/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift +++ b/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift @@ -67,4 +67,19 @@ struct BLEOutboundNotificationBufferTests { #expect(buffer.isEmpty) } + + @Test + func unsubscribeRemovesTargetSpecificCiphertextOnlyForThatCentral() { + var buffer = BLEOutboundNotificationBuffer() + _ = buffer.enqueue(data: Data([1]), targets: ["gone"], capCount: 4) + _ = buffer.enqueue(data: Data([2]), targets: ["gone", "live"], capCount: 4) + _ = buffer.enqueue(data: Data([3]), targets: nil, capCount: 4) + + buffer.removeTarget { $0 == "gone" } + let remaining = buffer.takeAll() + + #expect(remaining.map(\.data) == [Data([2]), Data([3])]) + #expect(remaining[0].targets == ["live"]) + #expect(remaining[1].targets == nil) + } } diff --git a/bitchatTests/Services/BLEOutboundWriteBufferTests.swift b/bitchatTests/Services/BLEOutboundWriteBufferTests.swift index f8e64080..801de777 100644 --- a/bitchatTests/Services/BLEOutboundWriteBufferTests.swift +++ b/bitchatTests/Services/BLEOutboundWriteBufferTests.swift @@ -72,4 +72,38 @@ struct BLEOutboundWriteBufferTests { #expect(buffer.peripheralIDs.isEmpty) } + + @Test + func acceptanceReportsWhenNewLowPriorityWriteIsTrimmed() { + var buffer = BLEOutboundWriteBuffer() + let peerID = "peer-1" + _ = buffer.enqueue( + data: Data(repeating: 0x01, count: 8), + for: peerID, + priority: .high, + capBytes: 8 + ) + + let attempt = buffer.enqueueReportingAcceptance( + data: Data(repeating: 0x02, count: 8), + for: peerID, + priority: .low, + capBytes: 8 + ) + + #expect(!attempt.accepted) + #expect(buffer.takeAll(for: peerID).compactMap(\.data.first) == [0x01]) + } + + @Test + func disconnectDiscardRemovesOnlyThatPeripheralQueue() { + var buffer = BLEOutboundWriteBuffer() + _ = buffer.enqueue(data: Data([1]), for: "gone", priority: .high, capBytes: 100) + _ = buffer.enqueue(data: Data([2]), for: "live", priority: .high, capBytes: 100) + + buffer.discardAll(for: "gone") + + #expect(buffer.takeAll(for: "gone").isEmpty) + #expect(buffer.takeAll(for: "live").map(\.data) == [Data([2])]) + } } diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift index 056d3ca9..4e9b027b 100644 --- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift +++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift @@ -83,7 +83,13 @@ struct BLEPublicMessageHandlerTests { #expect(recorder.deliveries.first?.nickname == "Alice") #expect(recorder.deliveries.first?.content == "hello mesh") #expect(recorder.deliveries.first?.timestamp == now) - #expect(recorder.deliveries.first?.messageID == nil) + // No message ID on the wire: the handler derives the stable one + // every device agrees on for the same sender/timestamp/content. + #expect(recorder.deliveries.first?.messageID == MeshMessageIdentity.stableID( + senderIDHex: remotePeerID.id, + timestampMs: timestamp(now), + content: "hello mesh" + )) } @Test @@ -100,11 +106,11 @@ struct BLEPublicMessageHandlerTests { @Test func staleBroadcastIsDropped() { - let now = Date(timeIntervalSince1970: 1_000) + let now = Date(timeIntervalSince1970: 1_000_000) let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] let handler = makeHandler(recorder: recorder, now: now) - let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) + let staleTimestamp = UInt64((now.timeIntervalSince1970 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 1000) let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp) handler.handle(packet, from: remotePeerID) diff --git a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift index fc725194..dc6d7271 100644 --- a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift +++ b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift @@ -36,11 +36,13 @@ struct BLEPublicMessagePolicyTests { @Test func staleBroadcastIsRejectedWithAge() { - let now = Date(timeIntervalSince1970: 1_000) + // The acceptance window matches the gossip public-history window. + let staleAge = TransportConfig.syncPublicMessageMaxAgeSeconds + 1 + let now = Date(timeIntervalSince1970: 1_000_000) let sender = PeerID(str: "8877665544332211") let packet = makePacket( sender: sender, - timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000), + timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000), recipientID: nil ) @@ -51,7 +53,7 @@ struct BLEPublicMessagePolicyTests { now: now ) - #expect(decision == .reject(.staleBroadcast(ageSeconds: 901))) + #expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge))) } @Test diff --git a/bitchatTests/Services/BLERecentPeripheralCacheTests.swift b/bitchatTests/Services/BLERecentPeripheralCacheTests.swift new file mode 100644 index 00000000..15eb057e --- /dev/null +++ b/bitchatTests/Services/BLERecentPeripheralCacheTests.swift @@ -0,0 +1,99 @@ +// +// BLERecentPeripheralCacheTests.swift +// bitchatTests +// +// Eviction, expiry, and reconnect-target selection for the background +// wake-on-proximity peripheral cache. +// + +import Testing +import Foundation +@testable import bitchat + +struct BLERecentPeripheralCacheTests { + + private let base = Date(timeIntervalSince1970: 1_700_000_000) + + private func makeCache(capacity: Int = 4, maxAge: TimeInterval = 900) -> BLERecentPeripheralCache { + BLERecentPeripheralCache(capacity: capacity, maxAge: maxAge) + } + + @Test + func recordUpsertsByPeripheralID() { + let cache = makeCache() + cache.record("p1", peripheralID: "A", at: base) + cache.record("p1-updated", peripheralID: "A", at: base.addingTimeInterval(10)) + + #expect(cache.count == 1) + let targets = cache.reconnectTargets(now: base.addingTimeInterval(11), limit: 10) { _ in false } + #expect(targets.map(\.peripheral) == ["p1-updated"]) + } + + @Test + func overCapacityEvictsStalestEntry() { + let cache = makeCache(capacity: 2) + cache.record("p1", peripheralID: "A", at: base) + cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1)) + cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(2)) + + #expect(cache.count == 2) + let targets = cache.reconnectTargets(now: base.addingTimeInterval(3), limit: 10) { _ in false } + #expect(targets.map(\.peripheralID) == ["C", "B"]) + } + + @Test + func refreshingAnEntryProtectsItFromEviction() { + let cache = makeCache(capacity: 2) + cache.record("p1", peripheralID: "A", at: base) + cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1)) + // A becomes the freshest again; adding C must evict B, not A + cache.record("p1", peripheralID: "A", at: base.addingTimeInterval(2)) + cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(3)) + + let targets = cache.reconnectTargets(now: base.addingTimeInterval(4), limit: 10) { _ in false } + #expect(targets.map(\.peripheralID) == ["C", "A"]) + } + + @Test + func expiredEntriesArePruned() { + let cache = makeCache(maxAge: 100) + cache.record("p1", peripheralID: "A", at: base) + cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(50)) + + let targets = cache.reconnectTargets(now: base.addingTimeInterval(120), limit: 10) { _ in false } + #expect(targets.map(\.peripheralID) == ["B"]) + #expect(cache.count == 1) + } + + @Test + func targetsAreFreshestFirstAndCappedAtLimit() { + let cache = makeCache(capacity: 8) + for (index, id) in ["A", "B", "C", "D"].enumerated() { + cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index))) + } + + let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { _ in false } + #expect(targets.map(\.peripheralID) == ["D", "C"]) + } + + @Test + func excludedPeripheralsAreSkippedWithoutConsumingTheLimit() { + let cache = makeCache(capacity: 8) + for (index, id) in ["A", "B", "C"].enumerated() { + cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index))) + } + + // C (freshest) is already connected; the two slots go to B and A + let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { $0 == "C" } + #expect(targets.map(\.peripheralID) == ["B", "A"]) + } + + @Test + func nonPositiveLimitReturnsNothing() { + let cache = makeCache() + cache.record("p1", peripheralID: "A", at: base) + + #expect(cache.reconnectTargets(now: base, limit: 0) { _ in false }.isEmpty) + #expect(cache.reconnectTargets(now: base, limit: -3) { _ in false }.isEmpty) + } +} diff --git a/bitchatTests/Services/BLERedundantLinkPolicyTests.swift b/bitchatTests/Services/BLERedundantLinkPolicyTests.swift new file mode 100644 index 00000000..db23a5e8 --- /dev/null +++ b/bitchatTests/Services/BLERedundantLinkPolicyTests.swift @@ -0,0 +1,134 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLERedundantLinkPolicyTests { + private let peer = PeerID(str: "1122334455667788") + private let otherPeer = PeerID(str: "8877665544332211") + + private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true) -> BLERedundantLinkPolicy.PeripheralLink { + BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable) + } + + @Test + func singleBoundLinkNeedsNoConsolidation() { + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p1", + mostRecentlyBoundUUID: "p1", + links: [link("p1", peer), link("p2", otherPeer)], + peerID: peer + ) + #expect(kept == nil) + } + + @Test + func ingressLinkOfVerifiedAnnounceWinsOverReverseMap() { + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-ingress", + mostRecentlyBoundUUID: "p-reverse", + links: [link("p-ingress", peer), link("p-reverse", peer), link("p-stale", peer)], + peerID: peer + ) + #expect(kept == "p-ingress") + } + + @Test + func centralIngressFallsBackToMostRecentlyBoundLink() { + // The announce arrived on the central link (a write), so no ingress + // peripheral exists; the peer's reverse-mapped link survives. + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: nil, + mostRecentlyBoundUUID: "p-reverse", + links: [link("p-reverse", peer), link("p-stale", peer)], + peerID: peer + ) + #expect(kept == "p-reverse") + } + + @Test + func noLiveCandidateAmongBoundLinksRetiresNothing() { + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: nil, + mostRecentlyBoundUUID: "p-disconnected", + links: [link("p-disconnected", peer, connected: false), link("p1", peer), link("p2", peer)], + peerID: peer + ) + #expect(kept == nil) + } + + @Test + func characteristicLessAnchorLosesToWritableDuplicate() { + // The ingress link is mid-service-rediscovery (no characteristic): + // keeping it and cancelling the writable duplicate would strand + // outbound traffic on the central link, so the writable + // reverse-mapped link wins. + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-charless", + mostRecentlyBoundUUID: "p-writable", + links: [link("p-charless", peer, writable: false), link("p-writable", peer)], + peerID: peer + ) + #expect(kept == "p-writable") + } + + @Test + func writableDuplicateThatIsNoAnchorDefersConsolidation() { + // Both anchors are characteristic-less but a writable third link + // exists: never keep a charless link over it — wait for a later + // announce instead of guessing which link to keep. + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-charless-1", + mostRecentlyBoundUUID: "p-charless-2", + links: [ + link("p-charless-1", peer, writable: false), + link("p-charless-2", peer, writable: false), + link("p-writable", peer) + ], + peerID: peer + ) + #expect(kept == nil) + } + + @Test + func allCharacteristicLessDuplicatesStillConsolidateOnIngress() { + // No writable link exists at all (all mid-rediscovery): the ingress + // anchor still consolidates — no writable duplicate is at risk. + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-ingress", + mostRecentlyBoundUUID: "p-stale", + links: [link("p-ingress", peer, writable: false), link("p-stale", peer, writable: false)], + peerID: peer + ) + #expect(kept == "p-ingress") + } + + @Test + func retirementSparesKeptLinkUnboundLinksAndOtherPeers() { + let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire( + links: [ + link("p-kept", peer), + link("p-dup-1", peer), + link("p-dup-2", peer), + link("p-gone", peer, connected: false), + link("p-unbound", nil), + link("p-other", otherPeer) + ], + peerID: peer, + keeping: "p-kept" + ) + #expect(Set(retiring) == Set(["p-dup-1", "p-dup-2"])) + } + + @Test + func rotationCleanupWithNoSurvivorRetiresEveryBoundLink() { + // Rotated-away identity: the rebound link now belongs to the new ID, + // so every link still bound to the old ID is a stale duplicate. + let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire( + links: [link("p-stale-1", peer), link("p-stale-2", peer)], + peerID: peer, + keeping: "" + ) + #expect(Set(retiring) == Set(["p-stale-1", "p-stale-2"])) + } +} diff --git a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift index e6bfce05..7e580098 100644 --- a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift +++ b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift @@ -112,6 +112,36 @@ struct BLERouteForwardingPolicyTests { #expect(plan.nextHop == nil) } + @Test("REQUEST_SYNC is never route-forwarded even with a route and TTL headroom") + func requestSyncNeverRouteForwarded() { + let previous = peer("1111111111111111") + let local = peer("2222222222222222") + let nextHop = peer("3333333333333333") + let destination = peer("4444444444444444") + var packet = makePacket( + sender: previous, + recipient: destination, + ttl: 7, + route: [routeData(local), routeData(nextHop)] + ) + packet = BitchatPacket( + type: MessageType.requestSync.rawValue, + senderID: packet.senderID, + recipientID: packet.recipientID, + timestamp: packet.timestamp, + payload: packet.payload, + signature: nil, + ttl: packet.ttl, + route: packet.route + ) + + let plan = forwardingPlan(packet, local: local, connected: [nextHop]) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.forwardPacket == nil) + #expect(plan.nextHop == nil) + } + private func forwardingPlan( _ packet: BitchatPacket, local: PeerID, diff --git a/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift new file mode 100644 index 00000000..5d3315f6 --- /dev/null +++ b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift @@ -0,0 +1,98 @@ +// +// BLESourceRouteFailureCacheTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct BLESourceRouteFailureCacheTests { + private let recipient = PeerID(str: "0102030405060708") + private let config = BLESourceRouteFailureCache.Config( + confirmationWindowSeconds: 10, + suppressionSeconds: 60 + ) + + private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool { + cache.shouldAttemptRoute(to: recipient, now: date) + } + + @Test func allowsRoutingByDefault() { + var cache = BLESourceRouteFailureCache(config: config) + #expect(attempts(&cache, at: Date())) + } + + @Test func unconfirmedRoutedSendSuppressesRouting() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Inside the confirmation window: keep routing. + #expect(attempts(&cache, at: t0.addingTimeInterval(5))) + // Past the window with no inbound traffic: route failed, flood. + #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) + // Still suppressed for the suppression TTL. + #expect(!attempts(&cache, at: t0.addingTimeInterval(40))) + // Suppression lapses: routing may be attempted again. + #expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61))) + } + + @Test func inboundActivityConfirmsPendingSend() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.noteInboundActivity(from: recipient) + // Confirmed: no suppression even long after the window. + #expect(attempts(&cache, at: t0.addingTimeInterval(30))) + } + + @Test func inboundActivityDoesNotLiftActiveSuppression() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Trip the failure → suppression starts at t0+15. + #expect(!attempts(&cache, at: t0.addingTimeInterval(15))) + // Inbound traffic may have arrived via flood; suppression holds. + cache.noteInboundActivity(from: recipient) + #expect(!attempts(&cache, at: t0.addingTimeInterval(20))) + #expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61))) + } + + @Test func backToBackSendsShareOneDeadline() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8)) + // Deadline runs from the first unconfirmed send. + #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) + } + + @Test func pruneDropsExpiredEntries() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Past confirmation + suppression: the entry can no longer matter. + cache.prune(now: t0.addingTimeInterval(75)) + #expect(attempts(&cache, at: t0.addingTimeInterval(76))) + } + + @Test func pruneKeepsEntriesThatStillMatter() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.prune(now: t0.addingTimeInterval(30)) + // The unconverted pending entry survives pruning and still converts + // into a suppression on the next routing decision. + #expect(!attempts(&cache, at: t0.addingTimeInterval(31))) + } +} diff --git a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift new file mode 100644 index 00000000..c762a0c7 --- /dev/null +++ b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift @@ -0,0 +1,98 @@ +// +// BLESourceRouteOriginationPolicyTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct BLESourceRouteOriginationPolicyTests { + private let localPeerIDData = Data(hexString: "0102030405060708")! + private let recipient = PeerID(str: "1112131415161718") + private let hop = Data(hexString: "2122232425262728")! + + private func makePacket( + senderID: Data? = nil, + recipientID: Data? = Data(hexString: "1112131415161718"), + ttl: UInt8 = 7 + ) -> BitchatPacket { + BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: senderID ?? localPeerIDData, + recipientID: recipientID, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01]), + signature: nil, + ttl: ttl + ) + } + + private func route( + packet: BitchatPacket, + isRecipientConnected: Bool = false, + shouldAttemptRoute: Bool = true, + computedRoute: [Data]? = nil + ) -> [Data]? { + BLESourceRouteOriginationPolicy.route( + for: packet, + to: recipient, + localPeerIDData: localPeerIDData, + isRecipientConnected: { _ in isRecipientConnected }, + shouldAttemptRoute: { _ in shouldAttemptRoute }, + computeRoute: { _ in computedRoute ?? [self.hop] } + ) + } + + @Test func routesWhenAllGatesPass() { + #expect(route(packet: makePacket()) == [hop]) + } + + @Test func relayedPacketNeverGetsRoute() { + let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011")) + #expect(route(packet: relayed) == nil) + } + + @Test func broadcastRecipientNeverGetsRoute() { + let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8)) + #expect(route(packet: broadcast) == nil) + let noRecipient = makePacket(recipientID: nil) + #expect(route(packet: noRecipient) == nil) + } + + @Test func linkLocalTTLNeverGetsRoute() { + // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops. + #expect(route(packet: makePacket(ttl: 0)) == nil) + #expect(route(packet: makePacket(ttl: 1)) == nil) + } + + @Test func directlyConnectedRecipientNeverGetsRoute() { + #expect(route(packet: makePacket(), isRecipientConnected: true) == nil) + } + + @Test func suppressedRecipientFallsBackToFlood() { + #expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil) + } + + @Test func missingOrEmptyRouteFallsBackToFlood() { + var sawComputeRoute = false + let result = BLESourceRouteOriginationPolicy.route( + for: makePacket(), + to: recipient, + localPeerIDData: localPeerIDData, + isRecipientConnected: { _ in false }, + shouldAttemptRoute: { _ in true }, + computeRoute: { _ in + sawComputeRoute = true + return nil + } + ) + #expect(result == nil) + #expect(sawComputeRoute) + #expect(route(packet: makePacket(), computedRoute: []) == nil) + } +} diff --git a/bitchatTests/Services/BoardAlertsModelTests.swift b/bitchatTests/Services/BoardAlertsModelTests.swift new file mode 100644 index 00000000..df891cea --- /dev/null +++ b/bitchatTests/Services/BoardAlertsModelTests.swift @@ -0,0 +1,215 @@ +// +// BoardAlertsModelTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation +import Testing +@testable import bitchat + +@MainActor +struct BoardAlertsModelTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + private let ownKey = Data(repeating: 7, count: 32) + + private final class Harness { + var lines: [(content: String, geohash: String)] = [] + var pendingFlushes: [@MainActor () -> Void] = [] + + @MainActor + func flushAll() { + let flushes = pendingFlushes + pendingFlushes = [] + for flush in flushes { flush() } + } + } + + private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel { + let fixedNow = now ?? baseDate + return BoardAlertsModel( + arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey }, + emitSystemLine: { content, geohash in + harness.lines.append((content, geohash)) + }, + now: { fixedNow }, + scheduleFlush: { flush in + harness.pendingFlushes.append(flush) + } + ) + ) + } + + private func makePost( + content: String = "hello", + geohash: String = "9q8yy", + nickname: String = "alice", + createdAt: UInt64? = nil, + urgent: Bool = false, + authorKey: Data = Data(repeating: 1, count: 32), + postID: Data? = nil + ) -> BoardPostPacket { + BoardPostPacket( + postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }), + geohash: geohash, + content: content, + authorSigningKey: authorKey, + authorNickname: nickname, + createdAt: createdAt ?? baseMs, + expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, + flags: urgent ? BoardPostPacket.urgentFlag : 0, + signature: Data(repeating: 2, count: 64) + ) + } + + @Test + func ownPosts_neverBadgeOrAlert() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(urgent: true, authorKey: ownKey)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(harness.lines.isEmpty) + } + + @Test + func routinePost_badgesWithoutChatLine() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(geohash: "")) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "") == 1) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(harness.lines.isEmpty) + } + + @Test + func urgentRecentPost_emitsLineInMatchingScope() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true)) + #expect(harness.lines.isEmpty) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].geohash == "9q8yy") + #expect(harness.lines[0].content.contains("road closed")) + #expect(harness.lines[0].content.contains("@alice")) + } + + @Test + func urgentBackfilledPost_badgesOnly() { + let harness = Harness() + let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120) + let model = makeModel(harness: harness, now: arrivalTime) + + model.handleArrival(makePost(createdAt: baseMs, urgent: true)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 1) + #expect(harness.lines.isEmpty) + } + + @Test + func simultaneousUrgentPosts_collapseIntoOneLine() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "one", urgent: true)) + model.handleArrival(makePost(content: "two", urgent: true)) + model.handleArrival(makePost(content: "three", urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].content.contains("3")) + #expect(harness.pendingFlushes.isEmpty) + } + + @Test + func urgentPostsInDifferentScopes_alertEachScope() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true)) + model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 2) + #expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""]) + } + + @Test + func duplicateArrival_isHandledOnce() { + let harness = Harness() + let model = makeModel(harness: harness) + let id = Data(repeating: 3, count: 16) + + model.handleArrival(makePost(urgent: true, postID: id)) + model.handleArrival(makePost(urgent: true, postID: id)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 1) + #expect(harness.lines.count == 1) + #expect(!harness.lines[0].content.contains("2")) + } + + @Test + func markSeen_clearsOnlyVisibleScopes() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(geohash: "")) + model.handleArrival(makePost(geohash: "9q8yy")) + model.handleArrival(makePost(geohash: "u4pruyd")) + + // Opening the sheet on mesh + 9q8yy must not eat the badge for the + // never-shown u4pruyd channel. + model.markSeen(forScopes: ["", "9q8yy"]) + + #expect(model.unseenCount(forGeohash: "") == 0) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(model.unseenCount(forGeohash: "u4pruyd") == 1) + } + + @Test + func reset_dropsPendingUrgentLinesAndBadges() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "pre-wipe secret", urgent: true)) + #expect(harness.pendingFlushes.count == 1) + + // Panic wipe lands before the collapse flush fires. + model.reset() + harness.flushAll() + + #expect(harness.lines.isEmpty) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + } + + @Test + func longUrgentContent_isTruncatedInLine() { + let harness = Harness() + let model = makeModel(harness: harness) + let long = String(repeating: "a", count: 400) + + model.handleArrival(makePost(content: long, urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].content.count < 200) + #expect(harness.lines[0].content.contains("…")) + } +} diff --git a/bitchatTests/Services/BoardStoreTests.swift b/bitchatTests/Services/BoardStoreTests.swift new file mode 100644 index 00000000..d501de39 --- /dev/null +++ b/bitchatTests/Services/BoardStoreTests.swift @@ -0,0 +1,385 @@ +// +// BoardStoreTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +struct BoardStoreTests { + + private final class MutableClock: @unchecked Sendable { + var now: Date + init(now: Date) { self.now = now } + } + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + + private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore { + BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now }) + } + + private func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("board-store-\(UUID().uuidString).json") + } + + private func makePost( + author: Curve25519.Signing.PrivateKey, + geohash: String = "9q8yy", + content: String = "note", + createdAt: UInt64, + lifetimeMs: UInt64 = 24 * 60 * 60 * 1000 + ) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) { + let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) + let key = author.publicKey.rawRepresentation + let expiresAt = createdAt + lifetimeMs + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: key, + authorNickname: "tester", + createdAt: createdAt, + expiresAt: expiresAt, + flags: 0 + ) + let post = BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: key, + authorNickname: "tester", + createdAt: createdAt, + expiresAt: expiresAt, + flags: 0, + signature: try author.signature(for: signingBytes) + ) + let wire = BoardWire.post(post) + return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post) + } + + private func makeTombstone( + for post: BoardPostPacket, + author: Curve25519.Signing.PrivateKey, + deletedAt: UInt64, + claimKey: Data? = nil + ) throws -> (wire: BoardWire, packet: BitchatPacket) { + let tombstone = BoardTombstonePacket( + postID: post.postID, + authorSigningKey: claimKey ?? author.publicKey.rawRepresentation, + deletedAt: deletedAt, + signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)) + ) + let wire = BoardWire.tombstone(tombstone) + return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt)) + } + + private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket { + BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }), + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 7 + ) + } + + // MARK: - Ingest basics + + @Test func ingestStoresAndDeduplicates() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + #expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + #expect(store.posts(forGeohash: "").isEmpty) + #expect(store.syncCandidates().count == 1) + } + + @Test func rejectsAlreadyExpiredPost() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + // MARK: - Receive-time timestamp policy + + @Test func rejectsPostCreatedBeyondClockSkew() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + @Test func acceptsPostCreatedWithinClockSkew() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + } + + @Test func rejectsPostExpiringTooFarInTheFuture() throws { + // Builds the wire directly, bypassing the decoder's span check, to + // exercise the ingest-level expiresAt bound on its own. + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + // MARK: - Caps and eviction + + @Test func perAuthorCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + var oldestID: Data? + for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) { + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) + if index == 0 { oldestID = entry.post.postID } + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + } + + let posts = store.posts(forGeohash: "9q8yy") + #expect(posts.count == BoardStore.Limits.maxPostsPerAuthor) + #expect(!posts.contains { $0.postID == oldestID }) + } + + @Test func globalCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + + var oldestID: Data? + var author = Curve25519.Signing.PrivateKey() + for index in 0..<(BoardStore.Limits.maxPosts + 1) { + if index % BoardStore.Limits.maxPostsPerAuthor == 0 { + author = Curve25519.Signing.PrivateKey() + } + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) + if index == 0 { oldestID = entry.post.postID } + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + } + + let posts = store.posts(forGeohash: "9q8yy") + #expect(posts.count == BoardStore.Limits.maxPosts) + #expect(!posts.contains { $0.postID == oldestID }) + } + + // MARK: - Expiry sweep + + @Test func expiredPostsAreSwept() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000) + let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000) + store.ingest(shortLived.wire, packet: shortLived.packet) + store.ingest(longLived.wire, packet: longLived.packet) + #expect(store.posts(forGeohash: "9q8yy").count == 2) + + clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later + let remaining = store.posts(forGeohash: "9q8yy") + #expect(remaining.count == 1) + #expect(remaining.first?.postID == longLived.post.postID) + #expect(store.syncCandidates().count == 1) + } + + // MARK: - Tombstones + + @Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000) + store.ingest(entry.wire, packet: entry.packet) + + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + + // Post is gone, tombstone still syncs so the delete propagates. + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + #expect(store.syncCandidates().count == 1) + + // Replayed copy of the deleted post is refused. + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + + // After the post's original expiry the tombstone is dropped too. + clock.now = baseDate.addingTimeInterval(25 * 60 * 60) + #expect(store.syncCandidates().isEmpty) + } + + @Test func tombstoneFromWrongKeyIsRejected() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let attacker = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + store.ingest(entry.wire, packet: entry.packet) + + // Attacker signs with their own key (self-consistent wire, so it + // passes signature verification) but targets the victim's post. + let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000) + #expect(store.ingest(forged.wire, packet: forged.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + } + + @Test func tombstoneArrivingBeforePostSuppressesIt() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + @Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) // never ingested + + // Attacker-chosen far-future deletedAt must not extend retention. + let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000 + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + #expect(store.syncCandidates().count == 1) + + // No post can outlive 7 days from receipt, so neither may an orphan + // tombstone (plus skew allowance). + clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60) + #expect(store.syncCandidates().isEmpty) + } + + @Test func orphanTombstonePerAuthorCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = [] + for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) { + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) + unseenPosts.append(entry) + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + } + + #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor) + // The oldest orphan was evicted, so its post is no longer suppressed… + #expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted) + // …while the surviving orphans still suppress theirs. + #expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected) + } + + @Test func orphanTombstoneGlobalCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + + var author = Curve25519.Signing.PrivateKey() + for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) { + if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 { + author = Curve25519.Signing.PrivateKey() + } + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + } + + #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones) + } + + @Test func matchedTombstonesAreExemptFromOrphanCaps() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + // Post-then-delete more times than the per-author orphan cap; every + // tombstone matched a live post, so none may be evicted. + let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2 + for index in 0.. +// + +import BitFoundation +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +@Suite("Courier over the bridge") +@MainActor +struct BridgeCourierServiceTests { + /// Closure-injected harness around `BridgeCourierService`. + @MainActor + private final class Fixture { + var bridgeOn = true + var relaysConnected = true + var myKey: Data? = Fixture.randomKey() + var localPeers: [(peerID: PeerID, noiseKey: Data)] = [] + var held: [CourierEnvelope] = [] + var sealResult: CourierEnvelope? + var deliverResult = true + var openResult = true + /// nil leaves the simulated relay confirmation in flight. + var automaticPublishResult: Bool? = true + + private(set) var publishedEvents: [NostrEvent] = [] + private(set) var openedSubscriptions: [[String]] = [] + private(set) var closedSubscriptions = 0 + private(set) var openedEnvelopes: [CourierEnvelope] = [] + private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = [] + private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = [] + private(set) var heldCooldowns: [TimeInterval] = [] + private(set) var markedHeldEnvelopes: [CourierEnvelope] = [] + private(set) var scheduledTimers: [(delay: TimeInterval, fire: @MainActor () -> Void)] = [] + private(set) var pendingPublishCompletions: [@MainActor (Bool) -> Void] = [] + + let service: BridgeCourierService + + init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) { + service = BridgeCourierService(now: now, dedupStore: dedupStore) + service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.publishEvent = { [weak self] event, completion in + guard let self else { + completion(false) + return + } + self.publishedEvents.append(event) + if let result = self.automaticPublishResult { + completion(result) + } else { + self.pendingPublishCompletions.append(completion) + } + } + service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) } + service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 } + service.myNoiseKey = { [weak self] in self?.myKey } + service.localVerifiedPeers = { [weak self] in self?.localPeers ?? [] } + service.sealEnvelope = { [weak self] content, messageID, key in + self?.sealRequests.append((content, messageID, key)) + return self?.sealResult + } + service.openEnvelope = { [weak self] envelope in + self?.openedEnvelopes.append(envelope) + return self?.openResult ?? false + } + service.deliverToPeer = { [weak self] envelope, peer in + self?.delivered.append((envelope, peer)) + return self?.deliverResult ?? false + } + service.heldEnvelopes = { [weak self] cooldown in + self?.heldCooldowns.append(cooldown) + return self?.held ?? [] + } + service.markHeldEnvelopePublished = { [weak self] envelope in + self?.markedHeldEnvelopes.append(envelope) + } + service.scheduleTimer = { [weak self] delay, fire in + self?.scheduledTimers.append((delay, fire)) + } + } + + func resolveNextPublish(_ succeeded: Bool) { + guard !pendingPublishCompletions.isEmpty else { return } + pendingPublishCompletions.removeFirst()(succeeded) + } + + static func randomKey() -> Data { + Data((0..<32).map { _ in UInt8.random(in: 0...255) }) + } + } + + private func makeEnvelope(recipientKey: Data, ciphertext: Data = Data(repeating: 7, count: 64)) -> CourierEnvelope { + CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientKey, + epochDay: CourierEnvelope.epochDay(for: Date()) + ), + expiry: UInt64((Date().timeIntervalSince1970 + 3600) * 1000), + ciphertext: ciphertext, + copies: 1 + ) + } + + private func makeDropEvent(for envelope: CourierEnvelope) throws -> NostrEvent { + let encoded = try #require(envelope.encode()) + let identity = try #require(BridgeCourierService.makeThrowawayIdentity()) + return try NostrProtocol.createCourierDropEvent( + envelope: encoded, + recipientTagHex: envelope.recipientTag.hexEncodedString(), + expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000), + senderIdentity: identity + ) + } + + // MARK: - Sender role + + @Test func depositSealsAndPublishesOnce() throws { + let fixture = Fixture() + let recipientKey = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: recipientKey) + let messageID = UUID().uuidString + + fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) + fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) + + #expect(fixture.sealRequests.count == 1) + #expect(fixture.publishedEvents.count == 1) + let event = try #require(fixture.publishedEvents.first) + #expect(event.kind == NostrProtocol.EventKind.courierDrop.rawValue) + #expect(event.isValidSignature()) + #expect(event.tags.contains { $0.count >= 2 && $0[0] == "x" && $0[1] == fixture.sealResult?.recipientTag.hexEncodedString() }) + #expect(event.tags.contains { $0.count >= 2 && $0[0] == "expiration" }) + } + + @Test func depositRequiresBridgeToggle() { + let fixture = Fixture() + fixture.bridgeOn = false + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + + fixture.service.depositDrop(content: "hi", messageID: UUID().uuidString, recipientNoiseKey: key) + + #expect(fixture.publishedEvents.isEmpty) + #expect(fixture.sealRequests.isEmpty) + } + + @Test func missingRelayPublisherDoesNotConsumeDurableDedupSlot() { + let fixture = Fixture() + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + fixture.service.publishEvent = nil + let messageID = UUID().uuidString + var results: [Bool] = [] + + fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) } + fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) } + #expect(fixture.sealRequests.count == 2) + #expect(fixture.publishedEvents.isEmpty) + #expect(results == [false, false]) + } + + @Test func relayRejectionDoesNotPersistDedupAndRetryCanSucceed() { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let key = Fixture.randomKey() + let messageID = UUID().uuidString + + let failed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + failed.sealResult = makeEnvelope(recipientKey: key) + failed.automaticPublishResult = false + var failedResults: [Bool] = [] + failed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { + failedResults.append($0) + } + failed.service.flushDedupSnapshot() + #expect(failedResults == [false]) + #expect(failed.publishedEvents.count == 1) + + // A relaunch over the failed attempt must be allowed to send again. + let retry = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + retry.sealResult = makeEnvelope(recipientKey: key) + var retryResults: [Bool] = [] + retry.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { + retryResults.append($0) + } + retry.service.flushDedupSnapshot() + #expect(retryResults == [true]) + #expect(retry.publishedEvents.count == 1) + + // Only confirmed relay acceptance consumes durable dedup. + let confirmed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + confirmed.sealResult = makeEnvelope(recipientKey: key) + confirmed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) + #expect(confirmed.publishedEvents.isEmpty) + #expect(confirmed.sealRequests.isEmpty) + } + + @Test func panicWipeInvalidatesInFlightPublishCompletion() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let key = Fixture.randomKey() + let messageID = UUID().uuidString + let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + fixture.sealResult = makeEnvelope(recipientKey: key) + fixture.automaticPublishResult = nil + var results: [Bool] = [] + + fixture.service.depositDrop(content: "in flight", messageID: messageID, recipientNoiseKey: key) { + results.append($0) + } + let staleCompletion = try #require(fixture.pendingPublishCompletions.first) + fixture.service.wipe() + #expect(results == [false]) + + // The pre-wipe relay completion cannot resurrect durable dedup or + // complete the caller a second time. + staleCompletion(true) + fixture.service.flushDedupSnapshot() + #expect(results == [false]) + + let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + relaunched.sealResult = makeEnvelope(recipientKey: key) + relaunched.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) + #expect(relaunched.publishedEvents.count == 1) + } + + @Test func bridgeDisableCancelsPendingAndInFlightPublishes() throws { + let fixture = Fixture() + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + fixture.automaticPublishResult = nil + var results: [Bool] = [] + + fixture.service.depositDrop(content: "in flight", messageID: "in-flight", recipientNoiseKey: key) { + results.append($0) + } + let staleCompletion = try #require(fixture.pendingPublishCompletions.first) + + fixture.relaysConnected = false + fixture.service.depositDrop(content: "pending", messageID: "pending", recipientNoiseKey: key) { + results.append($0) + } + #expect(fixture.service.pendingDrops.count == 1) + + fixture.bridgeOn = false + fixture.service.refresh() + #expect(results == [false, false]) + #expect(fixture.service.pendingDrops.isEmpty) + + staleCompletion(true) + #expect(results == [false, false]) + } + + @Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() { + let fixture = Fixture() + fixture.relaysConnected = false + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + + fixture.service.depositDrop(content: "later", messageID: UUID().uuidString, recipientNoiseKey: key) + #expect(fixture.publishedEvents.isEmpty) + #expect(fixture.service.pendingDrops.count == 1) + + fixture.relaysConnected = true + fixture.service.flushPendingDrops() + #expect(fixture.publishedEvents.count == 1) + #expect(fixture.service.pendingDrops.isEmpty) + } + + @Test func evictedPendingDropStaysRetryable() { + // Regression: a drop queued while relays are down but then evicted + // (oldest-out at capacity) before it ever published must release its + // sender-side dedup slot, or the router marks it "carried" and can + // never re-deposit it. + let fixture = Fixture() + fixture.relaysConnected = false + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + + let firstID = UUID().uuidString + var firstResults: [Bool] = [] + fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key) { firstResults.append($0) } + // Fill past capacity so the first drop is evicted. + for i in 1...BridgeCourierService.Limits.maxPendingDrops { + fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key) + } + #expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops) + #expect(firstResults == [false]) + + // The evicted first drop is deposit-able again (slot released). + fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key) + #expect(fixture.service.pendingDrops.last?.dedupKey == firstID) + } + + @Test func oversizeDropConsumesSlotInsteadOfChurning() { + // An envelope that encodes over the size cap fails identically on + // every attempt; the dedup slot must be consumed so the retry sweep + // doesn't re-run Noise sealing forever. + let fixture = Fixture() + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope( + recipientKey: key, + ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1) + ) + let messageID = UUID().uuidString + var results: [Bool] = [] + + fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) } + #expect(fixture.publishedEvents.isEmpty) + + // The retry sweep must not seal the same payload again. + fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) } + #expect(fixture.sealRequests.count == 1) + #expect(results == [false, false]) + } + + @Test func rejectedOversizeDropKeysExpireAndStayBounded() { + var date = Date(timeIntervalSince1970: 1_750_000_000) + let fixture = Fixture(now: { date }) + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope( + recipientKey: key, + ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1) + ) + + let firstID = "oversize-0" + fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key) + for index in 1...BridgeCourierService.Limits.maxTrackedIDs { + date = date.addingTimeInterval(1) + fixture.service.depositDrop(content: "big", messageID: "oversize-\(index)", recipientNoiseKey: key) + } + let afterCapacityFill = fixture.sealRequests.count + date = date.addingTimeInterval(1) + fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key) + #expect(fixture.sealRequests.count == afterCapacityFill + 1) + + let newestID = "oversize-\(BridgeCourierService.Limits.maxTrackedIDs)" + let beforeExpiry = fixture.sealRequests.count + fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key) + #expect(fixture.sealRequests.count == beforeExpiry) + + date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1) + fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key) + #expect(fixture.sealRequests.count == beforeExpiry + 1) + } + + @Test func publishedDropDedupSurvivesRelaunch() throws { + // Regression (field-verified amplification storm): the outbox that + // drives re-deposits is persisted, but the sender-side drop dedup was + // in-memory only — every relaunch republished the same undelivered + // message as a fresh drop, and relays hold each for 24h. + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let recipientKey = Fixture.randomKey() + let messageID = UUID().uuidString + + let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + fixture.sealResult = makeEnvelope(recipientKey: recipientKey) + var publishResults: [Bool] = [] + fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { publishResults.append($0) } + #expect(fixture.publishedEvents.count == 1) + #expect(publishResults == [true]) + // Persistence is coalesced; a real launch flushes within a second or + // on backgrounding — tests flush explicitly. + fixture.service.flushDedupSnapshot() + + // "Relaunch": a fresh service over the same store must refuse to + // publish the same message ID again (before even re-sealing it). + let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + relaunched.sealResult = makeEnvelope(recipientKey: recipientKey) + var relaunchResults: [Bool] = [] + relaunched.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) } + #expect(relaunched.publishedEvents.isEmpty) + #expect(relaunched.sealRequests.isEmpty) + #expect(relaunchResults == [false]) + } + + @Test func seenDropEventDedupSurvivesRelaunch() throws { + // Same storm, gateway side: relays redeliver the whole 24h drop + // backlog on every launch; a relaunch must not re-open (and re-ack) + // events it already handled. + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey)) + fixture.service.handleDropEvent(event) + #expect(fixture.openedEnvelopes.count == 1) + fixture.service.flushDedupSnapshot() + + let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + relaunched.myKey = myKey + relaunched.service.refresh() + relaunched.service.handleDropEvent(event) + #expect(relaunched.openedEnvelopes.isEmpty) + } + + @Test func offlineQueuedDropStaysRedepositableAfterRelaunch() throws { + // A deposit made while relays are down only joins the in-memory + // pending queue. Its dedup key must NOT be durable yet: if the app is + // killed before relays connect, the relaunch loses the queued drop — + // a persisted key would then block every 120s re-deposit for 24h and + // the message would silently never reach a relay. + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let recipientKey = Fixture.randomKey() + let messageID = UUID().uuidString + + let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + fixture.relaysConnected = false + fixture.sealResult = makeEnvelope(recipientKey: recipientKey) + fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) + #expect(fixture.publishedEvents.isEmpty) + // Even a flush while the drop is still pending must exclude its key. + fixture.service.flushDedupSnapshot() + + // "App killed before relays connected": pendingDrops were memory-only. + let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + relaunched.sealResult = makeEnvelope(recipientKey: recipientKey) + relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) + #expect(relaunched.publishedEvents.count == 1) + } + + @Test func publishedPendingDropBecomesDurableAfterFlush() throws { + // Counterpart: once the queued drop actually publishes on reconnect, + // its key becomes durable and a relaunch must not republish. + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let recipientKey = Fixture.randomKey() + let messageID = UUID().uuidString + + let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + fixture.relaysConnected = false + fixture.sealResult = makeEnvelope(recipientKey: recipientKey) + fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) + fixture.relaysConnected = true + fixture.service.flushPendingDrops() + #expect(fixture.publishedEvents.count == 1) + fixture.service.flushDedupSnapshot() + + let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + relaunched.sealResult = makeEnvelope(recipientKey: recipientKey) + var relaunchResults: [Bool] = [] + relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) } + #expect(relaunched.publishedEvents.isEmpty) + #expect(relaunchResults == [false]) + } + + @Test func failedGatewayHandoffReleasesSeenSlot() throws { + // A gateway's deliverToPeer handoff is best-effort: when it fails + // (the peer walked away between relay fetch and mesh send), the drop + // event must stay retryable — for a single-gateway mesh island this + // gateway is the recipient's only carrier. + let fixture = Fixture() + let peerKey = Fixture.randomKey() + let peer = PeerID(str: "aabbccdd00112233") + fixture.localPeers = [(peer, peerKey)] + fixture.service.refresh() + let event = try makeDropEvent(for: makeEnvelope(recipientKey: peerKey)) + + fixture.deliverResult = false + fixture.service.handleDropEvent(event) + #expect(fixture.delivered.count == 1) + + // Redelivery (relaunch/backlog re-fetch) retries the handoff … + fixture.deliverResult = true + fixture.service.handleDropEvent(event) + #expect(fixture.delivered.count == 2) + + // … and a successful handoff consumes the event for good. + fixture.service.handleDropEvent(event) + #expect(fixture.delivered.count == 2) + } + + @Test func staleWatchSetDeliveryIsNotConsumedBeforePeerBecomesCurrent() throws { + let fixture = Fixture() + let peerKey = Fixture.randomKey() + let peer = PeerID(str: "aabbccdd00112233") + let event = try makeDropEvent(for: makeEnvelope(recipientKey: peerKey)) + + // A callback from the previous relay subscription can land after its + // peer was removed from the bounded watch set. Ignore it without + // poisoning the persistent event-ID dedup record. + fixture.service.refresh() + fixture.service.handleDropEvent(event) + #expect(fixture.delivered.isEmpty) + + fixture.localPeers = [(peer, peerKey)] + fixture.service.refresh() + fixture.service.handleDropEvent(event) + #expect(fixture.delivered.count == 1) + + // Once the current peer's physical handoff succeeds, normal durable + // dedup applies. + fixture.service.handleDropEvent(event) + #expect(fixture.delivered.count == 1) + } + + @Test func distinctDropsUseDistinctThrowawayKeys() { + let fixture = Fixture() + let keyA = Fixture.randomKey() + let keyB = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: keyA) + fixture.service.depositDrop(content: "a", messageID: UUID().uuidString, recipientNoiseKey: keyA) + fixture.sealResult = makeEnvelope(recipientKey: keyB) + fixture.service.depositDrop(content: "b", messageID: UUID().uuidString, recipientNoiseKey: keyB) + + #expect(fixture.publishedEvents.count == 2) + #expect(fixture.publishedEvents[0].pubkey != fixture.publishedEvents[1].pubkey) + } + + @Test func bridgingPublishesHeldEnvelopesWithCooldown() { + let fixture = Fixture() + fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] + + fixture.service.publishHeldEnvelopes() + + #expect(fixture.publishedEvents.count == 1) + #expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown]) + #expect(fixture.markedHeldEnvelopes == fixture.held) + } + + @Test func rejectedHeldPublishDoesNotStartCooldown() { + let fixture = Fixture() + fixture.automaticPublishResult = false + fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] + + fixture.service.publishHeldEnvelopes() + + #expect(fixture.publishedEvents.count == 1) + #expect(fixture.markedHeldEnvelopes.isEmpty) + } + + @Test func heldPublishIsSingleFlightAndRetryableAfterRejection() { + let fixture = Fixture() + fixture.automaticPublishResult = nil + fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] + + fixture.service.publishHeldEnvelopes() + fixture.service.publishHeldEnvelopes() + #expect(fixture.publishedEvents.count == 1) + + fixture.resolveNextPublish(false) + fixture.service.publishHeldEnvelopes() + #expect(fixture.publishedEvents.count == 2) + #expect(fixture.markedHeldEnvelopes.isEmpty) + + fixture.resolveNextPublish(true) + #expect(fixture.markedHeldEnvelopes == fixture.held) + } + + @Test func bridgeDisableInvalidatesHeldPublishOperation() throws { + let fixture = Fixture() + fixture.automaticPublishResult = nil + fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] + + fixture.service.publishHeldEnvelopes() + let staleCompletion = try #require(fixture.pendingPublishCompletions.first) + fixture.bridgeOn = false + fixture.service.refresh() + staleCompletion(true) + + #expect(fixture.markedHeldEnvelopes.isEmpty) + } + + // MARK: - Subscription management + + @Test func refreshSubscribesOwnCandidateTags() throws { + let fixture = Fixture() + fixture.service.refresh() + + let tags = try #require(fixture.openedSubscriptions.last) + let myKey = try #require(fixture.myKey) + let expected = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).map { $0.hexEncodedString() }) + #expect(Set(tags) == expected) + #expect(tags.count == 3) // adjacent UTC days + } + + @Test func refreshAlsoWatchesLocalVerifiedPeers() throws { + let fixture = Fixture() + let peerKey = Fixture.randomKey() + fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), peerKey)] + + fixture.service.refresh() + + let tags = try #require(fixture.openedSubscriptions.last) + #expect(tags.count == 6) // 3 own + 3 watched + } + + @Test func refreshClosesSubscriptionWhenBridgeOff() { + let fixture = Fixture() + fixture.service.refresh() + #expect(fixture.openedSubscriptions.count == 1) + + fixture.bridgeOn = false + fixture.service.refresh() + #expect(fixture.closedSubscriptions == 1) + } + + @Test func announceDebounceSchedulesTrailingRefreshForPeersLearnedInsideWindow() throws { + var date = Date(timeIntervalSince1970: 1_750_000_000) + let fixture = Fixture(now: { date }) + + // Leading edge opens the own-tag subscription immediately. + fixture.service.refreshAfterVerifiedAnnounce() + #expect(fixture.openedSubscriptions.count == 1) + + // A second peer learned inside the debounce window must not wait for + // the 30-minute periodic timer. + date = date.addingTimeInterval(10) + fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), Fixture.randomKey())] + fixture.service.refreshAfterVerifiedAnnounce() + fixture.service.refreshAfterVerifiedAnnounce() // coalesces, not a second timer + + let trailingTimers = fixture.scheduledTimers.filter { $0.delay < 100 } + #expect(trailingTimers.count == 1) + let trailing = try #require(trailingTimers.first) + #expect(trailing.delay == 50) + date = date.addingTimeInterval(50) + trailing.fire() + + #expect(fixture.openedSubscriptions.count == 2) + #expect(fixture.openedSubscriptions.last?.count == 6) + } + + // MARK: - Inbound drops + + @Test func dropForUsIsOpened() throws { + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: myKey) + + fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) + + #expect(fixture.openedEnvelopes.count == 1) + #expect(fixture.delivered.isEmpty) + } + + @Test func transientOwnDropOpenFailureRemainsRetryable() throws { + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey)) + + fixture.openResult = false + fixture.service.handleDropEvent(event) + fixture.openResult = true + fixture.service.handleDropEvent(event) + fixture.service.handleDropEvent(event) + + #expect(fixture.openedEnvelopes.count == 2) + } + + @Test func duplicateDropEventOpensOnce() throws { + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey)) + + fixture.service.handleDropEvent(event) + fixture.service.handleDropEvent(event) + + #expect(fixture.openedEnvelopes.count == 1) + } + + @Test func dropForWatchedLocalPeerIsDelivered() throws { + let fixture = Fixture() + let peerKey = Fixture.randomKey() + let peer = PeerID(str: "aabbccdd00112233") + fixture.localPeers = [(peer, peerKey)] + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: peerKey) + + fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) + + #expect(fixture.delivered.count == 1) + #expect(fixture.delivered.first?.peer == peer) + #expect(fixture.openedEnvelopes.isEmpty) + } + + @Test func dropForStrangerIsIgnored() throws { + let fixture = Fixture() + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: Fixture.randomKey()) + + fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) + + #expect(fixture.openedEnvelopes.isEmpty) + #expect(fixture.delivered.isEmpty) + } + + @Test func mislabeledDropTagIsRejected() throws { + // The event's filterable #x tag must match the envelope's own tag. + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: Fixture.randomKey()) + let encoded = try #require(envelope.encode()) + let identity = try #require(BridgeCourierService.makeThrowawayIdentity()) + let mislabeled = try NostrProtocol.createCourierDropEvent( + envelope: encoded, + recipientTagHex: CourierEnvelope.recipientTag( + noiseStaticKey: myKey, + epochDay: CourierEnvelope.epochDay(for: Date()) + ).hexEncodedString(), // labeled for us, addressed to a stranger + expiresAt: Date().addingTimeInterval(3600), + senderIdentity: identity + ) + + fixture.service.handleDropEvent(mislabeled) + + #expect(fixture.openedEnvelopes.isEmpty) + #expect(fixture.delivered.isEmpty) + } + + @Test func expiredDropIsIgnored() throws { + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let expired = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag(noiseStaticKey: myKey, epochDay: CourierEnvelope.epochDay(for: Date())), + expiry: UInt64((Date().timeIntervalSince1970 - 60) * 1000), + ciphertext: Data(repeating: 1, count: 32), + copies: 1 + ) + + fixture.service.handleDropEvent(try makeDropEvent(for: expired)) + + #expect(fixture.openedEnvelopes.isEmpty) + } +} diff --git a/bitchatTests/Services/BridgeDropDedupStoreTests.swift b/bitchatTests/Services/BridgeDropDedupStoreTests.swift new file mode 100644 index 00000000..9662798a --- /dev/null +++ b/bitchatTests/Services/BridgeDropDedupStoreTests.swift @@ -0,0 +1,118 @@ +// +// BridgeDropDedupStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +@Suite("Bridge drop dedup persistence") +struct BridgeDropDedupStoreTests { + + // MARK: - ExpiringIDSet + + @Test func entriesExpireAfterLifetime() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + var set = ExpiringIDSet(capacity: 8, lifetime: 60) + + let inserted = set.insert("a", now: start) + #expect(inserted) + #expect(set.contains("a", now: start)) + let duplicate = set.insert("a", now: start.addingTimeInterval(30)) + #expect(!duplicate) + + // Past the lifetime the slot is free again. + let later = start.addingTimeInterval(61) + #expect(!set.contains("a", now: later)) + let reinserted = set.insert("a", now: later) + #expect(reinserted) + } + + @Test func capacityEvictsOldestFirst() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + var set = ExpiringIDSet(capacity: 2, lifetime: 3600) + + set.insert("oldest", now: start) + set.insert("middle", now: start.addingTimeInterval(1)) + set.insert("newest", now: start.addingTimeInterval(2)) + + let check = start.addingTimeInterval(3) + #expect(!set.contains("oldest", now: check)) + #expect(set.contains("middle", now: check)) + #expect(set.contains("newest", now: check)) + } + + @Test func removeReleasesSlot() { + let now = Date() + var set = ExpiringIDSet(capacity: 8, lifetime: 3600) + set.insert("a", now: now) + set.remove("a") + #expect(!set.contains("a", now: now)) + let reinserted = set.insert("a", now: now) + #expect(reinserted) + } + + @Test func initPrunesExpiredPersistedEntries() { + let now = Date() + let set = ExpiringIDSet( + capacity: 8, + lifetime: 3600, + entries: [ + "stale": now.addingTimeInterval(-7200), + "fresh": now.addingTimeInterval(-60) + ], + now: now + ) + #expect(!set.contains("stale", now: now)) + #expect(set.contains("fresh", now: now)) + } + + // MARK: - Store round trip + + @Test func snapshotRoundTripsThroughDisk() { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-store-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let recorded = Date(timeIntervalSince1970: 1_700_000_000) + + let store = BridgeDropDedupStore(fileURL: fileURL) + store.save(BridgeDropDedupStore.Snapshot( + publishedDropKeys: ["msg-1": recorded], + seenDropEventIDs: ["event-1": recorded] + )) + + let reloaded = BridgeDropDedupStore(fileURL: fileURL).load() + #expect(reloaded.publishedDropKeys["msg-1"] == recorded) + #expect(reloaded.seenDropEventIDs["event-1"] == recorded) + } + + @Test func wipeRemovesTheRecord() { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-store-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let store = BridgeDropDedupStore(fileURL: fileURL) + store.save(BridgeDropDedupStore.Snapshot( + publishedDropKeys: ["msg-1": Date()], + seenDropEventIDs: [:] + )) + store.wipe() + + let reloaded = BridgeDropDedupStore(fileURL: fileURL).load() + #expect(reloaded.publishedDropKeys.isEmpty) + #expect(reloaded.seenDropEventIDs.isEmpty) + } + + @Test func nonPersistingStoreStaysEmpty() { + let store = BridgeDropDedupStore(persistsToDisk: false) + store.save(BridgeDropDedupStore.Snapshot( + publishedDropKeys: ["msg-1": Date()], + seenDropEventIDs: [:] + )) + #expect(store.load().publishedDropKeys.isEmpty) + } +} diff --git a/bitchatTests/Services/BridgeServiceTests.swift b/bitchatTests/Services/BridgeServiceTests.swift new file mode 100644 index 00000000..24ffb54e --- /dev/null +++ b/bitchatTests/Services/BridgeServiceTests.swift @@ -0,0 +1,900 @@ +// +// BridgeServiceTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Mesh bridge policy") +@MainActor +struct BridgeServiceTests { + nonisolated private static let cell = "u4pruy" + + /// Closure-injected harness around `BridgeService` recording every side + /// effect, with a controllable clock, location, and connectivity. + @MainActor + private final class Fixture { + private final class ClockBox { + var now = Date() + } + + var relaysConnected = true + var locationCell: String? = BridgeServiceTests.cell + var meshAdvertisedCell: String? + var bridgePeers: [PeerID] = [] + var sendSucceeds = true + var locallySeenMessageIDs: Set = [] + var injectedPresenceOverride: ((String) -> Bool)? + var nickname = "tester" + + private(set) var published: [(event: NostrEvent, cell: String)] = [] + + /// Published chat messages only — the fixture's own presence + /// heartbeats (kind 20001, sent on enable) are filtered out. + var publishedMessages: [(event: NostrEvent, cell: String)] { + published.filter { $0.event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue } + } + private(set) var broadcasts: [Data] = [] + private(set) var injected: [BridgeService.InboundBridgeMessage] = [] + private(set) var removedInjectedMessageIDs: [String] = [] + private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] + private(set) var openedSubscriptions: [[String]] = [] + private(set) var closedSubscriptions = 0 + private(set) var enabledChanges: [Bool] = [] + private(set) var locationFixRequests = 0 + private(set) var cellChanges: [String?] = [] + private(set) var scheduledTimers: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] + + private let clock = ClockBox() + let identity: NostrIdentity + let defaults: UserDefaults + let service: BridgeService + + init( + enabled: Bool = true, + verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() } + ) { + let suite = "BridgeServiceTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + identity = try! NostrIdentity.generate() + let clock = clock + service = BridgeService( + defaults: defaults, + now: { clock.now }, + verifyEventSignature: verifyEventSignature + ) + service.publishToRelays = { [weak self] event, cell in + self?.published.append((event, cell)) + } + service.openSubscription = { [weak self] cells in + self?.openedSubscriptions.append(cells) + } + service.closeSubscription = { [weak self] in + self?.closedSubscriptions += 1 + } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.locationCell = { [weak self] in self?.locationCell } + service.requestLocationFix = { [weak self] in self?.locationFixRequests += 1 } + service.meshAdvertisedCell = { [weak self] in self?.meshAdvertisedCell } + service.sendToBridgePeer = { [weak self] payload, peer in + guard let self, self.sendSucceeds else { return false } + self.uplinkSends.append((payload, peer)) + return true + } + service.availableBridgePeers = { [weak self] in self?.bridgePeers ?? [] } + service.broadcastToMesh = { [weak self] payload in + self?.broadcasts.append(payload) + } + service.injectInbound = { [weak self] message in + self?.injected.append(message) + } + service.removeInjectedInbound = { [weak self] messageID in + self?.removedInjectedMessageIDs.append(messageID) + } + service.isInjectedInboundPresent = { [weak self] messageID in + guard let self else { return false } + return self.injectedPresenceOverride?(messageID) + ?? self.injected.contains { $0.messageID == messageID } + } + service.isMessageSeenLocally = { [weak self] id in + self?.locallySeenMessageIDs.contains(id) ?? false + } + service.deriveIdentity = { [weak self] _ in + guard let self else { throw NostrError.invalidEvent } + return self.identity + } + service.myNickname = { [weak self] in self?.nickname ?? "" } + service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } + service.onActiveCellChanged = { [weak self] cell in self?.cellChanges.append(cell) } + service.scheduleTimer = { [weak self] delay, work in + self?.scheduledTimers.append((delay, work)) + } + if enabled { + service.setEnabled(true) + } + } + + func advance(_ seconds: TimeInterval) { + clock.now = clock.now.addingTimeInterval(seconds) + } + + func fireScheduledTimers() { + let due = scheduledTimers + scheduledTimers.removeAll() + for item in due { item.work() } + } + } + + // MARK: Event helpers + + nonisolated private static let remoteMeshSenderID = "feedfacecafef00d" + nonisolated private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000 + + private func makeRemoteEvent( + cell: String = BridgeServiceTests.cell, + content: String = "hi \(UUID().uuidString.prefix(8))", + meshSenderID: String = BridgeServiceTests.remoteMeshSenderID, + meshTimestampMs: UInt64 = BridgeServiceTests.remoteMeshTimestampMs, + identity: NostrIdentity? = nil + ) throws -> NostrEvent { + try NostrProtocol.createBridgeMeshEvent( + content: content, + cell: cell, + senderIdentity: identity ?? NostrIdentity.generate(), + nickname: "remote", + meshSenderID: meshSenderID, + meshTimestampMs: meshTimestampMs + ) + } + + /// The dedup key receivers derive for an event built by `makeRemoteEvent`. + private func stableID( + content: String, + meshSenderID: String = BridgeServiceTests.remoteMeshSenderID, + meshTimestampMs: UInt64 = BridgeServiceTests.remoteMeshTimestampMs + ) -> String { + MeshMessageIdentity.stableID(senderIDHex: meshSenderID, timestampMs: meshTimestampMs, content: content) + } + + private func makePresenceEvent(cell: String = BridgeServiceTests.cell) throws -> NostrEvent { + try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate()) + } + + private func carrier( + _ event: NostrEvent, + direction: NostrCarrierPacket.Direction, + cell: String = BridgeServiceTests.cell + ) throws -> NostrCarrierPacket { + try #require(NostrCarrierPacket(direction: direction, geohash: cell, event: event)) + } + + // MARK: - Lifecycle & rendezvous + + @Test func enablingOpensSubscriptionForCellAndNeighbors() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + + #expect(fixture.service.activeCell == Self.cell) + let cells = try #require(fixture.openedSubscriptions.first) + #expect(cells.first == Self.cell) + #expect(cells.count == 9) // own cell + 8 neighbors + #expect(fixture.service.subscribedCells.count == 9) + } + + @Test func missingCellRequestsALocationFix() { + // Field bug: the bridge waited passively for availableChannels, + // which only flow while some other feature pumps location. Bridging + // without a cell must ask for a fix itself. + let fixture = Fixture(enabled: true) + fixture.locationCell = nil + fixture.service.refreshRendezvous() + + #expect(fixture.locationFixRequests >= 1) + #expect(fixture.service.activeCell == nil) + + // The fix lands, channels flow, and the sink re-enters here: + fixture.locationCell = Self.cell + fixture.service.refreshRendezvous() + #expect(fixture.service.activeCell == Self.cell) + } + + @Test func noLocationFallsBackToMeshAdvertisedCell() { + let fixture = Fixture(enabled: true) + fixture.locationCell = nil + fixture.meshAdvertisedCell = "u4prux" + fixture.service.refreshRendezvous() + + #expect(fixture.service.activeCell == "u4prux") + } + + @Test func disablingClosesSubscriptionAndClearsState() { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.service.setEnabled(false) + + #expect(fixture.closedSubscriptions >= 1) + #expect(fixture.service.activeCell == nil) + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func togglePersistsAcrossInstances() { + let fixture = Fixture(enabled: true) + let revived = BridgeService(defaults: fixture.defaults) + #expect(revived.isEnabled) + } + + // MARK: - Outgoing + + @Test func outgoingPublishesSignedRendezvousEventWithOriginCoordinates() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let sender = PeerID(str: "0011223344556677") + let timestamp = Date() + + fixture.service.bridgeOutgoing(content: "hello hill", senderPeerID: sender, timestamp: timestamp) + + let published = try #require(fixture.published.last) + #expect(published.cell == Self.cell) + #expect(published.event.isValidSignature()) + #expect(published.event.content == "hello hill") + #expect(published.event.tags.contains(["r", Self.cell])) + let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) + #expect(published.event.tags.contains([ + "m", + MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "hello hill"), + sender.id, + String(timestampMs) + ])) + #expect(published.event.tags.contains(["n", "tester"])) + } + + @Test func newMeshTagStaysPerMessageUniqueForOldParsers() throws { + // v1.7.0 parsers take m[1] unconditionally as the dedup key whenever + // the tag has >= 2 elements. A constant m[1] (e.g. the bare sender + // ID) would make old receivers inject-dedup away every message from + // a sender after their first — so element 1 must be the + // per-message-unique stable ID itself. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let sender = PeerID(str: "0011223344556677") + let timestamp = Date() + + fixture.service.bridgeOutgoing(content: "first", senderPeerID: sender, timestamp: timestamp) + fixture.service.bridgeOutgoing(content: "second", senderPeerID: sender, timestamp: timestamp) + + // Old-parser extraction: m[1] of the first `m` tag with >= 2 elements. + let oldParserKeys = fixture.publishedMessages.compactMap { + $0.event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1] + } + #expect(oldParserKeys.count == 2) + #expect(oldParserKeys[0] != oldParserKeys[1]) + // And old and new receivers key the same message identically: m[1] + // equals the ID the new parser recomputes from elements 2-3. + let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) + #expect(oldParserKeys == [ + MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "first"), + MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "second") + ]) + } + + @Test func nearbyOnlySuppressesTheBridgedCopy() { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.service.nearbyOnly = true + + fixture.service.bridgeOutgoing(content: "just us", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) + + #expect(fixture.publishedMessages.isEmpty) + #expect(fixture.uplinkSends.isEmpty) + } + + @Test func outgoingWithoutRelaysDepositsWithBridgePeer() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.relaysConnected = false + fixture.bridgePeers = [PeerID(str: "abcdef0123456789")] + + fixture.service.bridgeOutgoing(content: "no internet here", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) + + #expect(fixture.publishedMessages.isEmpty) + let sent = try #require(fixture.uplinkSends.first) + let carrier = try #require(NostrCarrierPacket.decode(sent.payload)) + #expect(carrier.direction == .toBridge) + #expect(carrier.geohash == Self.cell) + } + + @Test func ownRelayBackfilledEventIsIgnoredAfterRestart() throws { + // Field bug: a relaunch wipes the published-ID cache, and relay + // backfill then re-delivered the device's own pre-restart events as + // "bridged". Self-recognition by the deterministic rendezvous pubkey + // must catch them with no cache state at all. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let ownOldEvent = try NostrProtocol.createBridgeMeshEvent( + content: "sent before the restart", + cell: Self.cell, + senderIdentity: fixture.identity, // == deriveIdentity(cell) + nickname: "tester", + meshSenderID: "0011223344556677", + meshTimestampMs: Self.remoteMeshTimestampMs + ) + + fixture.service.handleRendezvousEvent(ownOldEvent) + + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func ownEventComingBackFromSubscriptionIsIgnored() { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.service.bridgeOutgoing(content: "echo me", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) + let ownEvent = fixture.published[0].event + + fixture.service.handleRendezvousEvent(ownEvent) + + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.service.bridgedPeerCount == 0) + } + + // MARK: - Subscription ingress + + @Test func remoteMessageInjectsAndDownlinks() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleRendezvousEvent(event) + + #expect(fixture.injected.count == 1) + #expect(fixture.injected.first?.content == event.content) + #expect(fixture.injected.first?.messageID == event.id) + #expect(fixture.injected.first?.senderNickname == "remote#\(event.pubkey.suffix(4))") + #expect(fixture.service.bridgedPeerCount == 1) + // Serving duty: after the jitter holdoff, the remote event rides out + // as a fromBridge broadcast — one switch, no gateway toggle. + #expect(fixture.broadcasts.isEmpty) + fixture.fireScheduledTimers() + let broadcast = try #require(fixture.broadcasts.first) + let carrier = try #require(NostrCarrierPacket.decode(broadcast)) + #expect(carrier.direction == .fromBridge) + } + + @Test func jitterHoldoffSuppressesAlreadyBroadcastEvents() throws { + // Two gateways, one island: while our drain waits out the jitter, + // the other gateway's fromBridge broadcast arrives — ours must yield. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleRendezvousEvent(event) // queued behind jitter + fixture.service.handleMeshCarrier( + try carrier(event, direction: .fromBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: false + ) + fixture.fireScheduledTimers() + + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.injected.count == 1) // rendered once, either path + } + + @Test func neighborCellEventIsAccepted() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let neighbor = try #require(Geohash.neighbors(of: Self.cell).first) + let event = try makeRemoteEvent(cell: neighbor) + + fixture.service.handleRendezvousEvent(event) + + #expect(fixture.injected.count == 1) + } + + @Test func outOfRingCellEventIsRejected() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent(cell: "9q8yyk") + + fixture.service.handleRendezvousEvent(event) + + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + } + + @Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let content = "heard on the radio" + // The radio copy's timeline row keys on the same derived stable ID. + fixture.locallySeenMessageIDs = [stableID(content: content)] + let event = try makeRemoteEvent(content: content) + + fixture.service.handleRendezvousEvent(event) + + // The island already heard this over radio: no duplicate render or + // wasted airtime. The public hint cannot attribute the Nostr signer, + // so it does not mutate participant state either. + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func bridgeFirstThenAuthenticatedRadioReplacesAliasesAndCancelsDownlink() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let content = "bridge arrived first" + let radioMessageID = stableID(content: content) + let event = try makeRemoteEvent(content: content) + + fixture.service.handleRendezvousEvent(event) + #expect(fixture.injected.map(\.messageID) == [event.id]) + #expect(fixture.service.bridgedPeerCount == 1) + + // This entry point is called only after the BLE packet signature has + // authenticated. The radio row must win; the public m-tag hint is not + // trusted enough to suppress it. + fixture.service.handleAuthenticatedRadioMessage(messageID: radioMessageID) + fixture.fireScheduledTimers() + + #expect(fixture.removedInjectedMessageIDs == [event.id]) + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.service.bridgedPeerCount == 1) + + // A different signer copying the same public mesh coordinates after + // radio authentication cannot put a bridge alias back into the row. + fixture.service.handleRendezvousEvent(try makeRemoteEvent(content: content)) + #expect(fixture.injected.count == 1) + } + + @Test func disablingBridgeDoesNotForgetAliasNeededByLaterRadioCopy() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let content = "radio arrives after opt-out" + let event = try makeRemoteEvent(content: content) + + fixture.service.handleRendezvousEvent(event) + fixture.service.setEnabled(false) + fixture.service.handleAuthenticatedRadioMessage(messageID: stableID(content: content)) + + #expect(fixture.removedInjectedMessageIDs == [event.id]) + } + + @Test func aliasPruningUsesExactRowLivenessWithoutDeletingHistory() throws { + let fixture = Fixture(enabled: true, verifyEventSignature: { _ in true }) + fixture.service.refreshRendezvous() + + func event(index: Int) -> NostrEvent { + let senderID = String(format: "%016llx", UInt64(index + 1)) + let content = "bridge overflow \(index)" + let timestampMs = UInt64(1_750_000_000_000) + UInt64(index) + var event = NostrEvent( + pubkey: String(format: "%064llx", UInt64(index + 1)), + createdAt: Date(), + kind: .ephemeralEvent, + tags: [ + ["r", Self.cell], + ["n", "remote"], + ["m", "unused", senderID, String(timestampMs)] + ], + content: content + ) + event.id = String(format: "%064llx", UInt64(index + 10_000)) + event.sig = String(repeating: "0", count: 128) + return event + } + + let oldest = event(index: 0) + fixture.service.handleRendezvousEvent(oldest) + for index in 1...BridgeService.Limits.maxTrackedEventIDs { + if index.isMultiple(of: 500) { fixture.advance(61) } + fixture.service.handleRendezvousEvent(event(index: index)) + } + + // The loop/dedup caches are intentionally smaller, but valid ingress + // through that boundary must not delete a still-visible bridge row. + #expect(fixture.removedInjectedMessageIDs.isEmpty) + + for index in (BridgeService.Limits.maxTrackedEventIDs + 1).. +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Gateway mode policy") +@MainActor +struct GatewayServiceTests { + nonisolated private static let geohash = "u4pruy" + + /// Closure-injected harness around `GatewayService` recording every + /// side effect, with a controllable clock and relay connectivity. + @MainActor + private final class Fixture { + private final class ClockBox { + var now = Date() + } + + var relaysConnected = true + var currentGeohash: String? = GatewayServiceTests.geohash + var gatewayPeers: [PeerID] = [] + var sendToGatewaySucceeds = true + + private(set) var published: [(event: NostrEvent, geohash: String)] = [] + private(set) var broadcasts: [Data] = [] + private(set) var injected: [NostrEvent] = [] + private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] + private(set) var enabledChanges: [Bool] = [] + private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] + + private let clock = ClockBox() + let defaults: UserDefaults + let service: GatewayService + + init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") { + defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let clock = clock + service = GatewayService(defaults: defaults) { clock.now } + service.publishToRelays = { [weak self] event, geohash in + self?.published.append((event, geohash)) + } + service.broadcastToMesh = { [weak self] payload in + self?.broadcasts.append(payload) + } + service.sendToGatewayPeer = { [weak self] payload, peer in + guard let self, self.sendToGatewaySucceeds else { return false } + self.uplinkSends.append((payload, peer)) + return true + } + service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.currentGeohash = { [weak self] in self?.currentGeohash } + service.injectInbound = { [weak self] event in self?.injected.append(event) } + service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } + // Capture drain timers instead of arming a real Task so the drain + // is deterministic under the fake clock. + service.scheduleDrainTimer = { [weak self] delay, work in + self?.scheduledDrains.append((delay, work)) + } + if enabled { + service.setEnabled(true) + } + } + + func advance(_ seconds: TimeInterval) { + clock.now = clock.now.addingTimeInterval(seconds) + } + + /// Fires every currently-scheduled drain timer (simulating the window + /// freeing), as the real Task would after its delay. + func fireScheduledDrains() { + let due = scheduledDrains + scheduledDrains.removeAll() + for item in due { item.work() } + } + } + + // MARK: Event helpers + + private func makeEvent( + geohash: String = GatewayServiceTests.geohash, + content: String = "hello \(UUID().uuidString.prefix(8))" + ) throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + /// A copy of `event` with tampered content but the original ID and + /// signature — what a forging gateway or mesh peer would produce. + private func forge(_ event: NostrEvent) throws -> NostrEvent { + let dict: [String: Any] = [ + "id": event.id, + "pubkey": event.pubkey, + "created_at": event.created_at, + "kind": event.kind, + "tags": event.tags, + "content": event.content + " (tampered)", + "sig": event.sig ?? "" + ] + return try NostrEvent(from: dict) + } + + private func carrierPayload( + _ event: NostrEvent, + direction: NostrCarrierPacket.Direction = .toGateway, + geohash: String = GatewayServiceTests.geohash + ) throws -> Data { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event)) + return try #require(packet.encode()) + } + + private func deposit( + _ event: NostrEvent, + into fixture: Fixture, + from depositor: PeerID = PeerID(str: "1122334455667788"), + geohash: String = GatewayServiceTests.geohash + ) throws { + let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash) + fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true) + } + + // MARK: - Uplink verification gates + + @Test("publishes a verified deposit to the geo relays") + func verifiedDepositPublished() throws { + let fixture = Fixture() + let event = try makeEvent() + try deposit(event, into: fixture) + + #expect(fixture.published.count == 1) + #expect(fixture.published.first?.event.id == event.id) + #expect(fixture.published.first?.geohash == Self.geohash) + // Viewing the same geohash: the carried message shows on our own timeline. + #expect(fixture.injected.map(\.id) == [event.id]) + } + + @Test("rejects a forged signature") + func forgedSignatureRejected() throws { + let fixture = Fixture() + let forged = try forge(try makeEvent()) + try deposit(forged, into: fixture) + + #expect(fixture.published.isEmpty) + #expect(fixture.injected.isEmpty) + } + + @Test("rejects wrong kind, geohash mismatch, and stale events") + func structuralGates() throws { + let fixture = Fixture() + + // Wrong kind (kind-1 text note instead of kind-20000 ephemeral). + let identity = try NostrIdentity.generate() + let note = try NostrProtocol.createGeohashTextNote( + content: "note", + geohash: Self.geohash, + senderIdentity: identity + ) + try deposit(note, into: fixture) + #expect(fixture.published.isEmpty) + + // Carrier geohash disagreeing with the event's #g tag. + let mismatched = try makeEvent(geohash: "9q8yyk") + try deposit(mismatched, into: fixture, geohash: Self.geohash) + #expect(fixture.published.isEmpty) + + // Stale event (beyond accepted clock skew). + let stale = try makeEvent() + fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60) + try deposit(stale, into: fixture) + #expect(fixture.published.isEmpty) + } + + @Test("does nothing while the toggle is off") + func disabledGatewayIgnoresDeposits() throws { + let fixture = Fixture(enabled: false) + try deposit(try makeEvent(), into: fixture) + #expect(fixture.published.isEmpty) + #expect(fixture.service.queuedUplinks.isEmpty) + + fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash) + #expect(fixture.broadcasts.isEmpty) + } + + // MARK: - Uplink quotas and rate limit + + @Test("rate-limits deposits per depositor per minute") + func uplinkRateLimit() throws { + let fixture = Fixture() + let depositor = PeerID(str: "aabbccddeeff0011") + + for _ in 0.. +// + +import Foundation +import Testing +@testable import bitchat + +@MainActor +struct GeohashChatActivityTrackerTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + + private func makeTracker(window: TimeInterval = 900, now: Date? = nil) -> (GeohashChatActivityTracker, (Date) -> Void) { + var currentNow = now ?? baseDate + let tracker = GeohashChatActivityTracker(window: window, now: { currentNow }) + return (tracker, { currentNow = $0 }) + } + + private func channel(_ geohash: String, _ level: GeohashChannelLevel) -> GeohashChannel { + GeohashChannel(level: level, geohash: geohash) + } + + @Test + func recordsAndCountsMessagesInWindow() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9Q8YY", senderName: "alice#ab12", content: "hi", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "bob#cd34", content: "yo", timestamp: baseDate) + + #expect(tracker.messageCount(for: "9q8yy") == 2) + #expect(tracker.lastMessage(for: "9q8YY")?.senderName == "bob#cd34") + } + + @Test + func dropsMessagesOlderThanWindow() { + let (tracker, advance) = makeTracker(window: 900) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "alice#ab12", content: "hi", timestamp: baseDate) + + advance(baseDate.addingTimeInterval(901)) + + #expect(tracker.messageCount(for: "9q8yy") == 0) + #expect(tracker.lastMessage(for: "9q8yy") == nil) + } + + @Test + func ignoresMessagesAlreadyOutsideWindow() { + let (tracker, _) = makeTracker(window: 900) + tracker.recordChatMessage( + geohash: "9q8yy", + senderName: "alice#ab12", + content: "old", + timestamp: baseDate.addingTimeInterval(-1000) + ) + + #expect(tracker.messageCount(for: "9q8yy") == 0) + } + + @Test + func keepsNewestPreview() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "newer", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "b#2222", content: "older", timestamp: baseDate.addingTimeInterval(-60)) + + #expect(tracker.lastMessage(for: "9q8yy")?.content == "newer") + #expect(tracker.messageCount(for: "9q8yy") == 2) + } + + @Test + func mostActivePicksBusiestChannel() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "one", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8", senderName: "b#2222", content: "two", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8", senderName: "c#3333", content: "three", timestamp: baseDate) + + let channels = [channel("9q8yy", .city), channel("9q8", .province)] + let best = tracker.mostActiveConversation(among: channels) + + #expect(best?.channel.geohash == "9q8") + #expect(best?.messageCount == 2) + } + + @Test + func mostActiveTieGoesToMoreLocalChannel() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yyzz1", senderName: "a#1111", content: "local", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q", senderName: "b#2222", content: "regional", timestamp: baseDate) + + let channels = [channel("9q", .region), channel("9q8yyzz1", .building)] + let best = tracker.mostActiveConversation(among: channels) + + #expect(best?.channel.geohash == "9q8yyzz1") + } + + @Test + func mostActiveIsNilWithoutMessages() { + let (tracker, _) = makeTracker() + #expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil) + } + + @Test + func clearRemovesEverything() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "hi", timestamp: baseDate) + tracker.clear() + + #expect(tracker.messageCount(for: "9q8yy") == 0) + #expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil) + } +} diff --git a/bitchatTests/Services/GeohashPresenceServiceTests.swift b/bitchatTests/Services/GeohashPresenceServiceTests.swift index f6be1f25..65c33f0c 100644 --- a/bitchatTests/Services/GeohashPresenceServiceTests.swift +++ b/bitchatTests/Services/GeohashPresenceServiceTests.swift @@ -230,8 +230,4 @@ private final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol { invalidateCallCount += 1 isValid = false } - - func fire() { - handler() - } } diff --git a/bitchatTests/Services/GroupProtocolTests.swift b/bitchatTests/Services/GroupProtocolTests.swift new file mode 100644 index 00000000..f9f19e7f --- /dev/null +++ b/bitchatTests/Services/GroupProtocolTests.swift @@ -0,0 +1,374 @@ +// +// GroupProtocolTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct GroupProtocolTests { + + // MARK: - Fixtures + + /// Deterministic member identity: an Ed25519 keypair plus the 64-hex + /// fingerprint the roster pins. + private struct TestIdentity { + let signingKey: Curve25519.Signing.PrivateKey + let fingerprint: String + + init(seed: UInt8) { + signingKey = Curve25519.Signing.PrivateKey() + fingerprint = Data(repeating: seed, count: 32).hexEncodedString() + } + + var member: GroupMember { + GroupMember( + fingerprint: fingerprint, + signingKey: signingKey.publicKey.rawRepresentation, + nickname: "peer-\(fingerprint.prefix(4))" + ) + } + + func sign(_ data: Data) -> Data? { + try? signingKey.signature(for: data) + } + } + + private let creator = TestIdentity(seed: 0xC1) + private let member = TestIdentity(seed: 0xA2) + private let outsider = TestIdentity(seed: 0xE3) + + private let groupID = Data((0..<16).map { UInt8($0) }) + private let key = Data(repeating: 0x42, count: 32) + + private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup { + BitchatGroup( + groupID: groupID, + name: "trail crew", + epoch: epoch, + members: [creator.member, member.member] + extraMembers, + creatorFingerprint: creator.fingerprint + ) + } + + // MARK: - State payload (invite / key update) + + @Test func statePayloadRoundTripAndSignatureVerify() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + let encoded = try #require(payload.encode()) + + let decoded = try #require(GroupStatePayload.decode(encoded)) + #expect(decoded == payload) + #expect(decoded.groupID == groupID) + #expect(decoded.name == "trail crew") + #expect(decoded.key == key) + #expect(decoded.epoch == 1) + #expect(decoded.members == group.members) + #expect(decoded.creatorFingerprint == creator.fingerprint) + #expect(decoded.verifyCreatorSignature()) + #expect(decoded.asGroup == group) + } + + @Test func forgedCreatorSignatureIsRejected() throws { + let group = makeGroup() + // Signed by a member who is in the roster but is NOT the creator. + let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign)) + #expect(!forged.verifyCreatorSignature()) + + // An outsider signing is equally rejected. + let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign)) + #expect(!outsiderForged.verifyCreatorSignature()) + } + + @Test func tamperedStateFailsSignature() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + + // Bumping the epoch invalidates the signature. + let epochTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch + 1, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!epochTampered.verifyCreatorSignature()) + + // So does swapping the key. + let keyTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: Data(repeating: 0x99, count: 32), + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!keyTampered.verifyCreatorSignature()) + + // And so does adding a member to the roster. + let rosterTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch, + members: payload.members + [outsider.member], + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!rosterTampered.verifyCreatorSignature()) + } + + @Test func creatorMissingFromRosterIsRejected() throws { + // State claiming a creator whose fingerprint is not in the roster has + // no key to verify against and must fail closed. + let group = BitchatGroup( + groupID: groupID, + name: "orphan", + epoch: 1, + members: [member.member], + creatorFingerprint: creator.fingerprint + ) + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { + Issue.record("roster should encode") + return + } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name) + let payload = GroupStatePayload( + groupID: groupID, + name: group.name, + key: key, + epoch: 1, + members: group.members, + creatorFingerprint: creator.fingerprint, + signature: creator.sign(content) ?? Data() + ) + #expect(!payload.verifyCreatorSignature()) + } + + @Test func rosterCapIsEnforcedOnTheWire() { + // 17 members cannot be encoded (hard cap is 16)… + let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member } + #expect(GroupRosterCoding.encode(seventeen) == nil) + + // …and a hand-built blob claiming 17 members fails to decode. + let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member } + guard var blob = GroupRosterCoding.encode(sixteen) else { + Issue.record("16-member roster should encode") + return + } + #expect(GroupRosterCoding.decode(blob)?.count == 16) + blob[blob.startIndex] = 17 + #expect(GroupRosterCoding.decode(blob) == nil) + } + + // MARK: - Message seal / open + + @Test func messageRoundTrip() throws { + let group = makeGroup() + let timestampMs: UInt64 = 1_750_000_000_000 + let sealed = try GroupCrypto.sealMessage( + content: "summit at noon", + messageID: "msg-1", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: timestampMs, + groupID: groupID, + epoch: group.epoch, + key: key, + sign: member.sign + ) + + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(envelope.groupID == groupID) + #expect(envelope.epoch == group.epoch) + + let plaintext = try GroupCrypto.openMessage(envelope, key: key) + #expect(plaintext.messageID == "msg-1") + #expect(plaintext.content == "summit at noon") + #expect(plaintext.senderNickname == "alice") + #expect(plaintext.timestampMs == timestampMs) + #expect(plaintext.senderSigningKey == member.member.signingKey) + + // The roster resolves the sender; an outsider's key would not. + #expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil) + #expect(group.member(withSigningKey: outsider.member.signingKey) == nil) + } + + @Test func wrongKeyFailsToDecrypt() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-2", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32)) + } + } + + @Test func epochIsBoundIntoTheCiphertext() throws { + // Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD: + // a rotated-out member cannot replay old ciphertext into a new epoch. + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-3", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + let relabeled = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: 2, + nonce: envelope.nonce, + ciphertext: envelope.ciphertext + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(relabeled, key: key) + } + } + + @Test func badSenderSignatureIsRejected() throws { + // A key-holder who signs with a key other than the one they claim + // (or garbage) is dropped even though decryption succeeds. + let sealed = try GroupCrypto.sealMessage( + content: "spoof", + messageID: "msg-4", + senderNickname: "mallory", + senderSigningKey: member.member.signingKey, // claims member's key… + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: outsider.sign // …but signs with the outsider's + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.badSenderSignature) { + _ = try GroupCrypto.openMessage(envelope, key: key) + } + } + + @Test func tamperedCiphertextFailsToOpen() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-5", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + var flipped = envelope.ciphertext + flipped[flipped.startIndex] ^= 0x01 + let tampered = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: envelope.epoch, + nonce: envelope.nonce, + ciphertext: flipped + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(tampered, key: key) + } + } + + @Test func malformedEnvelopesAreRejected() { + #expect(GroupMessageEnvelope.decode(Data()) == nil) + #expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil) + #expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil) + } + + // MARK: - Oversize / UTF-8 safety (Codex findings) + + @Test func oversizeMessageContentFailsToSealInsteadOfTruncating() { + // A content whose UTF-8 exceeds the 16-bit TLV length must fail to + // seal (surfacing send_failed) rather than silently truncate into a + // ciphertext recipients would drop. + let oversize = String(repeating: "a", count: 70_000) + #expect(throws: (any Error).self) { + _ = try GroupCrypto.sealMessage( + content: oversize, + messageID: "big", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + } + } + + @Test func multiByteNicknameTruncatesOnScalarBoundary() throws { + // 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split + // the 21st scalar and make the roster undecodable. Truncation must + // land on a Character boundary so the blob round-trips. + let euros = String(repeating: "€", count: 40) + let wide = GroupMember( + fingerprint: creator.fingerprint, + signingKey: creator.member.signingKey, + nickname: euros + ) + let blob = try #require(GroupRosterCoding.encode([wide])) + let decoded = try #require(GroupRosterCoding.decode(blob)) + #expect(decoded.count == 1) + #expect(Data(decoded[0].nickname.utf8).count <= 64) + #expect(decoded[0].nickname.allSatisfy { $0 == "€" }) + #expect(!decoded[0].nickname.isEmpty) + } + + // MARK: - Signable-bytes forward-proofing + + @Test func creatorSignatureCoversName() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + #expect(payload.verifyCreatorSignature()) + + // Swapping only the display name must invalidate the creator signature. + let renamed = GroupStatePayload( + groupID: payload.groupID, + name: "totally different name", + key: payload.key, + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!renamed.verifyCreatorSignature()) + } + + @Test func messageSignatureCoversEpoch() { + // The signed bytes differ by epoch, so a signature captured at one + // epoch cannot verify when re-sealed under a later epoch key. + let atEpoch1 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x" + ) + let atEpoch2 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x" + ) + #expect(atEpoch1 != atEpoch2) + } +} diff --git a/bitchatTests/Services/GroupStoreTests.swift b/bitchatTests/Services/GroupStoreTests.swift new file mode 100644 index 00000000..adf04806 --- /dev/null +++ b/bitchatTests/Services/GroupStoreTests.swift @@ -0,0 +1,170 @@ +// +// GroupStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +@MainActor +struct GroupStoreTests { + + private func makeMember(seed: UInt8, nickname: String = "peer") -> GroupMember { + GroupMember( + fingerprint: Data(repeating: seed, count: 32).hexEncodedString(), + signingKey: Data(repeating: seed &+ 1, count: 32), + nickname: nickname + ) + } + + private func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("group-store-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("groups.json") + } + + // MARK: - Create / read + + @Test func createGroupStoresMetadataAndKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1, nickname: "me") + + let group = try #require(store.createGroup(named: "ops", creator: creator)) + #expect(group.groupID.count == BitchatGroup.groupIDLength) + #expect(group.epoch == 1) + #expect(group.members == [creator]) + #expect(group.creatorFingerprint == creator.fingerprint) + + #expect(store.group(withID: group.groupID) == group) + #expect(store.group(for: group.peerID) == group) + let key = try #require(store.key(forGroupID: group.groupID)) + #expect(key.count == BitchatGroup.keyLength) + #expect(group.peerID.isGroup) + #expect(group.peerID.groupIDData == group.groupID) + } + + // MARK: - Roster cap + + @Test func rosterCapIsEnforced() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let group = try #require(store.createGroup(named: "big", creator: creator)) + + // Filling to the cap works… + let fifteen = (1...15).map { makeMember(seed: UInt8($0)) } + #expect(store.updateRoster(groupID: group.groupID, members: [creator] + fifteen) != nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // …one more is rejected. + let overflow = [creator] + fifteen + [makeMember(seed: 0x99)] + #expect(store.updateRoster(groupID: group.groupID, members: overflow) == nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // Direct upsert past the cap is rejected too. + var oversized = group + oversized.members = overflow + #expect(!store.upsert(oversized, key: Data(repeating: 1, count: 32))) + } + + @Test func rosterMustRetainCreator() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let other = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + + #expect(store.updateRoster(groupID: group.groupID, members: [other]) == nil) + #expect(store.group(withID: group.groupID)?.members == [creator]) + } + + // MARK: - Rotation + + @Test func rotateKeyBumpsEpochAndReplacesKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let removed = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + #expect(store.updateRoster(groupID: group.groupID, members: [creator, removed]) != nil) + let oldKey = try #require(store.key(forGroupID: group.groupID)) + + let rotation = try #require(store.rotateKey(groupID: group.groupID, members: [creator])) + #expect(rotation.group.epoch == 2) + #expect(rotation.group.members == [creator]) + #expect(rotation.key != oldKey) + #expect(store.key(forGroupID: group.groupID) == rotation.key) + #expect(store.group(withID: group.groupID)?.epoch == 2) + } + + // MARK: - Persistence + + @Test func persistsAcrossInstances() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let creator = makeMember(seed: 0xC1, nickname: "me") + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "hike", creator: creator)) + } + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups == [group]) + #expect(reloaded.key(forGroupID: group.groupID) != nil) + } + + @Test func groupsWithoutKeysAreDroppedOnLoad() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "stale", creator: makeMember(seed: 0xC1))) + } + // Simulate a keychain wipe without the metadata file being removed. + _ = keychain.deleteAllKeychainData() + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + #expect(reloaded.group(withID: group.groupID) == nil) + } + + // MARK: - Panic wipe + + @Test func wipeRemovesMetadataAndKeys() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = GroupStore(keychain: keychain, fileURL: fileURL) + let group = try #require(store.createGroup(named: "gone", creator: makeMember(seed: 0xC1))) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + store.wipe() + + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + + // A fresh instance sees nothing either. + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + } + + @Test func removeGroupDeletesItsKey() throws { + let keychain = MockKeychain() + let store = GroupStore(keychain: keychain, persistsToDisk: false) + let group = try #require(store.createGroup(named: "bye", creator: makeMember(seed: 0xC1))) + + store.removeGroup(withID: group.groupID) + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + } +} diff --git a/bitchatTests/Services/LocationStateManagerTests.swift b/bitchatTests/Services/LocationStateManagerTests.swift index c7a950f0..46edcd6a 100644 --- a/bitchatTests/Services/LocationStateManagerTests.swift +++ b/bitchatTests/Services/LocationStateManagerTests.swift @@ -86,6 +86,46 @@ final class LocationStateManagerTests: XCTestCase { XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters) } + func test_permissionRevocation_endsLiveRefreshWithoutDiscardingExplicitState() async { + let storage = makeStorage() + let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways) + let manager = LocationStateManager( + storage: storage, + locationManager: locationManager, + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + let authorized = await waitUntil { manager.permissionState == .authorized } + XCTAssertTrue(authorized) + + manager.locationManager( + CLLocationManager(), + didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] + ) + let channelsLoaded = await waitUntil { !manager.availableChannels.isEmpty } + XCTAssertTrue(channelsLoaded) + let cachedChannels = manager.availableChannels + manager.addBookmark("u4pru") + manager.markTeleported(for: "9q8yy", true) + manager.select(.location(GeohashChannel(level: .city, geohash: "9q8yy"))) + let teleported = await waitUntil { manager.teleported } + XCTAssertTrue(teleported) + + manager.beginLiveRefresh() + XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1) + + manager.locationManager(CLLocationManager(), didChangeAuthorization: .restricted) + + let restricted = await waitUntil { manager.permissionState == .restricted } + XCTAssertTrue(restricted) + XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1) + XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters) + XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters) + XCTAssertEqual(manager.availableChannels, cachedChannels, "cached display state can remain") + XCTAssertEqual(manager.bookmarks, ["u4pru"]) + XCTAssertTrue(manager.teleported, "an explicit remote selection survives device revocation") + } + func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async { let geocoder = MockLocationGeocoder() geocoder.enqueue( diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift new file mode 100644 index 00000000..b565840d --- /dev/null +++ b/bitchatTests/Services/MeshDiagnosticsTests.swift @@ -0,0 +1,300 @@ +// +// MeshDiagnosticsTests.swift +// bitchatTests +// +// Tests for /ping and /trace command handling and the topology snapshot +// types backing the mesh topology map. +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +@Suite(.serialized) +struct MeshDiagnosticsTests { + + // MARK: - Helpers + + @MainActor + private func makeProcessor( + context: DiagnosticsMockContext, + transport: MockTransport + ) -> CommandProcessor { + CommandProcessor( + contextProvider: context, + meshService: transport, + identityManager: MockIdentityManager(MockKeychain()) + ) + } + + /// Waits for the async ping completion (MainActor hop) to land. + @MainActor + private func waitForCommandOutput(_ context: DiagnosticsMockContext) async { + for _ in 0..<100 { + if !context.commandOutputs.isEmpty { return } + await Task.yield() + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + + // MARK: - /ping + + @MainActor + @Test func pingWithoutArgumentShowsUsage() { + let context = DiagnosticsMockContext() + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping") + switch result { + case .error(let message): + #expect(message == "usage: /ping ") + default: + Issue.record("Expected usage error") + } + } + + @MainActor + @Test func pingUnknownPeerFails() { + let context = DiagnosticsMockContext() + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping @ghost") + switch result { + case .error(let message): + #expect(message == "cannot ping ghost: not found on mesh") + default: + Issue.record("Expected error result") + } + } + + @MainActor + @Test func pingGeoDMPeerIsRejected() { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899") + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping @alice") + switch result { + case .error(let message): + #expect(message == "cannot ping alice: not found on mesh") + default: + Issue.record("Expected error for geo peer") + } + } + + @MainActor + @Test func pingSuccessReportsRttAndHops() async { + let context = DiagnosticsMockContext() + let peerID = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["alice"] = peerID + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/ping @alice") + switch result { + case .success(let message): + #expect(message == "pinging alice…") + default: + Issue.record("Expected immediate 'pinging' feedback") + } + #expect(transport.sentMeshPings == [peerID]) + + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"]) + } + + @MainActor + @Test func pingDirectPeerReportsSingleHop() async { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1) + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping alice") + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"]) + } + + @MainActor + @Test func pingTimeoutReportsNoReply() async { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") + let transport = MockTransport() + transport.meshPingResult = nil + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping @alice") + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["no reply from alice"]) + } + + @MainActor + @Test func pingOutputRoutesToConversationWhereCommandWasIssued() async { + let context = DiagnosticsMockContext() + let alice = PeerID(str: "abcd1234abcd1234") + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + context.nicknameToPeerID["alice"] = alice + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) + let processor = makeProcessor(context: context, transport: transport) + + // Issue the ping from bob's DM, then switch chats before the async + // result lands. The output must follow the origin conversation, not + // whatever is selected at callback time. + context.selectedPrivateChatPeer = bob + _ = processor.process("/ping @alice") + context.selectedPrivateChatPeer = nil + + await waitForCommandOutput(context) + #expect(context.commandOutputDestinations == [.privateChat(bob)]) + } + + @MainActor + @Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async { + let context = DiagnosticsMockContext() + let alice = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["alice"] = alice + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1) + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping @alice") + // Opening a DM afterwards must not swallow the public-timeline result. + context.selectedPrivateChatPeer = alice + + await waitForCommandOutput(context) + #expect(context.commandOutputDestinations == [.meshTimeline]) + } + + // MARK: - /trace + + @MainActor + @Test func traceDirectPeerShowsOneHop() { + let context = DiagnosticsMockContext() + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + context.nicknameToPeerID["bob"] = bob + let transport = MockTransport() + transport.meshPaths[bob] = [] + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/trace @bob") + switch result { + case .success(let message): + #expect(message == "estimated path: you → bob (1 hop)") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func traceMultiHopUsesNicknamesWithShortIDFallback() { + let context = DiagnosticsMockContext() + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + let alice = PeerID(str: "a11cea11cea11cea") + let unknown = PeerID(str: "dead00beef001234") + context.nicknameToPeerID["bob"] = bob + let transport = MockTransport() + transport.peerNicknames = [alice: "alice"] + transport.meshPaths[bob] = [alice, unknown] + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/trace bob") + switch result { + case .success(let message): + #expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func traceWithoutPathReportsNoKnownPath() { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0") + let processor = makeProcessor(context: context, transport: MockTransport()) + + let result = processor.process("/trace @bob") + switch result { + case .success(let message): + #expect(message == "no known path to bob") + default: + Issue.record("Expected success result") + } + } + + // MARK: - Topology snapshot types + + @Test func topologyEdgeNormalizesEndpointOrder() { + let a = PeerID(str: "aaaa000000000000") + let b = PeerID(str: "bbbb000000000000") + #expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a)) + #expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1) + } + + @Test func topologyLayoutPlacesSelfInCenter() { + let nodes: [MeshTopologyDisplayModel.Node] = [ + .init(id: "self", label: "me", isSelf: true), + .init(id: "a", label: "alice", isSelf: false), + .init(id: "b", label: "bob", isSelf: false) + ] + let size = CGSize(width: 200, height: 200) + let positions = MeshTopologyView.layout(nodes: nodes, in: size) + + #expect(positions["self"] == CGPoint(x: 100, y: 100)) + #expect(positions.count == 3) + // Ring nodes sit on the same radius around the center. + let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100) + let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100) + #expect(abs(radiusA - radiusB) < 0.001) + #expect(radiusA > 0) + } +} + +/// Minimal CommandContextProvider for diagnostics tests; records deferred +/// command output so async /ping results can be asserted. +@MainActor +private final class DiagnosticsMockContext: CommandContextProvider { + var nickname: String = "tester" + var activeChannel: ChannelID = .mesh + var selectedPrivateChatPeer: PeerID? + var blockedUsers: Set = [] + let idBridge = NostrIdentityBridge(keychain: MockKeychain()) + + var nicknameToPeerID: [String: PeerID] = [:] + private(set) var commandOutputs: [String] = [] + private(set) var commandOutputDestinations: [CommandOutputDestination] = [] + + func getPeerIDForNickname(_ nickname: String) -> PeerID? { + nicknameToPeerID[nickname] + } + + func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] } + func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil } + func startPrivateChat(with peerID: PeerID) {} + func sendPrivateMessage(_ content: String, to peerID: PeerID) {} + func clearCurrentPublicTimeline() {} + func clearPrivateChat(_ peerID: PeerID) {} + func sendPublicRaw(_ content: String) {} + func sendPublicMessage(_ content: String) {} + func groupCreate(named name: String) -> CommandResult { .handled } + func groupInvite(nickname: String) -> CommandResult { .handled } + func groupRemove(nickname: String) -> CommandResult { .handled } + func groupLeave() -> CommandResult { .handled } + func groupList() -> CommandResult { .handled } + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {} + func addPublicSystemMessage(_ content: String) {} + func toggleFavorite(peerID: PeerID) {} + + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + return .meshTimeline + } + + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + commandOutputs.append(content) + commandOutputDestinations.append(destination) + } +} diff --git a/bitchatTests/Services/MeshSightingsTrackerTests.swift b/bitchatTests/Services/MeshSightingsTrackerTests.swift new file mode 100644 index 00000000..e6795e7b --- /dev/null +++ b/bitchatTests/Services/MeshSightingsTrackerTests.swift @@ -0,0 +1,81 @@ +// +// MeshSightingsTrackerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat +import BitFoundation + +@MainActor +struct MeshSightingsTrackerTests { + + private func makeDefaults() -> UserDefaults { + let suite = "MeshSightingsTrackerTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + private let noon = Date(timeIntervalSince1970: 1_700_000_000) + + @Test + func countsDistinctPeersOnce() { + let defaults = makeDefaults() + let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666")) + + #expect(tracker.todayCount == 2) + #expect(tracker.lastSightingAt == noon) + } + + @Test + func persistsAcrossInstances() { + let defaults = makeDefaults() + let first = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + first.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + + let second = MeshSightingsTracker(defaults: defaults, now: { self.noon.addingTimeInterval(60) }) + #expect(second.todayCount == 1) + + // Same peer again does not double count after a relaunch. + second.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + #expect(second.todayCount == 1) + } + + @Test + func rollsOverOnNewDay() { + let defaults = makeDefaults() + var currentNow = noon + let tracker = MeshSightingsTracker(defaults: defaults, now: { currentNow }) + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + #expect(tracker.todayCount == 1) + + currentNow = noon.addingTimeInterval(2 * 24 * 60 * 60) + tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666")) + + #expect(tracker.todayCount == 1) + } + + @Test + func clearResetsEverything() { + let defaults = makeDefaults() + let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + + tracker.clear() + + #expect(tracker.todayCount == 0) + #expect(tracker.lastSightingAt == nil) + + let reloaded = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + #expect(reloaded.todayCount == 0) + } +} diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift index 1ae0f5f5..fa5d2a4d 100644 --- a/bitchatTests/Services/MeshTopologyTrackerTests.swift +++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift @@ -94,10 +94,132 @@ struct MeshTopologyTrackerTests { tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a]) - + // When start == end, route should be empty (no intermediate hops needed) let route = try #require(tracker.computeRoute(from: a, to: a)) #expect(route == []) } + @Test func noPathReturnsNil() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + let d = try hex("0404040404040404") + + // Two disconnected islands: A-B and C-D + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a]) + tracker.updateNeighbors(for: c, neighbors: [d]) + tracker.updateNeighbors(for: d, neighbors: [c]) + + #expect(tracker.computeRoute(from: a, to: d) == nil) + } + + /// Build a confirmed line topology n0 - n1 - ... - n(count-1). + private func makeLine(_ tracker: MeshTopologyTracker, count: Int) throws -> [Data] { + let nodes = try (0.. 0 { neighbors.append(nodes[i - 1]) } + if i < count - 1 { neighbors.append(nodes[i + 1]) } + tracker.updateNeighbors(for: nodes[i], neighbors: neighbors) + } + return nodes + } + + @Test func maxHopsCapsIntermediateHopCount() throws { + let tracker = MeshTopologyTracker() + // 7 nodes: source + 5 intermediates + target + let nodes = try makeLine(tracker, count: 7) + + // 5 intermediates exceed a 4-hop cap + #expect(tracker.computeRoute(from: nodes[0], to: nodes[6], maxHops: 4) == nil) + // 4 intermediates fit exactly + let route = try #require(tracker.computeRoute(from: nodes[0], to: nodes[5], maxHops: 4)) + #expect(route == Array(nodes[1...4])) + } + + @Test func staleNeighborBlocksRoute() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + let staleDate = Date().addingTimeInterval(-120) // past 60s freshness + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c], at: staleDate) + tracker.updateNeighbors(for: c, neighbors: [b]) + + #expect(tracker.computeRoute(from: a, to: c) == nil) + + // Refreshing B restores the route. + tracker.updateNeighbors(for: b, neighbors: [a, c]) + let route = try #require(tracker.computeRoute(from: a, to: c)) + #expect(route == [b]) + } + + @Test func versionGateBlocksV1AndUnknownHops() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + + // Without the gate the route exists. + #expect(tracker.computeRoute(from: a, to: c) == [b]) + // Version-unknown hops are assumed v1-only and block gated routes. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + // A v1 observation does not unlock the gate. + tracker.recordObservedVersion(1, for: b) + tracker.recordObservedVersion(2, for: c) + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + // Once the hop is observed speaking v2 the route opens. + tracker.recordObservedVersion(2, for: b) + let route = try #require(tracker.computeRoute(from: a, to: c, requiringVersion: 2)) + #expect(route == [b]) + } + + @Test func versionGateRequiresV2Target() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + tracker.recordObservedVersion(2, for: b) + + // The recipient must decode the v2 frame too. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + tracker.recordObservedVersion(2, for: c) + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) + } + + @Test func pruneDropsStaleObservedVersions() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + let old = Date().addingTimeInterval(-120) + tracker.recordObservedVersion(2, for: b, at: old) + tracker.recordObservedVersion(2, for: c, at: old) + + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) + tracker.prune(olderThan: 60) + // Claims are fresh but the version observations aged out. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + } + } diff --git a/bitchatTests/Services/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift new file mode 100644 index 00000000..c54b9f99 --- /dev/null +++ b/bitchatTests/Services/MessageOutboxStoreTests.swift @@ -0,0 +1,296 @@ +// +// MessageOutboxStoreTests.swift +// bitchatTests +// +// Tests for the encrypted-at-rest outbox persistence. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct MessageOutboxStoreTests { + + private func makeTempURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("outbox-\(UUID().uuidString).sealed") + } + + private func makeMessage(_ id: String, content: String = "hello") -> MessageOutboxStore.QueuedMessage { + MessageOutboxStore.QueuedMessage( + content: content, + nickname: "peer", + messageID: id, + timestamp: Date(timeIntervalSince1970: 1_750_000_000), + sendAttempts: 2, + depositedCourierKeys: [Data(repeating: 0xC1, count: 32)] + ) + } + + @Test func roundTripAcrossInstances() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + + let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + store.save([peerID: [makeMessage("m1")]]) + + // Same keychain (encryption key) reads it back, fields intact. + let reloaded = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(reloaded[peerID]?.count == 1) + #expect(reloaded[peerID]?.first?.messageID == "m1") + #expect(reloaded[peerID]?.first?.sendAttempts == 2) + #expect(reloaded[peerID]?.first?.depositedCourierKeys.count == 1) + } + + @Test func contentIsNotPlaintextOnDisk() throws { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) + store.save([PeerID(str: "0000000000000001"): [makeMessage("m1", content: "very secret message")]]) + + let raw = try Data(contentsOf: fileURL) + #expect(!raw.isEmpty) + // Sealed bytes must not contain the message plaintext. + #expect(raw.range(of: Data("very secret message".utf8)) == nil) + } + + @Test func loadWithoutKeyReturnsEmpty() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) + store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]]) + + // A different keychain (fresh device / wiped key) cannot read the file. + let other = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) + #expect(other.load().isEmpty) + } + + @Test func permanentlyMissingDeviceKeyDiscardsOrphanAndNewSavesSurviveRelaunch() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + + MessageOutboxStore(keychain: keychain, fileURL: fileURL) + .save([peerID: [makeMessage("orphaned")]]) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + // Models a device restore: Application Support brought the sealed + // file across, but its AfterFirstUnlockThisDeviceOnly key cannot. + keychain.deleteAll(service: "chat.bitchat.outbox") + let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + #expect(restored.load().isEmpty) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + + restored.save([peerID: [makeMessage("fresh")]]) + let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(relaunched[peerID]?.map(\.messageID) == ["fresh"]) + } + + @Test func temporarilyLockedKeyDoesNotDiscardDurableSnapshot() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + + MessageOutboxStore(keychain: keychain, fileURL: fileURL) + .save([peerID: [makeMessage("durable")]]) + let durableBytes = try? Data(contentsOf: fileURL) + + keychain.simulatedGenericReadError = .deviceLocked + let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + #expect(restored.load().isEmpty) + #expect((try? Data(contentsOf: fileURL)) == durableBytes) + + keychain.simulatedGenericReadError = nil + let recovered = restored.retryDeferredLoad() + #expect(recovered?[peerID]?.map(\.messageID) == ["durable"]) + } + + @Test @MainActor + func panicWipeInvalidatesQueuedRecoveryCallback() async { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + MessageOutboxStore(keychain: keychain, fileURL: fileURL) + .save([peerID: [makeMessage("durable")]]) + + var protectedDataUnavailable = true + let restored = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + #expect(restored.load().isEmpty) + var deliveredRecoveries: [MessageOutboxStore.Snapshot] = [] + restored.setRecoveryHandler { deliveredRecoveries.append($0) } + + protectedDataUnavailable = false + restored.retryDeferredLoad() // queues the handler onto MainActor + restored.wipe() // invalidates it before the queued Task runs + await Task.yield() + + #expect(deliveredRecoveries.isEmpty) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test @MainActor + func wipeBetweenRecoveryUnlockAndNotificationDropsRecoveredSnapshot() async { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + MessageOutboxStore(keychain: keychain, fileURL: fileURL) + .save([peerID: [makeMessage("durable")]]) + + var protectedDataUnavailable = true + var gapAction: (() -> Void)? + let restored = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + }, + beforeRecoveryNotification: { gapAction?() } + ) + #expect(restored.load().isEmpty) + var deliveredRecoveries: [MessageOutboxStore.Snapshot] = [] + restored.setRecoveryHandler { deliveredRecoveries.append($0) } + gapAction = { restored.wipe() } + + protectedDataUnavailable = false + restored.retryDeferredLoad() + await Task.yield() + + #expect(deliveredRecoveries.isEmpty) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test func deferredRemovalTombstoneFiltersUnseenDurableMessage() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + MessageOutboxStore(keychain: keychain, fileURL: fileURL) + .save([peerID: [makeMessage("durable")]]) + + var protectedDataUnavailable = true + let restored = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + #expect(restored.load().isEmpty) + restored.recordRemoval(messageID: "durable") + restored.save([:]) + + protectedDataUnavailable = false + let recovered = restored.retryDeferredLoad() + #expect(recovered?.isEmpty == true) + #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) + } + + @Test func wipeRemovesFileAndKey() { + let fileURL = makeTempURL() + let keychain = MockKeychain() + let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]]) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + store.wipe() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + #expect(store.load().isEmpty) + } + + @Test func savingEmptyOutboxRemovesFile() { + let fileURL = makeTempURL() + let keychain = MockKeychain() + let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + let peerID = PeerID(str: "0000000000000001") + store.save([peerID: [makeMessage("m1")]]) + store.save([peerID: []]) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test func protectedDataReadFailureDefersWriteAndMergesOnRecovery() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + + let seed = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + seed.save([peerID: [makeMessage("durable")]]) + let durableBytes = try? Data(contentsOf: fileURL) + + var protectedDataUnavailable = true + let restored = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + #expect(restored.load().isEmpty) + restored.save([peerID: [makeMessage("during-wake")]]) + + // The unreadable durable snapshot was not replaced by the partial + // in-memory outbox from the locked restoration. + #expect((try? Data(contentsOf: fileURL)) == durableBytes) + + protectedDataUnavailable = false + let recovered = restored.retryDeferredLoad() + #expect(Set(recovered?[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"]) + + let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"]) + } + + @Test func lockedWakeRemovalDoesNotResurrectPendingMessageOnRecovery() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + MessageOutboxStore(keychain: keychain, fileURL: fileURL) + .save([peerID: [makeMessage("durable")]]) + + var protectedDataUnavailable = true + let restored = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + #expect(restored.load().isEmpty) + restored.save([peerID: [makeMessage("wake")]]) + // A later delivery ack produces the complete empty in-memory view. + restored.save([:]) + + protectedDataUnavailable = false + let recovered = restored.retryDeferredLoad() + #expect(recovered?[peerID]?.map(\.messageID) == ["durable"]) + } +} diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 13f020a9..6c9d4ea1 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -226,6 +226,650 @@ struct MessageRouterTests { #expect(transport.sentFavoriteNotifications.count == 1) } + + // MARK: - Courier deposits + + private static func snapshot(_ peerID: PeerID, key: Data, verified: Bool) -> TransportPeerSnapshot { + TransportPeerSnapshot( + peerID: peerID, + nickname: "peer", + isConnected: true, + noisePublicKey: key, + lastSeen: Date(), + isVerified: verified + ) + } + + /// Directory that resolves one offline recipient and treats a fixed key + /// set as mutual favorites. + private static func directory(recipient: PeerID, recipientKey: Data, favoriteKeys: Set = []) -> CourierDirectory { + CourierDirectory( + noiseKey: { peerID in peerID == recipient ? recipientKey : nil }, + isTrustedCourier: { favoriteKeys.contains($0) } + ) + } + + @Test @MainActor + func sendPrivate_depositsWithVerifiedStrangerWhenNoFavoriteAround() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + let courier = PeerID(str: "00000000000000cc") + let courierKey = Data(repeating: 0xCC, count: 32) + + let transport = MockTransport() + transport.connectedPeers.insert(courier) + transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv1") + + #expect(transport.sentCourierMessages.count == 1) + #expect(transport.sentCourierMessages.first?.couriers == [courier]) + } + + @Test @MainActor + func sendPrivate_neverDepositsWithUnverifiedStranger() async { + let recipient = PeerID(str: "00000000000000aa") + let courier = PeerID(str: "00000000000000cc") + + let transport = MockTransport() + transport.connectedPeers.insert(courier) + transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: false)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: Data(repeating: 0xBB, count: 32)) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv2") + + #expect(transport.sentCourierMessages.isEmpty) + } + + @Test @MainActor + func sendPrivate_prefersFavoriteCouriersOverVerifiedOnes() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + let favorite = PeerID(str: "00000000000000f0") + let favoriteKey = Data(repeating: 0xF0, count: 32) + var snapshots = [Self.snapshot(favorite, key: favoriteKey, verified: false)] + let transport = MockTransport() + transport.connectedPeers.insert(favorite) + // Three verified strangers compete for the three courier slots. + for byte: UInt8 in [0xC1, 0xC2, 0xC3] { + let peer = PeerID(str: String(format: "00000000000000%02x", byte)) + transport.connectedPeers.insert(peer) + snapshots.append(Self.snapshot(peer, key: Data(repeating: byte, count: 32), verified: true)) + } + transport.updatePeerSnapshots(snapshots) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey, favoriteKeys: [favoriteKey]) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv3") + + let couriers = transport.sentCourierMessages.first?.couriers ?? [] + #expect(couriers.count == 3) + #expect(couriers.contains(favorite)) + } + + /// Residual gap after the rotation-heal containment: a replayed "direct" + /// announce (TTL is unsigned) can bind an absent victim's peer ID to the + /// replayer's link, leaving the victim "connected" on a link whose Noise + /// handshake can never complete. The connected fast-path must not trust + /// that outright: the send still goes out (a genuine link finishes the + /// handshake), but a copy is retained and a sealed copy goes to couriers + /// so nothing is silently lost. + @Test @MainActor + func sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier() async { + let victim = PeerID(str: "00000000000000aa") + let victimKey = Data(repeating: 0xBB, count: 32) + let courier = PeerID(str: "00000000000000cc") + let courierKey = Data(repeating: 0xCC, count: 32) + + let transport = MockTransport() + transport.connectedPeers.insert(victim) + transport.connectedPeers.insert(courier) + transport.securePeers = [] // no established Noise session with anyone + transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: victim, recipientKey: victimKey) + ) + router.sendPrivate("Hello", to: victim, recipientNickname: "Peer", messageID: "cs1") + + // The send is still attempted (it kicks the handshake on a genuine + // link) but not trusted outright: a courier gets a sealed copy now … + #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs1"]) + #expect(transport.sentCourierMessages.count == 1) + #expect(transport.sentCourierMessages.first?.messageID == "cs1") + #expect(transport.sentCourierMessages.first?.recipientNoiseKey == victimKey) + #expect(transport.sentCourierMessages.first?.couriers == [courier]) + + // … and the retained copy keeps flushing until a delivery ack — a + // flush over the insecure link must resend without dropping it. + router.flushOutbox(for: victim) + router.flushOutbox(for: victim) + #expect(transport.sentPrivateMessages.count == 3) + router.markDelivered("cs1") + router.flushOutbox(for: victim) + #expect(transport.sentPrivateMessages.count == 3) + } + + /// Flushes over a connected-but-insecure link never count toward the + /// attempt-cap drop: the message was actually transmitted over a live + /// link, so a peer whose Noise handshake stalls across reconnect flapping + /// must not burn through the cap and lose the store-and-forward copy the + /// secure-session gate exists to preserve. Retention stays bounded by + /// the 24h outbox TTL and the per-peer FIFO cap; an ack clears it. + @Test @MainActor + func flushOutbox_connectedInsecureFlushesNeverDropTheRetainedCopy() async { + let peerID = PeerID(str: "00000000000000ac") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.securePeers = [] // handshake never completes + + let router = MessageRouter(transports: [transport]) + var dropped: [String] = [] + router.onMessageDropped = { messageID, _ in dropped.append(messageID) } + + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "ci1") + + // Well past maxSendAttempts (8): every flush resends, none drops. + for _ in 0..<10 { + router.flushOutbox(for: peerID) + } + #expect(dropped.isEmpty) + #expect(transport.sentPrivateMessages.count == 11) + + // The copy is still retained and an ack still clears it. + router.markDelivered("ci1") + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 11) + } + + /// With an established secure session the connected fast-path stays + /// exactly as before: trusted outright, no retained copy, no courier. + @Test @MainActor + func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async { + let peerID = PeerID(str: "00000000000000ab") + let peerKey = Data(repeating: 0xAB, count: 32) + let courier = PeerID(str: "00000000000000cc") + + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.connectedPeers.insert(courier) + transport.securePeers = [peerID] + transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: true)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: peerID, recipientKey: peerKey) + ) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "cs2") + + #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"]) + #expect(transport.sentCourierMessages.isEmpty) + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 1) + } + + @Test @MainActor + func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + let courier = PeerID(str: "00000000000000cc") + let courierKey = Data(repeating: 0xCC, count: 32) + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + // Nobody around at send time: the message just queues. + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr1") + #expect(transport.sentCourierMessages.isEmpty) + + // A verified courier appears later: the deposit retries. + transport.connectedPeers.insert(courier) + transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) + router.courierBecameAvailable(courier) + #expect(transport.sentCourierMessages.count == 1) + #expect(transport.sentCourierMessages.first?.couriers == [courier]) + + // The same courier reconnecting does not receive the same mail twice. + router.courierBecameAvailable(courier) + #expect(transport.sentCourierMessages.count == 1) + } + + @Test @MainActor + func enqueueReplacementCarriesOverDepositedCourierKeys() async { + // Re-sending a queued message ID replaces the outbox entry; the + // replacement must inherit which couriers already carry the message, + // or the deposit retry re-burns the same courier slots (duplicate + // sealed copies to the same peer). + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + let courier = PeerID(str: "00000000000000cc") + let courierKey = Data(repeating: 0xCC, count: 32) + + let transport = MockTransport() + transport.connectedPeers.insert(courier) + transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1") + #expect(transport.sentCourierMessages.count == 1) + + // Same message ID re-sent (e.g. a resend while still queued): the + // courier already carrying it must not receive a second copy. + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1") + #expect(transport.sentCourierMessages.count == 1) + } + + @Test @MainActor + func courierBecameAvailable_ignoresTheRecipientThemselves() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr2") + + // The recipient connecting is a flush, not a courier opportunity. + transport.connectedPeers.insert(recipient) + transport.updatePeerSnapshots([Self.snapshot(recipient, key: recipientKey, verified: true)]) + router.courierBecameAvailable(recipient) + #expect(transport.sentCourierMessages.isEmpty) + } + + @Test @MainActor + func bridgeDepositCarriesOnlyAfterConfirmedSendAndRetriesFailure() async { + let recipient = PeerID(str: "00000000000000ba") + let recipientKey = Data(repeating: 0xBA, count: 32) + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + var completions: [@MainActor (Bool) -> Void] = [] + router.bridgeCourierDeposit = { _, _, _, completion in + completions.append(completion) + } + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "bridge-ack") + #expect(completions.count == 1) + #expect(carried.isEmpty) + + // Sweeps while the WebSocket write is pending must not duplicate it. + router.retryBridgeCourierDeposits() + #expect(completions.count == 1) + + completions.removeFirst()(false) + #expect(carried.isEmpty) + + // A failed socket write releases the in-flight slot for a real retry. + router.retryBridgeCourierDeposits() + #expect(completions.count == 1) + completions.removeFirst()(true) + #expect(carried == ["bridge-ack"]) + } + + // MARK: - Outbox persistence + + @Test @MainActor + func queuedMessagesSurviveRouterRestart() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000dd") + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router.sendPrivate("Survive", to: peerID, recipientNickname: "Peer", messageID: "p1") + #expect(transport.sentPrivateMessages.isEmpty) + + // "App restart": a fresh router over the same store, peer now around. + let transport2 = MockTransport() + transport2.reachablePeers.insert(peerID) + let router2 = MessageRouter( + transports: [transport2], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router2.flushOutbox(for: peerID) + #expect(transport2.sentPrivateMessages.map(\.messageID) == ["p1"]) + } + + @Test @MainActor + func deliveredMessagesDoNotResurrectAfterRestart() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000de") + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router.sendPrivate("Once", to: peerID, recipientNickname: "Peer", messageID: "p2") + router.markDelivered("p2") + + let transport2 = MockTransport() + transport2.reachablePeers.insert(peerID) + let router2 = MessageRouter( + transports: [transport2], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router2.flushOutbox(for: peerID) + #expect(transport2.sentPrivateMessages.isEmpty) + } + + @Test @MainActor + func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-protected-data-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000df") + + let durable = MessageOutboxStore.QueuedMessage( + content: "Before reboot", + nickname: "Peer", + messageID: "durable", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) + + var protectedDataUnavailable = true + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + let transport = MockTransport() + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + router.sendPrivate("During wake", to: peerID, recipientNickname: "Peer", messageID: "wake") + + transport.reachablePeers.insert(peerID) + protectedDataUnavailable = false + restoredStore.retryDeferredLoad() + // Recovery is delivered to the main actor without blocking the + // protected-data notification callback. + await Task.yield() + await Task.yield() + + #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["durable", "wake"]) + } + + @Test @MainActor + func ackWhileColdLoadIsLockedDoesNotResurrectUnseenDurableMessage() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-locked-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000e0") + let durable = MessageOutboxStore.QueuedMessage( + content: "Already delivered", + nickname: "Peer", + messageID: "acked-while-locked", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) + + var protectedDataUnavailable = true + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + let transport = MockTransport() + transport.reachablePeers.insert(peerID) + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + + // The router's locked cold-load view is empty, but the ack still has + // to suppress the durable message hidden on disk. + router.markDelivered("acked-while-locked") + protectedDataUnavailable = false + restoredStore.retryDeferredLoad() + await Task.yield() + await Task.yield() + + #expect(transport.sentPrivateMessages.isEmpty) + #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) + } + + @Test @MainActor + func ackAfterRecoveryCaptureBeforeMainActorMergeCannotResurrectMessage() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-recovery-gap-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000e1") + let durable = MessageOutboxStore.QueuedMessage( + content: "Captured then acked", + nickname: "Peer", + messageID: "recovery-gap-ack", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) + + var protectedDataUnavailable = true + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + let transport = MockTransport() + transport.reachablePeers.insert(peerID) + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + + protectedDataUnavailable = false + restoredStore.retryDeferredLoad() // captures recovery and queues MainActor merge + router.markDelivered("recovery-gap-ack") // runs before that queued Task + await Task.yield() + await Task.yield() + + #expect(transport.sentPrivateMessages.isEmpty) + #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) + } + + @Test @MainActor + func enqueueAfterRecoveryCapturePreservesUnseenDurableAndNewMessages() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-recovery-gap-enqueue-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000e2") + let durable = MessageOutboxStore.QueuedMessage( + content: "Before unlock", + nickname: "Peer", + messageID: "recovery-gap-durable", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) + + var protectedDataUnavailable = true + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + let transport = MockTransport() + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + + protectedDataUnavailable = false + restoredStore.retryDeferredLoad() // queues merge of the durable message + router.sendPrivate("During gap", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-new") + transport.reachablePeers.insert(peerID) + await Task.yield() + await Task.yield() + + #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["recovery-gap-durable", "recovery-gap-new"]) + let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["recovery-gap-durable", "recovery-gap-new"]) + } + + @Test @MainActor + func removingKnownWakeMessageInRecoveryGapPreservesOnlyUnseenDurableState() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-recovery-gap-known-removal-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000e3") + let durable = MessageOutboxStore.QueuedMessage( + content: "Unseen durable", + nickname: "Peer", + messageID: "recovery-gap-unseen", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) + + var protectedDataUnavailable = true + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + let transport = MockTransport() + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + router.sendPrivate("Known wake", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-known") + + protectedDataUnavailable = false + restoredStore.retryDeferredLoad() // captures unseen durable + known wake + + // Secure direct flush removes the wake message before recovery's + // MainActor merge. It must remain removed, while the unseen durable + // message still arrives through the pending recovery claim. + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + router.flushOutbox(for: peerID) + await Task.yield() + await Task.yield() + + let sentIDs = transport.sentPrivateMessages.map(\.messageID) + #expect(sentIDs.filter { $0 == "recovery-gap-known" }.count == 1) + #expect(sentIDs.filter { $0 == "recovery-gap-unseen" }.count == 1) + } + + @Test @MainActor + func failedRecoveryWriteStillPreservesUnseenDurableStateAcrossGapRemoval() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-recovery-write-failure-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000e4") + let durable = MessageOutboxStore.QueuedMessage( + content: "Unseen before unlock", + nickname: "Peer", + messageID: "recovery-write-failure-unseen", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) + + var protectedDataUnavailable = true + var failWrites = false + var injectedFailureCount = 0 + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + }, + writeData: { data, url, options in + if failWrites { + injectedFailureCount += 1 + throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError) + } + try data.write(to: url, options: options) + } + ) + let transport = MockTransport() + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + router.sendPrivate( + "Known wake", + to: peerID, + recipientNickname: "Peer", + messageID: "recovery-write-failure-known" + ) + + // The first post-unlock save reads durable D and knows router state W, + // but its D+W write fails. A later retry must retain the original + // unseen-D classification instead of mistaking the cached union for + // a fully router-known snapshot. + protectedDataUnavailable = false + failWrites = true + router.sendPrivate( + "Known wake", + to: peerID, + recipientNickname: "Peer", + messageID: "recovery-write-failure-known" + ) + failWrites = false + #expect(injectedFailureCount == 1) + + restoredStore.retryDeferredLoad() // persists D+W and queues recovery + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + router.flushOutbox(for: peerID) // removes W before queued callback + + // The gap save may remove W, but it must leave unseen D durable until + // MessageRouter receives the pending recovery callback. + let gapSnapshot = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(gapSnapshot[peerID]?.map(\.messageID) == ["recovery-write-failure-unseen"]) + + await Task.yield() + await Task.yield() + + let sentIDs = transport.sentPrivateMessages.map(\.messageID) + #expect(sentIDs.filter { $0 == "recovery-write-failure-known" }.count == 1) + #expect(sentIDs.filter { $0 == "recovery-write-failure-unseen" }.count == 1) + } } /// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift index 5523ccdc..72d9f6be 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -111,6 +111,7 @@ final class NetworkActivationServiceTests: XCTestCase { mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, + reachabilityMonitor: AlwaysReachableMonitor(), torController: torController, relayController: relayController, proxyController: proxyController, @@ -119,7 +120,6 @@ final class NetworkActivationServiceTests: XCTestCase { return NetworkActivationTestContext( service: service, storage: storage, - permissionSubject: permissionSubject, favoritesSubject: favoritesSubject, torController: torController, relayController: relayController, @@ -147,7 +147,6 @@ final class NetworkActivationServiceTests: XCTestCase { private struct NetworkActivationTestContext { let service: NetworkActivationService let storage: UserDefaults - let permissionSubject: CurrentValueSubject let favoritesSubject: CurrentValueSubject, Never> let torController: MockNetworkActivationTorController let relayController: MockNetworkActivationRelayController diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift new file mode 100644 index 00000000..fee973aa --- /dev/null +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -0,0 +1,240 @@ +import Combine +import XCTest +@testable import bitchat + +/// Covers the reachability-gate decision logic (pure debounce) and the +/// `NetworkActivationService` wiring that suppresses Tor/relay startup when +/// there is provably no network. +@MainActor +final class NetworkReachabilityGateTests: XCTestCase { + + // MARK: - Pure debounce logic + + func test_debounce_satisfiedStaysReachable() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // An interface remains present: no change, no pending. + XCTAssertNil(d.observe(reachable: true, at: t0)) + XCTAssertTrue(d.committed) + XCTAssertFalse(d.hasPendingChange) + } + + func test_debounce_unsatisfiedSuppressesAfterInterval() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // Path drops: not committed immediately (within debounce window). + XCTAssertNil(d.observe(reachable: false, at: t0)) + XCTAssertTrue(d.committed) + XCTAssertTrue(d.hasPendingChange) + // Still within window. + XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0))) + XCTAssertTrue(d.committed) + // Past the window: commit unreachable. + XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false) + XCTAssertFalse(d.committed) + XCTAssertFalse(d.hasPendingChange) + } + + func test_debounce_flapIsIgnored() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // Drop then recover well within the window — must never commit a change. + XCTAssertNil(d.observe(reachable: false, at: t0)) + XCTAssertTrue(d.hasPendingChange) + XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5))) + XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop") + // A late flush after the original deadline is a no-op (nothing pending). + XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0))) + XCTAssertTrue(d.committed) + } + + func test_debounce_recoverAfterOutageCommitsAfterInterval() { + var d = ReachabilityDebounce(interval: 2.5, initial: false) + let t0 = Date() + XCTAssertNil(d.observe(reachable: true, at: t0)) + XCTAssertTrue(d.hasPendingChange) + XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true) + XCTAssertTrue(d.committed) + } + + func test_debounce_duplicateObservationsPreservePendingDeadline() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + XCTAssertNil(d.observe(reachable: false, at: t0)) + // Duplicate unsatisfied updates mid-window keep the original deadline. + XCTAssertNil(d.observe(reachable: false, at: t0.addingTimeInterval(1.0))) + XCTAssertEqual(d.pendingRemaining(at: t0.addingTimeInterval(1.0)), 1.5) + // A duplicate arriving past the deadline commits immediately. + XCTAssertEqual(d.observe(reachable: false, at: t0.addingTimeInterval(2.5)), false) + XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5))) + } + + func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async { + let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0) + var received: [Bool] = [] + let cancellable = monitor.reachabilityPublisher.sink { received.append($0) } + defer { cancellable.cancel() } + + let start = Date() + monitor.ingest(reachable: false) + try? await Task.sleep(nanoseconds: 500_000_000) + // Duplicate unsatisfied update mid-window (e.g. interface detail change + // while still offline) must not restart the debounce window. + monitor.ingest(reachable: false) + + let committed = await waitUntil(timeout: 2.0) { !received.isEmpty } + XCTAssertTrue(committed) + XCTAssertEqual(received, [false]) + // The flush must fire at the original ~1.0s deadline, not ~1.5s + // (a full interval after the duplicate). + XCTAssertLessThan(Date().timeIntervalSince(start), 1.4) + } + + // MARK: - Service gating + + func test_start_whenUnreachable_suppressesTorAndRelays() { + let ctx = makeService(permission: .authorized, reachable: false) + ctx.service.start() + + XCTAssertFalse(ctx.service.activationAllowed) + XCTAssertFalse(ctx.service.isNetworkReachable) + XCTAssertTrue(ctx.reachability.startCalled) + XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0) + XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false]) + XCTAssertEqual(ctx.relayController.connectCallCount, 0) + XCTAssertEqual(ctx.relayController.disconnectCallCount, 1) + } + + func test_start_whenReachable_allowsTorAndRelays() { + let ctx = makeService(permission: .authorized, reachable: true) + ctx.service.start() + + XCTAssertTrue(ctx.service.activationAllowed) + XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1) + XCTAssertEqual(ctx.relayController.connectCallCount, 1) + } + + func test_reachabilityRecovery_resumesTorAndRelays() async { + let ctx = makeService(permission: .authorized, reachable: false) + ctx.service.start() + XCTAssertFalse(ctx.service.activationAllowed) + + ctx.reachability.set(true) + let resumed = await waitUntil { ctx.service.activationAllowed } + XCTAssertTrue(resumed) + XCTAssertTrue(ctx.service.isNetworkReachable) + XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1) + XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1) + } + + func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async { + let ctx = makeService(permission: .authorized, reachable: true) + ctx.service.start() + XCTAssertTrue(ctx.service.activationAllowed) + let disconnectsBefore = ctx.relayController.disconnectCallCount + + ctx.reachability.set(false) + let suppressed = await waitUntil { !ctx.service.activationAllowed } + XCTAssertTrue(suppressed) + XCTAssertFalse(ctx.service.isNetworkReachable) + XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore) + XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false)) + XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1) + } + + // MARK: - Harness + + private func makeService( + permission: LocationChannelManager.PermissionState, + reachable: Bool + ) -> Context { + let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + + let permissionSubject = CurrentValueSubject(permission) + let favoritesSubject = CurrentValueSubject, Never>([]) + let reachability = ControllableReachabilityMonitor(initial: reachable) + let torController = GateMockTorController() + let relayController = GateMockRelayController() + let proxyController = GateMockProxyController() + let service = NetworkActivationService( + storage: storage, + locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), + mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), + permissionProvider: { permissionSubject.value }, + mutualFavoritesProvider: { favoritesSubject.value }, + reachabilityMonitor: reachability, + torController: torController, + relayController: relayController, + proxyController: proxyController, + notificationCenter: NotificationCenter() + ) + return Context( + service: service, + reachability: reachability, + torController: torController, + relayController: relayController + ) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +@MainActor +private struct Context { + let service: NetworkActivationService + let reachability: ControllableReachabilityMonitor + let torController: GateMockTorController + let relayController: GateMockRelayController +} + +@MainActor +private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring { + private let subject: CurrentValueSubject + private(set) var startCalled = false + + init(initial: Bool) { + subject = CurrentValueSubject(initial) + } + + var isReachable: Bool { subject.value } + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + func start() { startCalled = true } + func set(_ reachable: Bool) { subject.send(reachable) } +} + +@MainActor +private final class GateMockTorController: NetworkActivationTorControlling { + private(set) var autoStartAllowedValues: [Bool] = [] + private(set) var startIfNeededCallCount = 0 + private(set) var shutdownCompletelyCallCount = 0 + func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) } + func startIfNeeded() { startIfNeededCallCount += 1 } + func shutdownCompletely() { shutdownCompletelyCallCount += 1 } +} + +@MainActor +private final class GateMockRelayController: NetworkActivationRelayControlling { + private(set) var connectCallCount = 0 + private(set) var disconnectCallCount = 0 + func connect() { connectCallCount += 1 } + func disconnect() { disconnectCallCount += 1 } +} + +private final class GateMockProxyController: NetworkActivationProxyControlling { + private(set) var proxyModes: [Bool] = [] + func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } +} diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index fbb46060..6deaa2b6 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -345,6 +345,121 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0) } + func test_sendEventImmediately_allRejectsFailThenOKRetrySucceeds() async throws { + let relays = [ + "wss://confirmed-reject-one.example", + "wss://confirmed-reject-two.example" + ] + let context = makeContext(permission: .denied) + context.manager.ensureConnections(to: relays) + let connected = await waitUntil { + relays.allSatisfy { relay in + context.manager.relays.first(where: { $0.url == relay })?.isConnected == true + } + } + XCTAssertTrue(connected) + + let event = try makeSignedEvent(content: "confirmed reject then retry") + var results: [Bool] = [] + var completionsWereOnMain: [Bool] = [] + context.manager.sendEventImmediately(event, to: relays) { + results.append($0) + completionsWereOnMain.append(Thread.isMainThread) + } + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertTrue(results.isEmpty, "socket writes alone must not confirm durability") + for relay in relays { + try context.sessionFactory.latestConnection(for: relay)?.emitOK( + eventID: event.id, + success: false, + reason: "rejected" + ) + } + let failedCompleted = await waitUntil { results.count == 1 } + XCTAssertTrue(failedCompleted) + XCTAssertEqual(results, [false]) + XCTAssertEqual(completionsWereOnMain, [true]) + XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0) + + // The explicit rejection leaves the same event retryable. + try? await Task.sleep(nanoseconds: 20_000_000) + context.manager.sendEventImmediately(event, to: relays) { + results.append($0) + completionsWereOnMain.append(Thread.isMainThread) + } + try context.sessionFactory.latestConnection(for: relays[0])?.emitOK( + eventID: event.id, + success: true, + reason: "accepted" + ) + let successfulCompleted = await waitUntil { results.count == 2 } + XCTAssertTrue(successfulCompleted) + XCTAssertEqual(results, [false, true]) + XCTAssertEqual(completionsWereOnMain, [true, true]) + XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0) + } + + func test_sendEventImmediately_mixedRelayOKUsesAcceptedResultOnce() async throws { + let relays = ["wss://confirmed-mixed-one.example", "wss://confirmed-mixed-two.example"] + let context = makeContext(permission: .denied) + context.manager.ensureConnections(to: relays) + let connected = await waitUntil { + relays.allSatisfy { relay in + context.manager.relays.first(where: { $0.url == relay })?.isConnected == true + } + } + XCTAssertTrue(connected) + + let event = try makeSignedEvent(content: "mixed confirmation") + var results: [Bool] = [] + context.manager.sendEventImmediately(event, to: relays) { results.append($0) } + try context.sessionFactory.latestConnection(for: relays[0])?.emitOK( + eventID: event.id, + success: false, + reason: "policy" + ) + try context.sessionFactory.latestConnection(for: relays[1])?.emitOK( + eventID: event.id, + success: true, + reason: "accepted" + ) + + let completed = await waitUntil { results.count == 1 } + XCTAssertTrue(completed) + XCTAssertEqual(results, [true]) + } + + func test_sendEventImmediately_timeoutFailsAndIgnoresLateWriteAndOK() async throws { + let relay = "wss://confirmed-timeout.example" + let context = makeContext(permission: .denied) + context.manager.ensureConnections(to: [relay]) + let connected = await waitUntil { + context.manager.relays.first(where: { $0.url == relay })?.isConnected == true + } + XCTAssertTrue(connected) + let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relay)) + connection.deferSendCompletions = true + + let event = try makeSignedEvent(content: "confirmation timeout") + var results: [Bool] = [] + context.manager.sendEventImmediately(event, to: [relay]) { results.append($0) } + XCTAssertTrue(results.isEmpty) + XCTAssertEqual( + context.scheduler.scheduled.first?.delay, + TransportConfig.nostrConfirmedSendAckTimeoutSeconds + ) + + context.scheduler.runNext() + let timedOut = await waitUntil { results.count == 1 } + XCTAssertTrue(timedOut) + XCTAssertEqual(results, [false]) + + connection.flushDeferredSendCompletions() + try connection.emitOK(eventID: event.id, success: true, reason: "late") + try? await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(results, [false]) + } + func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws { let context = makeContext( permission: .authorized, @@ -1158,6 +1273,38 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(firstConnection.cancelCallCount, 1) } + func test_staleSocketFailureCannotFailConfirmedSendOnReplacementConnection() async throws { + let relayURL = "wss://retry-stale-confirmation.example" + let context = makeContext(permission: .denied) + context.manager.ensureConnections(to: [relayURL]) + let initiallyConnected = await waitUntil { + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(initiallyConnected) + let oldConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL)) + + context.manager.retryConnection(to: relayURL) + let replaced = await waitUntil { + guard let current = context.sessionFactory.latestConnection(for: relayURL) else { return false } + return current !== oldConnection && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(replaced) + let currentConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL)) + + let event = try makeSignedEvent(content: "replacement confirmation") + var results: [Bool] = [] + context.manager.sendEventImmediately(event, to: [relayURL]) { results.append($0) } + oldConnection.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)) + try? await Task.sleep(nanoseconds: 30_000_000) + XCTAssertTrue(results.isEmpty) + XCTAssertTrue(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true) + + try currentConnection.emitOK(eventID: event.id, success: true, reason: "accepted") + let confirmed = await waitUntil { results == [true] } + XCTAssertTrue(confirmed) + } + func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async { let relayURL = "wss://retry-tor.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true) @@ -1483,7 +1630,6 @@ final class NostrRelayManagerTests: XCTestCase { return RelayManagerTestContext( manager: manager, permissionSubject: permissionSubject, - favoritesSubject: favoritesSubject, sessionFactory: sessionFactory, scheduler: scheduler, clock: clock, @@ -1537,7 +1683,6 @@ final class NostrRelayManagerTests: XCTestCase { private struct RelayManagerTestContext { let manager: NostrRelayManager let permissionSubject: CurrentValueSubject - let favoritesSubject: CurrentValueSubject, Never> let sessionFactory: MockRelaySessionFactory let scheduler: MockRelayScheduler let clock: MutableClock @@ -1644,9 +1789,8 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol { } private final class MockRelayConnection: NostrRelayConnectionProtocol { - private let url: String private let pingError: Error? - private let sendError: Error? + var sendError: Error? private var receiveHandler: ((Result) -> Void)? private(set) var resumeCallCount = 0 private(set) var cancelCallCount = 0 @@ -1662,8 +1806,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } } - init(url: String, pingError: Error? = nil, sendError: Error? = nil) { - self.url = url + init(url _: String, pingError: Error? = nil, sendError: Error? = nil) { self.pingError = pingError self.sendError = sendError } @@ -1691,7 +1834,9 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { func flushDeferredSendCompletions() { let pending = deferredSendCompletions deferredSendCompletions = [] - pending.forEach { $0(sendError) } + pending.forEach { + $0(sendError) + } } func receive(completionHandler: @escaping (Result) -> Void) { diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index 1af2bc46..e8fd19b5 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -6,6 +6,7 @@ // For more information, see // +import Combine import Foundation import Testing import BitFoundation @@ -42,7 +43,9 @@ struct NostrTransportTests { ) ) - #expect(!transport.isPeerReachable(fullPeerID)) + // Offline favorites are addressed by the full 64-hex noise key, so + // both forms must resolve to the same reachability answer. + #expect(transport.isPeerReachable(fullPeerID)) #expect(transport.isPeerReachable(shortPeerID)) #expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed"))) } @@ -83,6 +86,51 @@ struct NostrTransportTests { #expect(didRefresh) } + @Test("Prompt delivery requires both a known npub and a relay connection") + @MainActor + func canDeliverPromptlyTracksRelayConnectivity() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let recipient = try NostrIdentity.generate() + let noiseKey = Data((0..<32).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Alice" + ) + let connectivity = CurrentValueSubject(false) + + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + loadFavorites: { [noiseKey: relationship] }, + relayConnectivity: { connectivity.eraseToAnyPublisher() } + ) + ) + + // Reachable (npub known) but relays down: the peer must not be + // treated as promptly deliverable, or the router would skip the + // courier and let the message rot in the Nostr send queue. + #expect(transport.isPeerReachable(peerID)) + #expect(!transport.canDeliverPromptly(to: peerID)) + + connectivity.send(true) + let deliverable = await TestHelpers.waitUntil( + { transport.canDeliverPromptly(to: peerID) }, + timeout: 5.0 + ) + #expect(deliverable) + + connectivity.send(false) + let undeliverable = await TestHelpers.waitUntil( + { !transport.canDeliverPromptly(to: peerID) }, + timeout: 5.0 + ) + #expect(undeliverable) + } + @Test("Private message resolves short peer ID and emits decryptable packet") @MainActor func sendPrivateMessageResolvesShortPeerID() async throws { @@ -373,7 +421,8 @@ struct NostrTransportTests { currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil }, registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in }, sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in }, - scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in } + scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }, + relayConnectivity: @escaping @MainActor () -> AnyPublisher = { Just(false).eraseToAnyPublisher() } ) -> NostrTransport.Dependencies { NostrTransport.Dependencies( notificationCenter: notificationCenter, @@ -383,7 +432,8 @@ struct NostrTransportTests { currentIdentity: currentIdentity, registerPendingGiftWrap: registerPendingGiftWrap, sendEvent: sendEvent, - scheduleAfter: scheduleAfter + scheduleAfter: scheduleAfter, + relayConnectivity: relayConnectivity ) } diff --git a/bitchatTests/Services/PrivateChatManagerTests.swift b/bitchatTests/Services/PrivateChatManagerTests.swift index a986570d..69b49aaf 100644 --- a/bitchatTests/Services/PrivateChatManagerTests.swift +++ b/bitchatTests/Services/PrivateChatManagerTests.swift @@ -108,6 +108,76 @@ struct PrivateChatManagerTests { #expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback") } + @Test @MainActor + func markAsRead_calledTwiceSynchronously_routesOneReceiptPerMessage() async { + // Regression: opening a chat runs two read scans in the same + // synchronous MainActor stretch (beginPrivateChatSession and + // markPrivateMessagesAsRead). The receipt must be claimed before the + // routing task gets a chance to run, or both scans route a copy. + let transport = MockTransport() + let router = MessageRouter(transports: [transport]) + let (manager, store) = Self.makeManager(transport: transport) + manager.messageRouter = router + + let peerID = PeerID(str: "00000000000000DE") + transport.reachablePeers.insert(peerID) + + store.append( + BitchatMessage( + id: "pm-double", + sender: "Peer", + content: "Hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + to: .directPeer(peerID) + ) + store.markUnread(.directPeer(peerID)) + + manager.markAsRead(from: peerID) + manager.markAsRead(from: peerID) + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(transport.sentReadReceipts.count == 1) + #expect(manager.sentReadReceipts.contains("pm-double")) + } + + @Test @MainActor + func markAsRead_failedRouteReleasesClaimForRetry() async { + // No reachable transport: the receipt is not sent, and the eager + // claim must be released so a later read scan retries. + let transport = MockTransport() + let router = MessageRouter(transports: [transport]) + let (manager, store) = Self.makeManager(transport: transport) + manager.messageRouter = router + + let peerID = PeerID(str: "00000000000000DF") + + store.append( + BitchatMessage( + id: "pm-unroutable", + sender: "Peer", + content: "Hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + to: .directPeer(peerID) + ) + store.markUnread(.directPeer(peerID)) + + manager.markAsRead(from: peerID) + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(transport.sentReadReceipts.isEmpty) + #expect(!manager.sentReadReceipts.contains("pm-unroutable")) + } + @Test @MainActor func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async { let transport = MockTransport() diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift index c0c473cf..cb18ec64 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -134,6 +134,65 @@ struct RelayControllerTests { #expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) } + @Test + func voiceFrame_relaysWithFragmentPolicy() async { + // Sparse graph: fragment cap, tight jitter (multi-hop latency must + // stay inside the receiver's jitter buffer). + let sparse = RelayController.decide( + ttl: 7, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + isVoiceFrame: true, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + #expect(sparse.shouldRelay) + #expect(sparse.newTTL == min(UInt8(7), TransportConfig.bleFragmentRelayTtlCap) &- 1) + #expect(sparse.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs) + #expect(sparse.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs) + + // Dense graph: harder clamp contains the sustained per-talker stream. + let dense = RelayController.decide( + ttl: 7, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + isVoiceFrame: true, + degree: TransportConfig.bleHighDegreeThreshold, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + #expect(dense.shouldRelay) + #expect(dense.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) + } + + @Test + func requestSync_neverRelaysEvenWithTTLHeadroom() async { + let decision = RelayController.decide( + ttl: 7, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + isRequestSync: true, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(!decision.shouldRelay) + } + @Test func denseGraph_capsTTL() async { let decision = RelayController.decide( diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index 27e57fdf..a280d02e 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -496,4 +496,8 @@ private final class FailingCacheSaveKeychain: KeychainManagerProtocol { func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } + + func deleteAll(service: String) { + serviceStorage.removeValue(forKey: service) + } } diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift new file mode 100644 index 00000000..6ab46490 --- /dev/null +++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift @@ -0,0 +1,335 @@ +import Foundation +import Testing + +@testable import bitchat + +/// Vouch storage, accept-policy gates, derived trust levels, and persistence +/// compatibility for `SecureIdentityStateManager`. +/// +/// Ordering note: mutations use barrier blocks on the manager's concurrent +/// queue and reads use `queue.sync`, so a read submitted after a mutation +/// always observes it — no polling needed. +/// +/// `@MainActor` matches production (the manager's vouch API is driven by the +/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync` +/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift +/// Testing runs these tests in parallel on that pool, and on CI's few-core +/// runners every pool thread ended up parked in `queue.sync` behind a pending +/// `queue.async(.barrier)` write that never got a dispatch worker — a +/// process-wide deadlock (watchdog SIGKILL, exit 137). +@MainActor +struct SecureIdentityStateManagerVouchTests { + private let voucher = String(repeating: "0a", count: 32) + private let vouchee = String(repeating: "0b", count: 32) + + private func makeManager() -> SecureIdentityStateManager { + SecureIdentityStateManager(MockKeychain()) + } + + // MARK: - Accept-policy gates + + @Test + func recordVouch_rejectsUnverifiedVoucher() { + let manager = makeManager() + + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: vouchee).isEmpty) + + manager.setVerified(fingerprint: voucher, verified: true) + #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: vouchee).count == 1) + } + + @Test + func recordVouch_ignoresSelfVouch() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + #expect(!manager.recordVouch(voucheeFingerprint: voucher, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: voucher).isEmpty) + } + + @Test + func recordVouch_ignoresAlreadyVerifiedVouchee() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.setVerified(fingerprint: vouchee, verified: true) + + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(!manager.isVouched(fingerprint: vouchee)) + } + + @Test + func recordVouch_rejectsStaleAndFarFutureTimestamps() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + let stale = Date().addingTimeInterval(-31 * 24 * 60 * 60) + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: stale)) + + let farFuture = Date().addingTimeInterval(2 * 60 * 60) + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: farFuture)) + + #expect(manager.validVouchers(for: vouchee).isEmpty) + } + + @Test + func recordVouch_capsVouchersPerVoucheeKeepingMostRecent() { + let manager = makeManager() + let base = Date() + + // 9 verified vouchers vouch with strictly increasing timestamps. + let vouchers = (0..<9).map { String(format: "%02x", $0 + 0x10) + String(repeating: "00", count: 31) } + for (index, voucherFingerprint) in vouchers.enumerated() { + manager.setVerified(fingerprint: voucherFingerprint, verified: true) + let stored = manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: voucherFingerprint, + timestamp: base.addingTimeInterval(TimeInterval(index)), + now: base.addingTimeInterval(TimeInterval(index)) + ) + #expect(stored) + } + + let records = manager.validVouchers(for: vouchee) + #expect(records.count == SecureIdentityStateManager.maxVouchersPerVouchee) + // The oldest voucher fell off the end. + #expect(!records.contains { $0.voucherFingerprint == vouchers[0] }) + #expect(records.contains { $0.voucherFingerprint == vouchers[8] }) + + // An attestation older than everything retained is not stored. + let older = String(repeating: "0c", count: 32) + manager.setVerified(fingerprint: older, verified: true) + #expect(!manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: older, + timestamp: base.addingTimeInterval(-1), + now: base + )) + + // A repeat vouch from a retained voucher refreshes, not duplicates. + #expect(manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: vouchers[8], + timestamp: base.addingTimeInterval(100), + now: base.addingTimeInterval(100) + )) + #expect(manager.validVouchers(for: vouchee).count == SecureIdentityStateManager.maxVouchersPerVouchee) + } + + // MARK: - Derived trust & invalidation + + @Test + func unverifyingVoucher_invalidatesTheirVouchesWithoutDeletingThem() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + #expect(manager.isVouched(fingerprint: vouchee)) + + // Removing my verification of the voucher retires their vouches… + manager.setVerified(fingerprint: voucher, verified: false) + #expect(!manager.isVouched(fingerprint: vouchee)) + #expect(manager.validVouchers(for: vouchee).isEmpty) + + // …but the records survive: re-verifying the voucher restores them + // (recompute on read, no cascade delete). + manager.setVerified(fingerprint: voucher, verified: true) + #expect(manager.isVouched(fingerprint: vouchee)) + } + + @Test + func validVouchers_expireAtReadTime() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + let now = Date() + let timestamp = now.addingTimeInterval(-29 * 24 * 60 * 60) + #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: timestamp, now: now)) + #expect(manager.isVouched(fingerprint: vouchee, now: now)) + + let twoDaysLater = now.addingTimeInterval(2 * 24 * 60 * 60) + #expect(manager.validVouchers(for: vouchee, now: twoDaysLater).isEmpty) + #expect(!manager.isVouched(fingerprint: vouchee, now: twoDaysLater)) + } + + @Test + func effectiveTrustLevel_slotsVouchedBetweenCasualAndTrusted() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + // Unknown peer with a valid vouch reads as vouched. + #expect(manager.effectiveTrustLevel(for: vouchee) == .unknown) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + #expect(manager.effectiveTrustLevel(for: vouchee) == .vouched) + + // Explicit trust outranks a vouch. + manager.updateSocialIdentity(SocialIdentity( + fingerprint: vouchee, + localPetname: nil, + claimedNickname: "bob", + trustLevel: .trusted, + isFavorite: false, + isBlocked: false, + notes: nil + )) + #expect(manager.effectiveTrustLevel(for: vouchee) == .trusted) + + // Explicit verification outranks everything. + manager.setVerified(fingerprint: vouchee, verified: true) + #expect(manager.effectiveTrustLevel(for: vouchee) == .verified) + #expect(!manager.isVouched(fingerprint: vouchee)) + + // Losing the voucher downgrades vouched back to the stored level. + manager.setVerified(fingerprint: vouchee, verified: false) + manager.setVerified(fingerprint: voucher, verified: false) + #expect(manager.effectiveTrustLevel(for: vouchee) == .casual) + } + + // MARK: - Exchange-policy state + + @Test + func mostRecentlyVerifiedFingerprints_ordersAndExcludes() { + let manager = makeManager() + let first = String(repeating: "01", count: 32) + let second = String(repeating: "02", count: 32) + let third = String(repeating: "03", count: 32) + manager.setVerified(fingerprint: first, verified: true) + manager.setVerified(fingerprint: second, verified: true) + manager.setVerified(fingerprint: third, verified: true) + + let ordered = manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: third) + #expect(ordered == [second, first]) + + let limited = manager.mostRecentlyVerifiedFingerprints(limit: 1, excluding: third) + #expect(limited == [second]) + } + + @Test + func vouchBatchSentAt_roundTrips() { + let manager = makeManager() + #expect(manager.lastVouchBatchSent(to: voucher) == nil) + + let sentAt = Date(timeIntervalSince1970: 1_700_000_000) + manager.markVouchBatchSent(to: voucher, at: sentAt) + #expect(manager.lastVouchBatchSent(to: voucher) == sentAt) + } + + @Test + func signingPublicKey_returnsAnnounceBoundKeyByFingerprint() async { + let manager = makeManager() + let signingKey = Data(repeating: 0x22, count: 32) + manager.upsertCryptographicIdentity( + fingerprint: voucher, + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: signingKey, + claimedNickname: nil + ) + + let stored = await waitUntil { manager.signingPublicKey(forFingerprint: voucher) == signingKey } + #expect(stored) + #expect(manager.signingPublicKey(forFingerprint: vouchee) == nil) + } + + // MARK: - Panic wipe + + @Test + func clearAllIdentityData_wipesVouchState() async { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + manager.markVouchBatchSent(to: voucher, at: Date()) + #expect(manager.isVouched(fingerprint: vouchee)) + + manager.clearAllIdentityData() + + let wiped = await waitUntil { !manager.isVouched(fingerprint: vouchee) } + #expect(wiped) + #expect(manager.validVouchers(for: vouchee).isEmpty) + #expect(manager.lastVouchBatchSent(to: voucher) == nil) + #expect(manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: "").isEmpty) + } + + // MARK: - Persistence compatibility + + @Test + func trustLevelRawValuesAreStable() throws { + // Raw values are what's persisted; they must never change when cases + // are added mid-ladder. + #expect(TrustLevel.unknown.rawValue == "unknown") + #expect(TrustLevel.casual.rawValue == "casual") + #expect(TrustLevel.vouched.rawValue == "vouched") + #expect(TrustLevel.trusted.rawValue == "trusted") + #expect(TrustLevel.verified.rawValue == "verified") + + let legacy = Data(#"["unknown","casual","trusted","verified"]"#.utf8) + let decoded = try JSONDecoder().decode([TrustLevel].self, from: legacy) + #expect(decoded == [.unknown, .casual, .trusted, .verified]) + } + + @Test + func identityCachePersistedBeforeVouchingDecodesCleanly() throws { + // A cache captured before the vouch fields existed must decode without + // tripping the "unreadable cache" recovery path. + let legacyJSON = Data(""" + { + "socialIdentities": {}, + "nicknameIndex": {}, + "verifiedFingerprints": ["\(voucher)"], + "lastInteractions": {}, + "blockedNostrPubkeys": [], + "version": 1 + } + """.utf8) + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON) + #expect(decoded.vouchesByVouchee == nil) + #expect(decoded.vouchBatchSentAt == nil) + #expect(decoded.verifiedAt == nil) + #expect(decoded.verifiedFingerprints == [voucher]) + } + + @Test + func identityCacheRoundTripsVouchState() throws { + var cache = IdentityCache() + cache.verifiedFingerprints = [voucher] + cache.vouchesByVouchee = [vouchee: [VouchRecord(voucherFingerprint: voucher, timestamp: Date(timeIntervalSince1970: 1_700_000_000))]] + cache.vouchBatchSentAt = [voucher: Date(timeIntervalSince1970: 1_700_000_001)] + cache.verifiedAt = [voucher: Date(timeIntervalSince1970: 1_700_000_002)] + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache)) + #expect(decoded.vouchesByVouchee == cache.vouchesByVouchee) + #expect(decoded.vouchBatchSentAt == cache.vouchBatchSentAt) + #expect(decoded.verifiedAt == cache.verifiedAt) + } + + @Test + func vouchStateSurvivesReload() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + let saved = await waitUntil { manager.isVouched(fingerprint: self.vouchee) } + #expect(saved) + manager.forceSave() + + let reloaded = SecureIdentityStateManager(keychain) + #expect(reloaded.isVouched(fingerprint: vouchee)) + #expect(reloaded.validVouchers(for: vouchee).count == 1) + } + + // MARK: - Helpers + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} diff --git a/bitchatTests/Services/UnifiedNoticesTests.swift b/bitchatTests/Services/UnifiedNoticesTests.swift new file mode 100644 index 00000000..3dd369b4 --- /dev/null +++ b/bitchatTests/Services/UnifiedNoticesTests.swift @@ -0,0 +1,142 @@ +// +// UnifiedNoticesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct UnifiedNoticesTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + + private func makePost( + content: String, + nickname: String = "alice", + createdAt: UInt64? = nil, + urgent: Bool = false + ) -> BoardPostPacket { + BoardPostPacket( + postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }), + geohash: "9q8yy", + content: content, + authorSigningKey: Data(repeating: 1, count: 32), + authorNickname: nickname, + createdAt: createdAt ?? baseMs, + expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, + flags: urgent ? BoardPostPacket.urgentFlag : 0, + signature: Data(repeating: 2, count: 64) + ) + } + + private func makeNote( + content: String, + nickname: String? = "alice", + createdAt: Date? = nil, + geohash: String = "9q8yy" + ) -> LocationNotesManager.Note { + LocationNotesManager.Note( + id: UUID().uuidString, + pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""), + content: content, + createdAt: createdAt ?? baseDate, + nickname: nickname, + geohash: geohash + ) + } + + @Test + func merge_dropsBridgedCopyOfBoardPost() { + let post = makePost(content: "free couch on 5th") + let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30)) + + let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) + + #expect(merged.count == 1) + #expect(merged[0].isBoardPost) + } + + @Test + func merge_keepsNoteWithSameContentOutsideWindow() { + let post = makePost(content: "water station here") + let oldNote = makeNote( + content: "water station here", + createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60) + ) + + let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote]) + + #expect(merged.count == 2) + } + + @Test + func merge_keepsSameTextNoteFromNeighborCell() { + // The notes subscription covers the center cell plus 8 neighbors; a + // matching note posted to a *neighbor* is not the bridged copy. + let post = makePost(content: "free couch on 5th") + let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz") + + let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote]) + + #expect(merged.count == 2) + } + + @Test + func merge_keepsNoteFromDifferentAuthor() { + let post = makePost(content: "meetup at 6", nickname: "alice") + let note = makeNote(content: "meetup at 6", nickname: "bob") + + let merged = UnifiedNotices.merge(posts: [post], notes: [note]) + + #expect(merged.count == 2) + } + + @Test + func merge_sortsUrgentFirstThenNewest() { + let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true) + let newerPost = makePost(content: "later post", createdAt: baseMs) + let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30)) + + let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note]) + + #expect(merged.map(\.content) == ["road closed", "a note", "later post"]) + #expect(merged[0].isUrgent) + } + + @Test + func merge_anonNicknamesMatchForDedupe() { + // Bridged posts from an empty nickname arrive as anon notes with no + // "n" tag; they must still dedupe against the anon board copy. + let post = makePost(content: "hello", nickname: "") + let bridged = makeNote(content: "hello", nickname: nil) + + let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) + + #expect(merged.count == 1) + #expect(merged[0].isBoardPost) + #expect(merged[0].author == "anon") + } + + @Test + func noticeItem_normalizesNoteDisplayName() { + let note = LocationNotesManager.Note( + id: "e1", + pubkey: "deadbeef", + content: "hi", + createdAt: baseDate, + nickname: "dave", + geohash: "9q8yy" + ) + + let item = NoticeItem(note: note) + + #expect(item.author == "dave") + #expect(!item.isBoardPost) + #expect(!item.isUrgent) + } +} diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index c910af9b..a81003ec 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -41,6 +41,160 @@ struct UnifiedPeerServiceTests { #expect(service.isBlocked(peerID)) } + + @Test @MainActor + func setBlocked_persistsByFingerprintAndToggles() async { + let transport = MockTransport() + let identity = TestIdentityManager() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) + + let peerID = PeerID(str: "00000000000000EE") + let fingerprint = "fp-target" + transport.peerFingerprints[peerID] = fingerprint + + // Blocking resolves and persists by the peer's fingerprint, and + // scrubs the peer's carried public messages from the gossip archive + // while the fingerprint↔peerID mapping is still known (the + // archived-echo seed filter can't resolve offline strangers). + let resolved = service.setBlocked(peerID, blocked: true) + #expect(resolved == fingerprint) + #expect(identity.isBlocked(fingerprint: fingerprint)) + #expect(service.isBlocked(peerID)) + #expect(transport.purgedArchivePeers == [peerID]) + + // Unblocking clears it against the same identity, without purging. + let unresolved = service.setBlocked(peerID, blocked: false) + #expect(unresolved == fingerprint) + #expect(!identity.isBlocked(fingerprint: fingerprint)) + #expect(!service.isBlocked(peerID)) + #expect(transport.purgedArchivePeers == [peerID]) + } + + // MARK: - Offline-favorite dedup (updatePeers phase 2) + + /// A mutual favorite that is also on the mesh must collapse to a single + /// row keyed by the short mesh ID — even when the announced nickname no + /// longer matches the one stored with the favorite. + @Test @MainActor + func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async { + let favoritesService = FavoritesPersistenceService.shared + + let transport = MockTransport() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) + + let noiseKey = Data(repeating: 0xAB, count: 32) + favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice") + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + defer { + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) + } + + let meshID = PeerID(publicKey: noiseKey) + let snapshots = [TransportPeerSnapshot( + peerID: meshID, + nickname: "alice-renamed", + isConnected: true, + noisePublicKey: noiseKey, + lastSeen: Date() + )] + transport.updatePeerSnapshots(snapshots) + service.didUpdatePeerSnapshots(snapshots) + + let rows = service.peers.filter { $0.noisePublicKey == noiseKey } + #expect(rows.count == 1) + #expect(rows.first?.peerID == meshID) + #expect(rows.first?.isMutualFavorite == true) + #expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1) + } + + /// Same collapse must hold for a reachable-but-not-connected favorite + /// (relayed peers linger as "reachable" after their link drops). + @Test @MainActor + func updatePeers_reachableMutualFavoriteYieldsSingleRow() async { + let favoritesService = FavoritesPersistenceService.shared + + let transport = MockTransport() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) + + let noiseKey = Data(repeating: 0xCD, count: 32) + favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob") + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + defer { + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) + } + + let otherKey = Data(repeating: 0x11, count: 32) + let snapshots = [ + // A live link is required for anyone to count as reachable. + TransportPeerSnapshot( + peerID: PeerID(publicKey: otherKey), + nickname: "carol", + isConnected: true, + noisePublicKey: otherKey, + lastSeen: Date() + ), + TransportPeerSnapshot( + peerID: PeerID(publicKey: noiseKey), + nickname: "bob", + isConnected: false, + noisePublicKey: noiseKey, + lastSeen: Date() + ) + ] + transport.updatePeerSnapshots(snapshots) + service.didUpdatePeerSnapshots(snapshots) + + let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey } + #expect(bobRows.count == 1) + #expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey)) + #expect(bobRows.first?.isReachable == true) + } + + /// A mutual favorite with no mesh presence still gets its offline row, + /// keyed by the full noise-key PeerID. + @Test @MainActor + func updatePeers_offlineMutualFavoriteGetsOfflineRow() async { + let favoritesService = FavoritesPersistenceService.shared + + let transport = MockTransport() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) + + let noiseKey = Data(repeating: 0xEF, count: 32) + favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave") + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + defer { + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) + } + + transport.updatePeerSnapshots([]) + service.didUpdatePeerSnapshots([]) + + let rows = service.peers.filter { $0.noisePublicKey == noiseKey } + #expect(rows.count == 1) + #expect(rows.first?.peerID == PeerID(hexData: noiseKey)) + #expect(rows.first?.isMutualFavorite == true) + } + + @Test @MainActor + func setBlocked_unknownIdentityReturnsNil() async { + let transport = MockTransport() + let identity = TestIdentityManager() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) + + // No fingerprint resolvable for this peer (offline & unknown). + let peerID = PeerID(str: "00000000000000FF") + + #expect(service.setBlocked(peerID, blocked: true) == nil) + #expect(!service.isBlocked(peerID)) + } } private final class TestIdentityManager: SecureIdentityStateManagerProtocol { @@ -65,18 +219,6 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { socialIdentities[identity.fingerprint] = identity } - func getFavorites() -> Set { - favorites - } - - func setFavorite(_ fingerprint: String, isFavorite: Bool) { - if isFavorite { - favorites.insert(fingerprint) - } else { - favorites.remove(fingerprint) - } - } - func isFavorite(fingerprint: String) -> Bool { favorites.contains(fingerprint) } @@ -117,8 +259,6 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} - func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} - func clearAllIdentityData() { socialIdentities.removeAll() favorites.removeAll() @@ -143,4 +283,33 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func getVerifiedFingerprints() -> Set { verified } + + // MARK: Vouching (unused by these tests) + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + false + } + + func validVouchers(for fingerprint: String) -> [VouchRecord] { + [] + } + + func isVouched(fingerprint: String) -> Bool { + false + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + nil + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) {} + + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + nil + } + + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + [] + } } diff --git a/bitchatTests/Sync/GossipSyncBoardTests.swift b/bitchatTests/Sync/GossipSyncBoardTests.swift new file mode 100644 index 00000000..7fe8bdcf --- /dev/null +++ b/bitchatTests/Sync/GossipSyncBoardTests.swift @@ -0,0 +1,131 @@ +// +// GossipSyncBoardTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// Board posts ride gossip sync through a provider that queries the board +/// store, so retention (expiry, tombstones, caps) has a single owner. +struct GossipSyncBoardTests { + + private let myPeerID = PeerID(str: "0102030405060708") + + private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket { + BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: try #require(Data(hexString: "aabbccddeeff0011")), + recipientID: nil, + timestamp: timestamp, + payload: Data([0x42]), + signature: nil, + ttl: 7 + ) + } + + private func quietConfig() -> GossipSyncManager.Config { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + return config + } + + @Test func boardRequestIsServedFromProvider() async throws { + let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) + manager.boardPacketsProvider = { return [boardPacket] } + + let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let sent = try #require(delegate.packets.first) + #expect(sent.type == MessageType.boardPost.rawValue) + #expect(sent.isRSR) + } + + @Test func nonBoardRequestDoesNotServeBoardPackets() async throws { + let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) + manager.boardPacketsProvider = { return [boardPacket] } + + let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + // Follow with a board request; only its response should arrive, which + // also proves the first request produced nothing. + let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + #expect(delegate.packets.count == 1) + #expect(delegate.packets.first?.type == MessageType.boardPost.rawValue) + } + + @Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws { + var config = quietConfig() + config.boardSyncIntervalSeconds = 1 + + // Without a provider the board schedule stays silent. + let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let unwiredDelegate = RecordingBoardDelegate() + unwired.delegate = unwiredDelegate + unwired._performMaintenanceSynchronously(now: Date()) + #expect(unwiredDelegate.packets.isEmpty) + + // With a provider, maintenance sends a board-typed request. + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + manager.boardPacketsProvider = { return [] } + manager._performMaintenanceSynchronously(now: Date()) + + #expect(delegate.packets.count == 1) + let payload = try #require(delegate.packets.first?.payload) + let request = try #require(RequestSyncPacket.decode(from: payload)) + #expect(request.types == .board) + } +} + +private final class RecordingBoardDelegate: GossipSyncManager.Delegate { + private let lock = NSLock() + private var _packets: [BitchatPacket] = [] + + var packets: [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return _packets + } + + func sendPacket(_ packet: BitchatPacket) { + lock.lock() + _packets.append(packet) + lock.unlock() + } + + func sendPacket(to peerID: PeerID, packet: BitchatPacket) { + lock.lock() + _packets.append(packet) + lock.unlock() + } + + func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { + packet + } + + func getConnectedPeers() -> [PeerID] { + [] + } +} diff --git a/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift new file mode 100644 index 00000000..d055bde6 --- /dev/null +++ b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift @@ -0,0 +1,76 @@ +// +// RequestSyncPacketFragmentFilterTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct RequestSyncPacketFragmentFilterTests { + + @Test func fragmentIdFilterRoundTripsThroughWireEncoding() throws { + let id1 = try #require(Data(hexString: "00112233445566aa")) + let id2 = try #require(Data(hexString: "ffeeddccbbaa9988")) + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([id1, id2])) + + let packet = RequestSyncPacket(p: 7, m: 128, data: Data([0x01]), types: .fragment, fragmentIdFilter: filter) + let decoded = try #require(RequestSyncPacket.decode(from: packet.encode())) + + #expect(decoded.fragmentIdFilter == filter) + let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(decoded.fragmentIdFilter)) + #expect(ids == Set([id1, id2])) + } + + @Test func encodeCapsFilterAtMaxCountWithinDecoderBudget() throws { + let ids = (0..<100).map { i -> Data in + var id = Data(repeating: 0, count: 8) + id[7] = UInt8(i) + return id + } + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter(ids)) + + let tokens = filter.split(separator: ",") + #expect(tokens.count == RequestSyncPacket.maxFragmentIdFilterCount) + // 60 IDs * 17 bytes ("<16 hex>,") - 1 = 1019 ≤ the 1024-byte cap. + #expect(filter.utf8.count == 1019) + #expect(filter.utf8.count <= 1024) + } + + @Test func encodeDropsMalformedIDs() throws { + let good = try #require(Data(hexString: "0011223344556677")) + let short = Data([0x01, 0x02]) + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([short, good])) + #expect(filter == good.hexEncodedString()) + #expect(RequestSyncPacket.encodeFragmentIdFilter([short]) == nil) + #expect(RequestSyncPacket.encodeFragmentIdFilter([]) == nil) + } + + @Test func decodeIgnoresMalformedTokens() throws { + let good = try #require(Data(hexString: "0011223344556677")) + let ids = try #require( + RequestSyncPacket.decodeFragmentIdFilter("zzzz,0011,0011223344556677,") + ) + #expect(ids == Set([good])) + #expect(RequestSyncPacket.decodeFragmentIdFilter(nil) == nil) + #expect(RequestSyncPacket.decodeFragmentIdFilter("not-hex") == nil) + } + + @Test func decoderIgnoresOversizedFilterValue() throws { + // Hand-roll a payload whose 0x06 TLV exceeds the acceptance cap; the + // request must still decode, with the filter dropped. + var payload = RequestSyncPacket(p: 7, m: 128, data: Data([0x01])).encode() + let oversized = Data(repeating: UInt8(ascii: "a"), count: 1025) + payload.append(0x06) + payload.append(UInt8((oversized.count >> 8) & 0xFF)) + payload.append(UInt8(oversized.count & 0xFF)) + payload.append(oversized) + + let decoded = try #require(RequestSyncPacket.decode(from: payload)) + #expect(decoded.fragmentIdFilter == nil) + } +} diff --git a/bitchatTests/Sync/SyncResponseRateLimiterTests.swift b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift new file mode 100644 index 00000000..63f5329a --- /dev/null +++ b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncResponseRateLimiterTests { + + private let peer = PeerID(str: "1122334455667788") + private let otherPeer = PeerID(str: "8899aabbccddeeff") + + @Test func allowsResponsesUpToBudgetThenBlocks() { + var limiter = SyncResponseRateLimiter(maxResponses: 2, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let second = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(1)) + let third = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(2)) + + #expect(first) + #expect(second) + #expect(!third) + } + + @Test func budgetIsPerPeer() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let repeated = limiter.shouldRespond(to: peer, now: now) + let other = limiter.shouldRespond(to: otherPeer, now: now) + + #expect(first) + #expect(!repeated) + #expect(other) + } + + @Test func allowsAgainAfterWindowSlides() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let insideWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(29)) + let afterWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(31)) + + #expect(first) + #expect(!insideWindow) + #expect(afterWindow) + } + + @Test func pruneDropsExpiredHistory() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + limiter.prune(now: now.addingTimeInterval(31)) + let afterPrune = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(32)) + + #expect(first) + #expect(afterPrune) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift new file mode 100644 index 00000000..829bfd4d --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift @@ -0,0 +1,79 @@ +// +// SyncTypeFlagsBoardTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// The board sync flag is the first bit outside the original single byte of +/// type flags. These tests pin down the wire compatibility contract: the +/// types TLV has been a variable-length (1-8 byte) little-endian bitfield +/// since type-aware sync, so widening to two bytes must decode everywhere +/// and unknown bits must be ignored, not rejected. +struct SyncTypeFlagsBoardTests { + + @Test func boardFlagEncodesIntoSecondByte() throws { + let data = try #require(SyncTypeFlags.board.toData()) + // Little-endian: low byte first, board bit (bit 8) in byte 2. + #expect(data == Data([0x00, 0x01])) + } + + @Test func boardFlagRoundTrips() throws { + let flags = SyncTypeFlags(messageTypes: [.message, .boardPost]) + let data = try #require(flags.toData()) + let decoded = try #require(SyncTypeFlags.decode(data)) + #expect(decoded.contains(.message)) + #expect(decoded.contains(.boardPost)) + #expect(!decoded.contains(.fragment)) + #expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost])) + } + + /// An old decoder is modeled by bits it has no mapping for: the shared + /// decode path accepts the bytes and simply maps unknown bits to no + /// message type, so a board-only request reads as "nothing I can serve". + @Test func unknownBitsDecodeToNoTypes() throws { + // Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle, + // bit 10 = groupMessage); a future (or unknown) two-byte bitfield must + // decode without error and yield no known types. + let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8]))) + #expect(decoded.toMessageTypes().isEmpty) + for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] { + #expect(!decoded.contains(type)) + } + } + + @Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws { + // Known low-byte flags survive alongside unknown high bits (11-15). + let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xF8]))) + #expect(decoded.contains(.announce)) + #expect(decoded.contains(.message)) + #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message])) + } + + @Test func requestSyncPacketRoundTripsBoardFlag() throws { + let request = RequestSyncPacket( + p: 4, + m: 128, + data: Data([0xAB, 0xCD]), + types: SyncTypeFlags(messageTypes: [.boardPost]) + ) + let decoded = try #require(RequestSyncPacket.decode(from: request.encode())) + let types = try #require(decoded.types) + #expect(types.contains(.boardPost)) + #expect(!types.contains(.message)) + } + + @Test func singleByteLegacyEncodingStillDecodes() throws { + // Requests from old clients keep the one-byte bitfield. + let decoded = try #require(SyncTypeFlags.decode(Data([0x03]))) + #expect(decoded.contains(.announce)) + #expect(decoded.contains(.message)) + #expect(!decoded.contains(.boardPost)) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift new file mode 100644 index 00000000..2bd9d53b --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift @@ -0,0 +1,62 @@ +// +// SyncTypeFlagsGroupTests.swift +// bitchat +// +// Wire-compat proof for the groupMessage sync bit (bit 10): the types +// bitfield widens from 1 to 2 bytes, and clients that don't know the bit +// simply ignore it. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsGroupTests { + + @Test func groupMessageOccupiesBitTen() { + #expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10) + #expect(SyncTypeFlags.groupMessage.contains(.groupMessage)) + #expect(!SyncTypeFlags.publicMessages.contains(.groupMessage)) + } + + @Test func extendedBitfieldWidensToTwoBytes() throws { + // Legacy flags fit one byte… + #expect(SyncTypeFlags.publicMessages.toData() == Data([0x03])) + + // …the group bit widens the little-endian encoding to two bytes. + let combined = SyncTypeFlags.publicMessages.union(.groupMessage) + let encoded = try #require(combined.toData()) + #expect(encoded == Data([0x03, 0x04])) + + let decoded = try #require(SyncTypeFlags.decode(encoded)) + #expect(decoded == combined) + #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + } + + @Test func unknownBitsAreIgnoredNotRejected() throws { + // An "old client" reading a 2-byte field keeps the raw bits but maps + // unknown bit indices to no message type — it answers with the types + // it knows instead of dropping the request. + let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC]))) + #expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + #expect(futuristic.contains(.announce)) + #expect(futuristic.contains(.message)) + } + + @Test func requestSyncPacketRoundTripsGroupFlag() throws { + let types = SyncTypeFlags.publicMessages.union(.groupMessage) + let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types) + let encoded = packet.encode() + + let decoded = try #require(RequestSyncPacket.decode(from: encoded)) + #expect(decoded.types == types) + #expect(decoded.types?.contains(.groupMessage) == true) + #expect(decoded.p == 8) + #expect(decoded.m == 1024) + #expect(decoded.data == Data([0xAB, 0xCD])) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift new file mode 100644 index 00000000..8c400119 --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsTests { + + @Test func knownTypesRoundTripThroughData() throws { + let flags: SyncTypeFlags = [.announce, .message, .fragment, .fileTransfer] + let data = try #require(flags.toData()) + let decoded = try #require(SyncTypeFlags.decode(data)) + #expect(decoded == flags) + } + + @Test func decodeDropsPhantomBits() { + // Bits 11+ map to no message type (bit 8 = boardPost, bit 9 = + // prekeyBundle, bit 10 = groupMessage). They must not survive decode + // as phantom membership. + let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type + let decoded = SyncTypeFlags.decode(phantom) + #expect(decoded?.rawValue == 0) + #expect(decoded?.toMessageTypes().isEmpty == true) + } + + @Test func boardBitSurvivesDecode() { + // Bit 8 maps to boardPost and spills the field into a second byte; + // it must survive decode while the phantom high bits (11+) are + // stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared + // to isolate the board bit. + let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom + let decoded = SyncTypeFlags.decode(mixed) + #expect(decoded?.contains(.board) == true) + #expect(decoded?.rawValue == 0b1_0000_0000) + } + + @Test func phantomBitsAreStrippedButKnownBitsSurvive() { + // Low byte = announce(0) + message(1); high byte bits 11+ are phantom. + let mixed = Data([0b0000_0011, 0xF8]) + let decoded = SyncTypeFlags.decode(mixed) + #expect(decoded?.contains(.announce) == true) + #expect(decoded?.contains(.message) == true) + // Only the two known bits remain; phantom high bits are gone. + #expect(decoded?.rawValue == 0b0000_0011) + } + + @Test func rawValueInitNormalizesPhantomBits() { + let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) + // Every known type bit is set; nothing above them survives. boardPost + // occupies bit 8, so the known set spills into a second byte. + #expect(flags.contains(.announce)) + #expect(flags.contains(.fileTransfer)) + #expect(flags.contains(.board)) + let data = flags.toData() + #expect(data?.count == 2) + } +} diff --git a/bitchatTests/TestUtilities/TestConstants.swift b/bitchatTests/TestUtilities/TestConstants.swift index 25437d9f..83f5b46b 100644 --- a/bitchatTests/TestUtilities/TestConstants.swift +++ b/bitchatTests/TestUtilities/TestConstants.swift @@ -12,6 +12,11 @@ import Foundation struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 static let shortTimeout: TimeInterval = 1.0 + /// For positive waits on work that hops through `Task.detached` or + /// background queues: those contend with every parallel test worker for + /// the global executor, so a loaded CI runner can exceed + /// `defaultTimeout`. `waitUntil` returns as soon as the condition holds, + /// so passing runs never pay the longer timeout. static let longTimeout: TimeInterval = 10.0 static let testNickname1 = "Alice" @@ -20,9 +25,5 @@ struct TestConstants { static let testNickname4 = "David" static let testMessage1 = "Hello, World!" - static let testMessage2 = "How are you?" - static let testMessage3 = "This is a test message" static let testLongMessage = String(repeating: "This is a long message. ", count: 100) - - static let testSignature = Data(repeating: 0xAB, count: 64) } diff --git a/bitchatTests/TestUtilities/TestHelpers.swift b/bitchatTests/TestUtilities/TestHelpers.swift index 21894e91..45b432cb 100644 --- a/bitchatTests/TestUtilities/TestHelpers.swift +++ b/bitchatTests/TestUtilities/TestHelpers.swift @@ -12,20 +12,7 @@ import BitFoundation @testable import bitchat final class TestHelpers { - - // MARK: - Key Generation - - static func generateTestKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { - let privateKey = Curve25519.KeyAgreement.PrivateKey() - let publicKey = privateKey.publicKey - return (privateKey, publicKey) - } - - static func generateTestIdentity(peerID: String, nickname: String) -> (peerID: String, nickname: String, privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { - let (privateKey, publicKey) = generateTestKeyPair() - return (peerID: peerID, nickname: nickname, privateKey: privateKey, publicKey: publicKey) - } - + // MARK: - Message Creation static func createTestMessage( @@ -54,13 +41,13 @@ final class TestHelpers { type: UInt8 = 0x01, senderID: PeerID = PeerID(str: UUID().uuidString), recipientID: PeerID? = nil, - payload: Data = "test payload".data(using: .utf8)!, + payload: Data = Data("test payload".utf8), signature: Data? = nil, ttl: UInt8 = 3 ) -> BitchatPacket { return BitchatPacket( type: type, - senderID: senderID.id.data(using: .utf8)!, + senderID: Data(senderID.id.utf8), recipientID: recipientID?.id.data(using: .utf8), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, @@ -78,11 +65,7 @@ final class TestHelpers { } return data } - - static func generateTestPeerID() -> String { - return "PEER" + UUID().uuidString.prefix(8) - } - + // MARK: - Async Helpers static func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout) async throws { @@ -110,32 +93,10 @@ final class TestHelpers { } return true } - - static func expectAsync( - timeout: TimeInterval = TestConstants.defaultTimeout, - operation: @escaping () async throws -> T - ) async throws -> T { - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - return try await operation() - } - - group.addTask { - try await sleep(1) - throw TestError.timeout - } - - let result = try await group.next()! - group.cancelAll() - return result - } - } } enum TestError: Error { case timeout - case unexpectedValue - case testFailure(String) } // MARK: - Private chat seeding (ConversationStore migration) @@ -164,18 +125,6 @@ extension ChatViewModel { } } - /// Test-only replacement for `messages.removeAll()`: empties a public - /// channel's conversation. - @MainActor - func clearPublicMessages(for channel: ChannelID = .mesh) { - conversations.clear(ConversationID(channelID: channel)) - } - - /// Test-only: drops every private chat and unread flag. - @MainActor - func clearAllPrivateChats() { - conversations.removeAllDirectConversations() - } } func sleep(_ seconds: TimeInterval) async throws { diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 09631d6a..6c104e53 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -1,3 +1,4 @@ +import Combine import Testing import Foundation import SwiftUI @@ -39,6 +40,7 @@ private struct SmokeFeatureModels { let verificationModel: VerificationModel let conversationUIModel: ConversationUIModel let peerListModel: PeerListModel + let boardAlertsModel: BoardAlertsModel } @MainActor @@ -80,6 +82,14 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur locationChannelsModel: locationChannelsModel ) + let boardAlertsModel = BoardAlertsModel( + arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { _ in false }, + emitSystemLine: { _, _ in } + ) + ) + return SmokeFeatureModels( publicChatModel: publicChatModel, appChromeModel: appChromeModel, @@ -88,7 +98,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur privateConversationModel: privateConversationModel, verificationModel: verificationModel, conversationUIModel: conversationUIModel, - peerListModel: peerListModel + peerListModel: peerListModel, + boardAlertsModel: boardAlertsModel ) } @@ -106,6 +117,7 @@ private func installSmokeEnvironment( .environmentObject(featureModels.verificationModel) .environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.peerListModel) + .environmentObject(featureModels.boardAlertsModel) } @MainActor @@ -395,9 +407,17 @@ struct ViewSmokeTests { } @Test - func locationNotesView_rendersNoRelayAndLoadedStates() throws { - let (viewModel, _, _) = makeSmokeViewModel() + func noticesView_rendersNoRelayAndLoadedStates() throws { + let (viewModel, transport, _) = makeSmokeViewModel() let featureModels = makeSmokeFeatureModels(for: viewModel) + featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq"))) + defer { featureModels.locationChannelsModel.select(.mesh) } + let board = BoardManager( + transport: transport, + store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }), + publishToNostr: { _, _, _, _, _ in nil }, + deleteFromNostr: { _, _ in } + ) let noRelayManager = LocationNotesManager( geohash: "u4pruydq", @@ -440,18 +460,28 @@ struct ViewSmokeTests { eose?() _ = mount( - LocationNotesView( - geohash: "u4pruydq", + NoticesView( senderNickname: viewModel.nickname, - manager: noRelayManager + board: board, + initialTab: .geo, + notesManager: noRelayManager ) .environmentObject(featureModels.locationChannelsModel) ) _ = mount( - LocationNotesView( - geohash: "u4pruydq", + NoticesView( senderNickname: viewModel.nickname, - manager: loadedManager + board: board, + initialTab: .geo, + notesManager: loadedManager + ) + .environmentObject(featureModels.locationChannelsModel) + ) + _ = mount( + NoticesView( + senderNickname: viewModel.nickname, + board: board, + initialTab: .mesh ) .environmentObject(featureModels.locationChannelsModel) ) @@ -468,13 +498,15 @@ struct ViewSmokeTests { description: "app_info.features.encryption.description" ) + // AppInfoView's settings pane reads LocationChannelsModel from the + // environment, so it can only render mounted with one installed. let appInfo = AppInfoView() + .environmentObject(LocationChannelsModel(manager: makeSmokeLocationManager())) let header = SectionHeader("app_info.features.title") let featureRow = FeatureRow(info: feature) let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token")) let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test")) - _ = appInfo.body _ = header.body _ = featureRow.body _ = paymentCashu.body @@ -556,11 +588,19 @@ struct ViewSmokeTests { @Test func voiceAndMediaViews_renderAndWarmCaches() async throws { let audioURL = try makeTemporaryAudioURL() + // Probed directly below. Deliberately a separate file from `audioURL`: + // `WaveformCache.shared` is process-wide and the mounted + // `VoiceNoteView` warms it for `audioURL` at the view's default bin + // width concurrently, so asserting an exact bin count for that URL + // races with the view's own cache write. + let waveformProbeURL = try makeTemporaryAudioURL() let imageURL = try makeTemporaryImageURL() defer { try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: waveformProbeURL) try? FileManager.default.removeItem(at: imageURL) WaveformCache.shared.purge(url: audioURL) + WaveformCache.shared.purge(url: waveformProbeURL) } let waveformView = WaveformView( @@ -594,20 +634,22 @@ struct ViewSmokeTests { _ = mount(voiceNoteView) let bins = await withCheckedContinuation { continuation in - WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in + WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in continuation.resume(returning: values) } } playback.loadDuration() - try? await Task.sleep(nanoseconds: 250_000_000) + // loadDuration hops through a background queue and back to main; poll + // instead of a fixed sleep so a loaded runner can't outlast the wait. + _ = await TestHelpers.waitUntil({ playback.duration > 0 }) playback.seek(to: 1.25) playback.stop() VoiceNotePlaybackCoordinator.shared.activate(playback) VoiceNotePlaybackCoordinator.shared.deactivate(playback) - await VoiceRecorder.shared.cancelRecording() + await VoiceRecorder.shared.cancelRecording(owner: VoiceRecorder.RecordingOwner()) #expect(bins.count == 16) - #expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16) + #expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16) #expect(playback.duration > 0) #expect(playback.progress == 0) } diff --git a/bitchatTests/VoiceBurstPacketTests.swift b/bitchatTests/VoiceBurstPacketTests.swift new file mode 100644 index 00000000..ea7b657f --- /dev/null +++ b/bitchatTests/VoiceBurstPacketTests.swift @@ -0,0 +1,161 @@ +// +// VoiceBurstPacketTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +struct VoiceBurstPacketTests { + private let burstID = Data((0..<8).map { UInt8($0 + 1) }) + + // MARK: - Round trips + + @Test func startRoundTrip() throws { + let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))) + let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) + #expect(decoded == packet) + #expect(decoded.seq == 0) + } + + @Test func framesRoundTrip() throws { + let frames = [Data([0xDE, 0xAD]), Data(repeating: 0x42, count: 130)] + let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 7, kind: .frames(frames))) + let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) + #expect(decoded == packet) + guard case .frames(let decodedFrames) = decoded.kind else { + Issue.record("expected frames") + return + } + #expect(decodedFrames == frames) + } + + @Test func endRoundTrip() throws { + let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 42, kind: .end(totalDataPackets: 41, durationMs: 2_688))) + let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) + #expect(decoded == packet) + } + + @Test func canceledRoundTrip() throws { + let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 3, kind: .canceled)) + let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) + #expect(decoded == packet) + } + + @Test func decodeSurvivesReslicedData() throws { + // Simulates the payload arriving as a slice with a non-zero start index. + let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data([1, 2, 3])]))) + var padded = Data([0xAA, 0xBB]) + padded.append(packet.encode()) + let slice = padded.dropFirst(2) + #expect(VoiceBurstPacket.decode(slice) == packet) + } + + // MARK: - Validation + + @Test func rejectsMalformedInput() { + #expect(VoiceBurstPacket.decode(Data()) == nil) + #expect(VoiceBurstPacket.decode(Data(repeating: 0, count: 10)) == nil) + // Unknown flags byte. + var unknownFlags = burstID + unknownFlags.append(contentsOf: [0, 1, 0xFF]) + #expect(VoiceBurstPacket.decode(unknownFlags) == nil) + // Data packet with zero frames. + var empty = burstID + empty.append(contentsOf: [0, 1, 0]) + #expect(VoiceBurstPacket.decode(empty) == nil) + // Truncated frame length. + var truncated = burstID + truncated.append(contentsOf: [0, 1, 0, 0x00, 0x10, 0xAB]) + #expect(VoiceBurstPacket.decode(truncated) == nil) + // Unknown codec in START. + var badCodec = burstID + badCodec.append(contentsOf: [0, 0, 0x01, 0x7F]) + #expect(VoiceBurstPacket.decode(badCodec) == nil) + } + + @Test func rejectsInvalidConstruction() { + #expect(VoiceBurstPacket(burstID: Data([1, 2]), seq: 0, kind: .canceled) == nil) + #expect(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([])) == nil) + #expect(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data()])) == nil) + let tooMany = Array(repeating: Data([0x01]), count: VoiceBurstPacket.maxFramesPerPacket + 1) + #expect(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames(tooMany)) == nil) + } + + @Test func makeBurstIDProducesUniqueEightBytes() { + let a = VoiceBurstPacket.makeBurstID() + let b = VoiceBurstPacket.makeBurstID() + #expect(a.count == VoiceBurstPacket.burstIDSize) + #expect(a != b) + } + + // MARK: - Packetizer + + @Test func packetizerRespectsBudgetAndCounts() throws { + var packetizer = VoiceBurstPacketizer(burstID: burstID, budget: 210) + let frame = Data(repeating: 0x55, count: 130) // realistic 16 kbps AAC frame + + // First frame buffers, second forces a flush of the first. + #expect(packetizer.add(frame).isEmpty) + let flushed = packetizer.add(frame) + #expect(flushed.count == 1) + let first = try #require(VoiceBurstPacket.decode(flushed[0])) + #expect(first.seq == 1) + guard case .frames(let frames) = first.kind else { + Issue.record("expected frames") + return + } + #expect(frames == [frame]) + + // Remaining frame flushes on demand; counters advance. + let final = packetizer.flush() + #expect(final.count == 1) + #expect(try #require(VoiceBurstPacket.decode(final[0])).seq == 2) + #expect(packetizer.dataPacketCount == 2) + #expect(packetizer.nextSeq == 3) + #expect(packetizer.flush().isEmpty) + } + + @Test func packetizerBatchesSmallFrames() throws { + var packetizer = VoiceBurstPacketizer(burstID: burstID, budget: 210) + let small = Data(repeating: 0x11, count: 40) + for _ in 0..<4 { + #expect(packetizer.add(small).isEmpty) // 4 * 42 + 11 = 179 <= 210 + } + let packets = packetizer.flush() + #expect(packets.count == 1) + guard case .frames(let frames) = try #require(VoiceBurstPacket.decode(packets[0])).kind else { + Issue.record("expected frames") + return + } + #expect(frames.count == 4) + } + + @Test func packetizerDropsOversizedFrame() { + var packetizer = VoiceBurstPacketizer(burstID: burstID, budget: 210) + #expect(packetizer.add(Data(repeating: 0, count: 500)).isEmpty) + #expect(packetizer.flush().isEmpty) + #expect(packetizer.dataPacketCount == 0) + } + + @Test func encodedPacketStaysWithinNoisePaddingBucket() throws { + // The whole point of the budget: burst content + 1 type byte + + // 16-byte Noise tag must stay within MessagePadding's 256 bucket. + var packetizer = VoiceBurstPacketizer(burstID: burstID) + var largest = 0 + for _ in 0..<3 { + for packet in packetizer.add(Data(repeating: 0xAB, count: 160)) { + largest = max(largest, packet.count) + } + } + for packet in packetizer.flush() { + largest = max(largest, packet.count) + } + #expect(largest > 0) + #expect(largest + 1 + 16 <= 256) + } +} diff --git a/bitchatTests/VoiceCaptureSessionTests.swift b/bitchatTests/VoiceCaptureSessionTests.swift new file mode 100644 index 00000000..40853f2c --- /dev/null +++ b/bitchatTests/VoiceCaptureSessionTests.swift @@ -0,0 +1,207 @@ +// +// VoiceCaptureSessionTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +@MainActor +private final class StubPTTCapture: PTTCapturing { + var onFrames: (([Data]) -> Void)? + var stopResult: (url: URL?, encodedFrames: Int) + var startError: Error? + private(set) var startCount = 0 + private(set) var cancelCount = 0 + + init( + stopResult: (url: URL?, encodedFrames: Int), + startError: Error? = nil + ) { + self.stopResult = stopResult + self.startError = startError + } + + func start(outputURL: URL) async throws { + startCount += 1 + if let startError { + throw startError + } + } + + func stop() -> (url: URL?, encodedFrames: Int) { + stopResult + } + + func cancel() { + cancelCount += 1 + } +} + +private final class CaptureLeaseSession: SessionApplying, @unchecked Sendable { + private let lock = NSLock() + private var _activationCalls: [Bool] = [] + + var activationCalls: [Bool] { lock.withLock { _activationCalls } } + + func setCategory(_ category: AudioSessionCoordinator.Category) throws {} + + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { + lock.withLock { _activationCalls.append(active) } + } +} + +@MainActor +private final class GatedVoiceCaptureSession: VoiceCaptureSession { + let isLive = false + private let startError: Error? + private(set) var finishStarted = false + private(set) var cancelCount = 0 + private var finishContinuation: CheckedContinuation? + + init(startError: Error? = nil) { + self.startError = startError + } + + func requestPermission() async -> Bool { true } + func start() async throws { + if let startError { throw startError } + } + + func finish() async -> URL? { + finishStarted = true + return await withCheckedContinuation { continuation in + finishContinuation = continuation + } + } + + func cancel() async { + cancelCount += 1 + } + + func resolveFinish(with url: URL?) { + let continuation = finishContinuation + finishContinuation = nil + continuation?.resume(returning: url) + } +} + +@MainActor +struct VoiceCaptureSessionTests { + private func waitUntil( + _ condition: () -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !condition(), ContinuousClock.now < deadline { + await Task.yield() + try? await Task.sleep(nanoseconds: 1_000_000) + } + #expect(condition(), sourceLocation: sourceLocation) + } + + private func isRecording(_ state: VoiceRecordingViewModel.State) -> Bool { + if case .recording = state { return true } + return false + } + + @Test func staleCaptureCallbackCannotInvalidateNewGeneration() { + let generations = PTTCaptureGeneration() + let old = generations.begin() + generations.invalidate() + let current = generations.begin() + + #expect(!generations.invalidate(ifCurrent: old)) + #expect(generations.isCurrent(current)) + #expect(generations.invalidate(ifCurrent: current)) + #expect(!generations.isCurrent(current)) + } + + @Test func coordinatorCancellationIsNotReportedAsAStartedCapture() async { + let capture = StubPTTCapture( + stopResult: (nil, 0), + startError: CancellationError() + ) + let session = PTTLiveVoiceSession( + sendPacket: { _ in }, + capture: capture + ) + + await #expect(throws: CancellationError.self) { + try await session.start() + } + } + + @Test func interruptedShortCaptureIsCanceledEvenAfterLongHold() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ptt-interrupted-test-\(UUID().uuidString).m4a") + _ = FileManager.default.createFile(atPath: url.path, contents: Data([0x00])) + defer { try? FileManager.default.removeItem(at: url) } + + let capture = StubPTTCapture(stopResult: (url, 1)) + var sentPackets: [Data] = [] + var now = Date() + let session = PTTLiveVoiceSession( + sendPacket: { sentPackets.append($0) }, + capture: capture, + now: { now }, + burstID: Data(repeating: 0xA5, count: 8) + ) + + try await session.start() + now = now.addingTimeInterval(2) + let result = await session.finish() + + #expect(result == nil) + #expect(!FileManager.default.fileExists(atPath: url.path)) + let packet = try #require(sentPackets.last.flatMap(VoiceBurstPacket.decode)) + guard case .canceled = packet.kind else { + Issue.record("Expected a canceled control packet for a subsecond interrupted capture") + return + } + } + + @Test func droppingCaptureLeaseReturnsItsCoordinatorToken() async throws { + let rawSession = CaptureLeaseSession() + let coordinator = AudioSessionCoordinator(session: rawSession) + let token = try await coordinator.acquire(.capture) {} + var lease: PTTCaptureSessionLease? = PTTCaptureSessionLease(coordinator: coordinator) + lease?.install(token) + + lease = nil + await coordinator.drain() + + #expect(rawSession.activationCalls == [true, false]) + } + + @Test func rejectedNewHoldAndStaleFinalizeLeaveNewerGenerationIdle() async { + let oldSession = GatedVoiceCaptureSession() + let newSession = GatedVoiceCaptureSession( + startError: VoiceRecorder.RecorderError.recordingInProgress + ) + var sessions: [GatedVoiceCaptureSession] = [oldSession, newSession] + let viewModel = VoiceRecordingViewModel() + viewModel.sessionProvider = { sessions.removeFirst() } + + viewModel.start(shouldShow: true) + await waitUntil { self.isRecording(viewModel.state) } + viewModel.finish(completion: { _ in }) + await waitUntil { oldSession.finishStarted } + + viewModel.start(shouldShow: true) + await waitUntil { newSession.cancelCount == 1 && viewModel.state == .idle } + #expect(viewModel.state == .idle) + + // The older finalize now fails after the newer press has completed. + // It must not replace the newer generation's idle state with an alert. + oldSession.resolveFinish(with: nil) + for _ in 0..<20 { + await Task.yield() + } + #expect(viewModel.state == .idle) + } +} diff --git a/bitchatTests/VoiceNotePlaybackControllerTests.swift b/bitchatTests/VoiceNotePlaybackControllerTests.swift new file mode 100644 index 00000000..f7853f1d --- /dev/null +++ b/bitchatTests/VoiceNotePlaybackControllerTests.swift @@ -0,0 +1,145 @@ +// +// VoiceNotePlaybackControllerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import AVFoundation +import Foundation +@testable import bitchat + +/// Thread-safe: the coordinator invokes it on its private serial queue (the +/// blocking session IPC runs off the main thread) while the test reads from +/// the main actor. +private final class RecordingAudioSession: SessionApplying, @unchecked Sendable { + private let lock = NSLock() + private var _activationCalls: [Bool] = [] + private var _categoryCallCount = 0 + private var _categoryError: Error? + + var activationCalls: [Bool] { lock.withLock { _activationCalls } } + var categoryCallCount: Int { lock.withLock { _categoryCallCount } } + var categoryError: Error? { + get { lock.withLock { _categoryError } } + set { lock.withLock { _categoryError = newValue } } + } + + func setCategory(_ category: AudioSessionCoordinator.Category) throws { + try lock.withLock { + _categoryCallCount += 1 + if let error = _categoryError { + _categoryError = nil + throw error + } + } + } + + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { + lock.withLock { _activationCalls.append(active) } + } +} + +private struct PlaybackSessionError: Error {} + +@MainActor +struct VoiceNotePlaybackControllerTests { + /// A short silent PCM file `AVAudioPlayer` can open on the test host. + private func makeTempVoiceNote(seconds: Double = 0.2) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-note-test-\(UUID().uuidString).caf") + let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)) + let file = try AVAudioFile(forWriting: url, settings: format.settings) + let frames = AVAudioFrameCount(seconds * 16_000) + let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames)) + buffer.frameLength = frames + try file.write(from: buffer) + return url + } + + private func waitUntil( + _ condition: () -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !condition(), ContinuousClock.now < deadline { + await Task.yield() + try? await Task.sleep(nanoseconds: 1_000_000) + } + #expect(condition(), sourceLocation: sourceLocation) + } + + @Test func seekWhilePausedDoesNotAcquireSession() throws { + let session = RecordingAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + let url = try makeTempVoiceNote() + defer { try? FileManager.default.removeItem(at: url) } + + let controller = VoiceNotePlaybackController( + url: url, + sessionCoordinator: coordinator, + exclusivity: VoiceNotePlaybackCoordinator() + ) + controller.seek(to: 0.5) + + // The scrub position moved (the player is real and ready)... + #expect(controller.progress > 0.25) + // ...but nothing is audible, so the session must not be held: an + // acquired-while-paused token on a discarded row would pin the + // session (and any escalated category) forever. + #expect(session.activationCalls.isEmpty) + #expect(!controller.isPlaying) + } + + @Test func deinitReleasesSessionAndStopsPlayback() async throws { + let session = RecordingAudioSession() + let coordinator = AudioSessionCoordinator(session: session) + let url = try makeTempVoiceNote() + defer { try? FileManager.default.removeItem(at: url) } + + // Fresh exclusivity slot: a parallel test's play() must not pause + // this controller while its session acquire is in flight. + var controller: VoiceNotePlaybackController? = VoiceNotePlaybackController( + url: url, + sessionCoordinator: coordinator, + exclusivity: VoiceNotePlaybackCoordinator() + ) + controller?.play() + // The session acquire is asynchronous now (its blocking IPC runs off + // the main thread), so await the activation instead of asserting + // right after play(). + await waitUntil { session.activationCalls == [true] } + + // Navigating away discards the row's @StateObject mid-playback: + // deinit must release the session hold (a fire-and-forget hop onto + // the coordinator's queue). + controller = nil + + await waitUntil { session.activationCalls == [true, false] } + } + + @Test func activationFailureDoesNotStartUnregisteredPlayback() async throws { + let session = RecordingAudioSession() + session.categoryError = PlaybackSessionError() + let coordinator = AudioSessionCoordinator(session: session) + let url = try makeTempVoiceNote(seconds: 30) + defer { try? FileManager.default.removeItem(at: url) } + + let controller = VoiceNotePlaybackController( + url: url, + sessionCoordinator: coordinator, + exclusivity: VoiceNotePlaybackCoordinator() + ) + controller.play() + + await waitUntil { + session.categoryCallCount == 1 && !controller.isPlaybackStartPending + } + + #expect(session.activationCalls.isEmpty) + #expect(!controller.isPlaying) + #expect(controller.currentTime == 0) + } +} diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift new file mode 100644 index 00000000..dfaa62d5 --- /dev/null +++ b/bitchatTests/VoiceRecorderTests.swift @@ -0,0 +1,402 @@ +// +// VoiceRecorderTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { + private let lock = NSLock() + private let activationGate = DispatchSemaphore(value: 0) + private let shouldGateFirstActivation: Bool + private var gatedFirstActivation = false + private var _activationCalls: [Bool] = [] + private var _activationBegan = false + + init(gateFirstActivation: Bool = false) { + self.shouldGateFirstActivation = gateFirstActivation + } + + var activationCalls: [Bool] { lock.withLock { _activationCalls } } + var activationBegan: Bool { lock.withLock { _activationBegan } } + + func setCategory(_ category: AudioSessionCoordinator.Category) throws {} + + func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { + let shouldWait = lock.withLock { () -> Bool in + _activationCalls.append(active) + guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false } + gatedFirstActivation = true + _activationBegan = true + return true + } + if shouldWait { + activationGate.wait() + } + } + + func resumeActivation() { + activationGate.signal() + } +} + +private final class TestVoiceAudioRecorder: VoiceAudioRecording { + let prepareResult: Bool + let recordResult: Bool + + private let lock = NSLock() + private var _isRecording = false + private var _isMeteringEnabled = false + private var _prepareCallCount = 0 + private var _recordedDurations: [TimeInterval] = [] + private var _stopCallCount = 0 + + init(prepareResult: Bool, recordResult: Bool) { + self.prepareResult = prepareResult + self.recordResult = recordResult + } + + var isRecording: Bool { lock.withLock { _isRecording } } + var isMeteringEnabled: Bool { + get { lock.withLock { _isMeteringEnabled } } + set { lock.withLock { _isMeteringEnabled = newValue } } + } + var prepareCallCount: Int { lock.withLock { _prepareCallCount } } + var recordedDurations: [TimeInterval] { lock.withLock { _recordedDurations } } + var stopCallCount: Int { lock.withLock { _stopCallCount } } + + func prepareToRecord() -> Bool { + lock.withLock { _prepareCallCount += 1 } + return prepareResult + } + + func record(forDuration duration: TimeInterval) -> Bool { + lock.withLock { + _recordedDurations.append(duration) + if recordResult { + _isRecording = true + } + } + return recordResult + } + + func stop() { + lock.withLock { + _stopCallCount += 1 + _isRecording = false + } + } + + /// Models `record(forDuration:)` reaching its duration cap before the + /// caller invokes `VoiceRecorder.stopRecording(owner:)`. + func simulateAutomaticStop() { + lock.withLock { _isRecording = false } + } +} + +private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { + struct Plan { + let prepareResult: Bool + let recordResult: Bool + + static let success = Plan(prepareResult: true, recordResult: true) + } + + private let lock = NSLock() + private var plans: [Plan] + private var _recorders: [TestVoiceAudioRecorder] = [] + private var _urls: [URL] = [] + + init(plans: [Plan]) { + self.plans = plans + } + + var recorders: [TestVoiceAudioRecorder] { lock.withLock { _recorders } } + var urls: [URL] { lock.withLock { _urls } } + + func makeRecorder(url: URL) throws -> any VoiceAudioRecording { + let plan = lock.withLock { plans.isEmpty ? .success : plans.removeFirst() } + // AVAudioRecorder creates its output during initialization. A real + // byte on disk lets the tests distinguish preserve from delete. + try Data([0x01]).write(to: url) + let recorder = TestVoiceAudioRecorder( + prepareResult: plan.prepareResult, + recordResult: plan.recordResult + ) + lock.withLock { + _recorders.append(recorder) + _urls.append(url) + } + return recorder + } +} + +/// One-shot async gate that proves `VoiceRecorder.stopRecording` has reached +/// its actor-reentrant padding boundary, then holds it there until the test has +/// exercised a competing owner. Unlike `Task.yield()` plus a short real sleep, +/// this remains deterministic when the full test suite saturates the executor. +private final class VoiceRecorderPaddingGate: @unchecked Sendable { + private let lock = NSLock() + private var _entered = false + private var isOpen = false + private var openWaiters: [CheckedContinuation] = [] + + var entered: Bool { lock.withLock { _entered } } + + func wait() async { + await withCheckedContinuation { continuation in + let resumeImmediately = lock.withLock { () -> Bool in + _entered = true + guard !isOpen else { return true } + openWaiters.append(continuation) + return false + } + if resumeImmediately { + continuation.resume() + } + } + } + + func open() { + let waiters = lock.withLock { () -> [CheckedContinuation] in + isOpen = true + defer { openWaiters.removeAll() } + return openWaiters + } + waiters.forEach { $0.resume() } + } +} + +@MainActor +struct VoiceRecorderTests { + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-recorder-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func waitUntil( + _ condition: () -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !condition(), ContinuousClock.now < deadline { + await Task.yield() + try? await Task.sleep(nanoseconds: 1_000_000) + } + #expect(condition(), sourceLocation: sourceLocation) + } + + @Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let session = VoiceRecorderTestSession(gateFirstActivation: true) + let coordinator = AudioSessionCoordinator(session: session) + let factory = TestVoiceAudioRecorderFactory(plans: [.success]) + let voiceRecorder = VoiceRecorder( + sessionCoordinator: coordinator, + recorderFactory: factory, + permissionGranted: { true }, + paddingInterval: 0, + outputDirectory: directory + ) + let owner = VoiceRecorder.RecordingOwner() + + let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } + await waitUntil { session.activationBegan } + + await voiceRecorder.cancelRecording(owner: owner) + session.resumeActivation() + + do { + _ = try await startTask.value + Issue.record("The canceled session acquire unexpectedly started recording") + } catch { + #expect(error is CancellationError) + } + await coordinator.drain() + + #expect(factory.recorders.isEmpty) + #expect(session.activationCalls == [true, false]) + } + + @Test func prepareFailureCleansUpAndAllowsTheNextRecording() async throws { + try await verifyFailedStart( + firstPlan: .init(prepareResult: false, recordResult: true), + expectedPrepareCalls: 1, + expectedRecordCalls: 0 + ) + } + + @Test func recordFailureCleansUpAndAllowsTheNextRecording() async throws { + try await verifyFailedStart( + firstPlan: .init(prepareResult: true, recordResult: false), + expectedPrepareCalls: 1, + expectedRecordCalls: 1 + ) + } + + @Test func automaticStopReturnsAndPreservesFileThenNextRecordingWorks() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let session = VoiceRecorderTestSession() + let coordinator = AudioSessionCoordinator(session: session) + let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success]) + let voiceRecorder = VoiceRecorder( + sessionCoordinator: coordinator, + recorderFactory: factory, + permissionGranted: { true }, + paddingInterval: 0, + outputDirectory: directory + ) + let firstOwner = VoiceRecorder.RecordingOwner() + + let firstURL = try await voiceRecorder.startRecording(owner: firstOwner) + let firstRecorder = try #require(factory.recorders.first) + #expect(firstRecorder.recordedDurations == [120]) + firstRecorder.simulateAutomaticStop() + + let finishedURL = await voiceRecorder.stopRecording(owner: firstOwner) + await coordinator.drain() + #expect(finishedURL == firstURL) + #expect(firstRecorder.stopCallCount == 0) + #expect(FileManager.default.fileExists(atPath: firstURL.path)) + #expect(session.activationCalls == [true, false]) + + let secondOwner = VoiceRecorder.RecordingOwner() + let secondURL = try await voiceRecorder.startRecording(owner: secondOwner) + #expect(secondURL != firstURL) + #expect(FileManager.default.fileExists(atPath: firstURL.path)) + #expect(factory.recorders.count == 2) + let secondRecorder = try #require(factory.recorders.last) + + #expect(await voiceRecorder.stopRecording(owner: secondOwner) == secondURL) + await coordinator.drain() + #expect(secondRecorder.stopCallCount == 1) + #expect(session.activationCalls == [true, false, true, false]) + #expect(FileManager.default.fileExists(atPath: firstURL.path)) + #expect(FileManager.default.fileExists(atPath: secondURL.path)) + } + + @Test func rejectedNewHoldCancelCannotDeleteHoldFinishingDuringPadding() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let session = VoiceRecorderTestSession() + let coordinator = AudioSessionCoordinator(session: session) + let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success]) + let paddingGate = VoiceRecorderPaddingGate() + let voiceRecorder = VoiceRecorder( + sessionCoordinator: coordinator, + recorderFactory: factory, + permissionGranted: { true }, + paddingInterval: 0.05, + outputDirectory: directory, + testingHooks: .init(waitForStopPadding: { _ in await paddingGate.wait() }) + ) + let finishingHold = VoiceNoteCaptureSession(recorder: voiceRecorder) + let rejectedHold = VoiceNoteCaptureSession(recorder: voiceRecorder) + + try await finishingHold.start() + let firstURL = try #require(factory.urls.first) + let finishTask = Task { await finishingHold.finish() } + await waitUntil { paddingGate.entered } + + await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { + try await rejectedHold.start() + } + // This is the view-model error path that used to globally cancel the + // shared recorder and delete `firstURL` during the padding sleep. + await rejectedHold.cancel() + paddingGate.open() + + #expect(await finishTask.value == firstURL) + await coordinator.drain() + #expect(FileManager.default.fileExists(atPath: firstURL.path)) + #expect(factory.recorders[0].stopCallCount == 1) + #expect(session.activationCalls == [true, false]) + } + + @Test func stalePreviousHoldCancelCannotStopNewRecording() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let session = VoiceRecorderTestSession() + let coordinator = AudioSessionCoordinator(session: session) + let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success]) + let voiceRecorder = VoiceRecorder( + sessionCoordinator: coordinator, + recorderFactory: factory, + permissionGranted: { true }, + paddingInterval: 0, + outputDirectory: directory + ) + let previousHold = VoiceNoteCaptureSession(recorder: voiceRecorder) + let currentHold = VoiceNoteCaptureSession(recorder: voiceRecorder) + + try await previousHold.start() + let firstURL = try #require(factory.urls.first) + #expect(await previousHold.finish() == firstURL) + + try await currentHold.start() + let secondURL = try #require(factory.urls.last) + let secondRecorder = try #require(factory.recorders.last) + await previousHold.cancel() + + #expect(secondRecorder.isRecording) + #expect(FileManager.default.fileExists(atPath: secondURL.path)) + #expect(await currentHold.finish() == secondURL) + await coordinator.drain() + #expect(secondRecorder.stopCallCount == 1) + #expect(FileManager.default.fileExists(atPath: firstURL.path)) + #expect(FileManager.default.fileExists(atPath: secondURL.path)) + } + + private func verifyFailedStart( + firstPlan: TestVoiceAudioRecorderFactory.Plan, + expectedPrepareCalls: Int, + expectedRecordCalls: Int + ) async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let session = VoiceRecorderTestSession() + let coordinator = AudioSessionCoordinator(session: session) + let factory = TestVoiceAudioRecorderFactory(plans: [firstPlan, .success]) + let voiceRecorder = VoiceRecorder( + sessionCoordinator: coordinator, + recorderFactory: factory, + permissionGranted: { true }, + paddingInterval: 0, + outputDirectory: directory + ) + let failedOwner = VoiceRecorder.RecordingOwner() + + await #expect(throws: VoiceRecorder.RecorderError.failedToStartRecording) { + try await voiceRecorder.startRecording(owner: failedOwner) + } + await coordinator.drain() + + let failedRecorder = try #require(factory.recorders.first) + let failedURL = try #require(factory.urls.first) + #expect(failedRecorder.prepareCallCount == expectedPrepareCalls) + #expect(failedRecorder.recordedDurations.count == expectedRecordCalls) + #expect(!FileManager.default.fileExists(atPath: failedURL.path)) + #expect(session.activationCalls == [true, false]) + + let nextOwner = VoiceRecorder.RecordingOwner() + let nextURL = try await voiceRecorder.startRecording(owner: nextOwner) + #expect(FileManager.default.fileExists(atPath: nextURL.path)) + #expect(await voiceRecorder.stopRecording(owner: nextOwner) == nextURL) + await coordinator.drain() + #expect(session.activationCalls == [true, false, true, false]) + } +} diff --git a/bitchatTests/XChaCha20Poly1305CompatTests.swift b/bitchatTests/XChaCha20Poly1305CompatTests.swift index 9b607a9b..a90e1de0 100644 --- a/bitchatTests/XChaCha20Poly1305CompatTests.swift +++ b/bitchatTests/XChaCha20Poly1305CompatTests.swift @@ -13,7 +13,7 @@ import struct Foundation.Data struct XChaCha20Poly1305CompatTests { @Test func sealAndOpenRoundtrip() throws { - let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)! + let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8) let key = Data(repeating: 0x42, count: 32) let nonce = Data(repeating: 0x24, count: 24) @@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealAndOpenWithAAD() throws { - let plaintext = "Secret message".data(using: .utf8)! + let plaintext = Data("Secret message".utf8) let key = Data(repeating: 0xAB, count: 32) let nonce = Data(repeating: 0xCD, count: 24) - let aad = "additional authenticated data".data(using: .utf8)! + let aad = Data("additional authenticated data".utf8) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad) let decrypted = try XChaCha20Poly1305Compat.open( @@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealProducesDifferentCiphertextWithDifferentNonces() throws { - let plaintext = "Same plaintext".data(using: .utf8)! + let plaintext = Data("Same plaintext".utf8) let key = Data(repeating: 0x42, count: 32) let nonce1 = Data(repeating: 0x01, count: 24) let nonce2 = Data(repeating: 0x02, count: 24) @@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnShortKey() { - let plaintext = "Test".data(using: .utf8)! + let plaintext = Data("Test".utf8) let shortKey = Data(repeating: 0x42, count: 16) let nonce = Data(repeating: 0x24, count: 24) @@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnLongKey() { - let plaintext = "Test".data(using: .utf8)! + let plaintext = Data("Test".utf8) let longKey = Data(repeating: 0x42, count: 64) let nonce = Data(repeating: 0x24, count: 24) @@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnEmptyKey() { - let plaintext = "Test".data(using: .utf8)! + let plaintext = Data("Test".utf8) let emptyKey = Data() let nonce = Data(repeating: 0x24, count: 24) @@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnShortNonce() { - let plaintext = "Test".data(using: .utf8)! + let plaintext = Data("Test".utf8) let key = Data(repeating: 0x42, count: 32) let shortNonce = Data(repeating: 0x24, count: 12) @@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnLongNonce() { - let plaintext = "Test".data(using: .utf8)! + let plaintext = Data("Test".utf8) let key = Data(repeating: 0x42, count: 32) let longNonce = Data(repeating: 0x24, count: 32) @@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnEmptyNonce() { - let plaintext = "Test".data(using: .utf8)! + let plaintext = Data("Test".utf8) let key = Data(repeating: 0x42, count: 32) let emptyNonce = Data() @@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func openFailsWithWrongKey() throws { - let plaintext = "Secret".data(using: .utf8)! + let plaintext = Data("Secret".utf8) let correctKey = Data(repeating: 0x42, count: 32) let wrongKey = Data(repeating: 0x43, count: 32) let nonce = Data(repeating: 0x24, count: 24) @@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func openFailsWithTamperedCiphertext() throws { - let plaintext = "Secret".data(using: .utf8)! + let plaintext = Data("Secret".utf8) let key = Data(repeating: 0x42, count: 32) let nonce = Data(repeating: 0x24, count: 24) diff --git a/docs/ARTI-BINARY-PROVENANCE.md b/docs/ARTI-BINARY-PROVENANCE.md index a13240ba..9f44db3c 100644 --- a/docs/ARTI-BINARY-PROVENANCE.md +++ b/docs/ARTI-BINARY-PROVENANCE.md @@ -18,7 +18,9 @@ The crate declares `rust-version = "1.90"` and uses `arti-client` / `tor-rtcompa - `aarch64-apple-ios` - `aarch64-apple-ios-sim` +- `x86_64-apple-ios` - `aarch64-apple-darwin` +- `x86_64-apple-darwin` It builds release static libraries with size-oriented flags (`opt-level=z`, fat LTO, one codegen unit, `panic=abort`, stripped symbols), normalizes static-archive metadata with `xcrun libtool -static -D`, then packages them with `xcodebuild -create-xcframework`. @@ -29,30 +31,34 @@ From the repo root: ```sh cd localPackages/Arti rustup toolchain install 1.96.0 -rustup default 1.96.0 -rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin -cargo install cbindgen +rustup target add --toolchain 1.96.0 aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios aarch64-apple-darwin x86_64-apple-darwin +rustup run 1.96.0 cargo install cbindgen --version 0.29.4 --locked ./build-ios.sh ``` +`build-ios.sh` defaults to the audited `1.96.0` toolchain and refuses a rustc +version other than `1.96.0`; it likewise requires cbindgen `0.29.4`. Set +`RUST_TOOLCHAIN`, `RUSTC_VERSION`, or `CBINDGEN_VERSION` explicitly only when +intentionally updating the binary provenance and hashes below. + After rebuilding, verify that: - `Cargo.lock` changes are intentional and reviewed. - `Frameworks/include/arti.h` still matches the exported FFI functions used by `TorManager`. -- `Frameworks/arti.xcframework` contains iOS device, iOS simulator, and macOS arm64 slices. +- `Frameworks/arti.xcframework` contains iOS device arm64, universal iOS simulator arm64+x86_64, and universal macOS arm64+x86_64 slices. - The main app still passes iOS tests and the macOS build. ## Audited Rebuild -The June 2026 artifact below was rebuilt from source on this host with: +The July 2026 artifact below was rebuilt from source on this host with: ```text rustc 1.96.0 (ac68faa20 2026-05-25) cargo 1.96.0 (30a34c682 2026-05-25) rustup 1.29.0 (28d1352db 2026-03-05) -cbindgen 0.29.3 -Xcode 26.5 -Build version 17F42 +cbindgen 0.29.4 +Xcode 26.6 +Build version 17F113 ``` Rust 1.86.0 was also checked during the audit and no longer builds this lockfile because `typed-index-collections@3.4.0` requires Rust 1.90.0 or newer. @@ -70,13 +76,13 @@ find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 Current hashes: ```text -2083d44eafc765db1ffa2691a5c5fabe60b4edbb82b574169ca0c6b98e245e3a localPackages/Arti/Frameworks/arti.xcframework/Info.plist -551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h -85febff37b751df667a3cab8222de2e1450cefe44b5b62c419adcbce48b9663f localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a +cac99db408280bbef15cae8ce64c8ccdbf2e8863c205168d59f83fe8ab680f94 localPackages/Arti/Frameworks/arti.xcframework/Info.plist 551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h -fd25ee379d709a794733fc3c052746d1e6f7b25fec23e5f5234008a3434ce879 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a -551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h -8c426a41dc3eb76cc3e3e22e3356b9d11dbebdf0a0f248c5ac892e1839352c75 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a +5461a231a786812e91e7965290031ea3479fdc5c6459553e46988ecafbbc2a3d localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a +551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/Headers/arti.h +af8f5f636eb6affb309b3e44f13e48498eb2540c77af44ddcd7fdf9241b1e317 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a +551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/Headers/arti.h +7c9afe98227f1767567ddcd4e35d9dfffe70309c302c4dbc9a6c9d6aeefab007 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a ``` ## Review Checklist diff --git a/docs/PUSH-TO-TALK-DESIGN.md b/docs/PUSH-TO-TALK-DESIGN.md new file mode 100644 index 00000000..fc3a69fd --- /dev/null +++ b/docs/PUSH-TO-TALK-DESIGN.md @@ -0,0 +1,162 @@ +# Smart Push-to-Talk (PTT) — Design + +**Status:** Draft for review — no code yet +**Scope:** Live voice bursts over the BLE mesh, in public chat and Noise DMs, with graceful degradation to the existing voice-note pipeline. + +## 1. Goal and core idea + +Today, holding the mic button records an AAC `.m4a` and ships it as a `fileTransfer` (0x22) after you release — the receiver hears nothing until the whole file arrives. PTT makes the same gesture *live*: encoded audio frames stream over the mesh while you speak, and nearby receivers hear you with sub-second delay, walkie-talkie style. + +The "smart" part is that PTT is not a separate mode the user must choose. It is a **delivery strategy**, picked automatically per conversation: + +| Context | Delivery | +|---|---| +| DM, peer connected/reachable on mesh | **Live stream** (Noise-encrypted frames) + finalized voice note for reliability | +| DM, peer only reachable via Nostr | Existing voice-note recording only (no live; media doesn't ride Nostr today) | +| Public mesh chat | **Live broadcast stream** (signed) + finalized voice note, same dedup as DMs | +| Geohash (Nostr) channels | PTT unavailable (matches existing `canSendMediaInCurrentContext` media policy) | + +*(Public originally speced as live-only to avoid doubling bandwidth, but dropping the note broadcast would regress mixed-version meshes — old clients that can't decode live bursts would stop receiving public voice entirely — and late joiners/out-of-range peers would get nothing. The note stays; live receivers absorb it silently.)* + +One gesture (hold mic), one mental model ("talk"), and the system degrades from live → reliable-note → unavailable based on what the transport can actually do. + +**Bandwidth reality check:** the mesh moves ~15 KB/s per link (469 B fragments at 30 ms spacing); our voice codec needs ~2 KB/s. Live voice fits with a wide margin, even relayed. + +## 2. What already exists (reused, not rebuilt) + +- **Capture UX:** `ContentComposerView.micButtonView` already implements hold-to-record / release-to-send via `DragGesture`, backed by `VoiceRecordingViewModel`'s state machine and `VoiceRecorder` (AAC-LC, 16 kHz mono, 16 kbps). Mic permission string is already in Info.plist. +- **Reliable delivery:** `BitchatFilePacket` TLV + `fileTransfer` (0x22) + fragmentation + transfer progress + `ChatMediaTransferCoordinator`. +- **Playback:** `VoiceNotePlaybackController` + `VoiceNotePlaybackCoordinator` (single active playback), `WaveformView`, `VoiceNoteView`. +- **Transport:** signed public packets, Noise sessions with typed inner payloads, `RelayController` flood control, `MessageDeduplicator`, `MessageRouter.canDeliverPromptly`. + +New work is the **streaming path**: a frame encoder/packetizer, a wire format for bursts, a jitter-buffered receiver/player, relay policy, and the live-bubble UI. + +## 3. Wire protocol + +### 3.1 Burst framing (shared inner format) + +A talk burst is a sequence of packets sharing an 8-byte random `burstID`: + +``` +[burstID: 8][seq: UInt16 BE][flags: 1][payload…] +``` + +`flags` bits: +- `0x01 START` — payload is a header TLV: codec (1 B enum, 0x01 = AAC-LC/16kHz/mono), frame duration ms, batch size. Sent as seq 0 and re-attached (piggybacked TLV) every ~2 s so mid-burst joiners can sync. +- `0x02 END` — payload: total data-packet count (UInt16), duration ms (UInt32). Lets receivers detect tail loss. +- `0x04 CANCELED` — sender slid-to-cancel; receivers stop playback, discard buffered audio, and drop the bubble. +- `0x00` (data) — payload is N length-prefixed AAC frames: `[len: UInt16 BE][ADTS-less raw AAC frame]…` + +Audio math: AAC-LC at 16 kHz has 1024-sample frames = **64 ms/frame ≈ 130 B at 16 kbps**. A greedy packetizer batches frames up to a **210-byte burst-content budget** (`pttMaxBurstContentBytes`), chosen so the Noise ciphertext (content + 1 inner-type byte + 16-byte tag) stays inside `MessagePadding`'s 256-byte bucket — the whole directed packet is 288 bytes and voice **never enters the fragment scheduler** (which caps concurrent transfers at 2 and would starve file sends). At 16 kbps that works out to **1 frame/packet, ~15.6 pkt/s, ~5.3 KB/s wire** for DMs; public bursts (unpadded, planned for phase 2) can batch 3 frames. + +### 3.2 Public mesh: new `MessageType.voiceFrame = 0x29` + +- Broadcast (no recipient), **signed** like public messages; unsigned or signature-mismatched frames are dropped on receive. +- **No padding** (add to `BLEOutboundPacketPolicy` alongside `fileTransfer`) — padding to the 512 block would push every packet over MTU into fragmentation. +- TTL: `messageTTL` (7) at origin; `RelayController` gets a `voiceFrame` case mirroring the fragment policy — dense clamp to 5, jitter 8–25 ms — so live audio floods like fragments, not like announces. Per-hop cost is ~10–40 ms; 3 hops stays comfortably inside the jitter buffer. +- Dedup: existing `MessageDeduplicator` (timestamp ms + seq make each packet unique). +- 0x29 is the next free code after `nostrCarrier = 0x28`; unknown types are ignored by older clients (iOS and Android), so rollout is compatible — old clients simply don't hear live bursts. + +### 3.3 DM: new `NoisePayloadType.voiceFrame = 0x08` + +- Inner payload = the same burst framing, wrapped in `noiseEncrypted` (0x11) directed packets — wire-indistinguishable from other DM traffic by size/type (0x04/0x05 are reserved per the comment in `BitchatProtocol.swift`; 0x08 is the first free slot after `groupKeyUpdate = 0x07`). +- Directed encrypted packets are already always-relayed by `RelayController`, so multi-hop DMs work unchanged. +- Requires an established session; PTT-live is only offered when `noiseService.hasEstablishedSession` (otherwise first hold triggers the normal handshake + falls back to voice-note for that burst). +- Fire-and-forget: **no delivery acks, no retransmit** for frames. Late audio is worthless; reliability comes from the finalized note (§5). + +### 3.4 Known traffic-analysis tradeoff + +A steady ~8 pkt/s cadence for the duration of a burst is a timing fingerprint even under Noise (observers can infer "someone is speaking to someone", not content). This is inherent to live voice; the doc records it as accepted for v1. Block padding is skipped for voiceFrame (size classes would leak little beyond what cadence already does). + +## 4. Sender pipeline + +`VoiceRecorder` (AVAudioRecorder → file) can't stream, so PTT capture uses a parallel path in a new `PTTStreamEncoder` actor: + +``` +AVAudioEngine input tap (native format) + → PTTInputResampler → 16 kHz mono PCM + → PTTFrameEncoder (AVAudioConverter) → AAC-LC frames → packetizer → BLEService.sendVoiceFrame + → the same PCM is simultaneously written to an AVAudioFile .m4a (identical settings + to VoiceRecorder), so finalization needs no remux step +``` + +- On release: emit `END`, then hand the finalized `.m4a` to the **existing** `sendVoiceNote` flow. The note's `fileName` embeds the burst ID (`voice_.m4a`) — that links note↔burst with **zero changes** to `BitchatFilePacket`/Android interop. +- On slide-to-cancel: stop tap, emit `CANCELED`, delete local file, skip finalization. UX note: unlike voice notes, live audio already played on the far side cannot be unsent — the recording UI must make "you are live" unmistakable (see §7). +- Caps: max burst 120 s (matching the voice-note recorder's cap), exactly one outbound burst at a time. +- Audio session: reuse `VoiceRecorder`'s `.playAndRecord` config; add `.duckOthers`. + +## 5. Receiver pipeline + +New `VoiceBurstAssembler` (keyed by sender + burstID) feeding a `PTTBurstPlayer`: + +- **Jitter buffer:** start playback after 350 ms of buffered audio or 500 ms wall-clock, whichever first. Frames decode via `AVAudioConverter` → `AVAudioPlayerNode`. +- **Loss handling:** gap in seq → insert silence for the missing frames (64 ms each) and keep going. No PLC in v1; at these frame sizes brief dropouts are acceptable. +- **Burst end:** on `END`, or 3 s with no frames (talker walked out of range). +- **Persistence:** frames append to an incoming ADTS `.aac` file (already an allowed `MimeType`), so every burst becomes a replayable voice-note bubble containing whatever was captured — even a partial one. +- **Dedup with the finalized note:** when a `fileTransfer` arrives whose fileName carries a burstID we already assembled (DM or public), it silently *replaces* the partial file behind the existing bubble (no new message row, no second notification). Receivers that heard everything live just get a lossless copy. +- **Resource caps:** ≤ 8 concurrent assemblies, ≤ 256 KB per burst (60 s × 2 KB/s + slack), 30 s stale cleanup, and drop inbound frames beyond ~2× realtime per sender (spam/flood guard). + +## 6. Playback policy — when does it actually make sound? + +Auto-playing strangers' audio is the fastest way to make this feature hated. Rules: + +1. **Live autoplay** only when *all* hold: app foregrounded, the burst's conversation is the one currently on screen, and the **"live voice messages"** toggle is on (app-level, in the app-info sheet, default on; per-conversation overrides are a v2 refinement). The same toggle gates live *sending* — off means voice behaves exactly like classic notes in both directions. +2. Otherwise the burst appears as a **live bubble**: pulsing waveform + `LIVE` badge + sender name; tapping it joins playback at the live edge. When the burst ends it becomes a normal voice-note bubble. +3. **One voice at a time:** route through `VoiceNotePlaybackCoordinator`. If two people talk simultaneously, the first burst holds the floor; the second shows as a tappable live bubble. No mixing in v1. +4. Notifications: a live burst in a non-focused DM fires the normal message notification once (at START), not per-frame. + +**Floor courtesy (public mesh):** while someone else's burst is live in the current channel, the mic button tints "busy" with the talker's name. Holding it still works — a decentralized mesh has no floor arbiter, and rejecting sends would desync under partitions — but the UI discourages talk-over. Hard floor control (token passing) is explicitly out of scope. + +## 7. UX + +- **Same gesture:** hold mic = talk. When the live path is active, the recording HUD shows a red pulsing **LIVE** treatment (vs. the current neutral recording UI) so the sender knows audio is leaving in real time, not on release. When live isn't available (Nostr-only peer, no session yet), the HUD looks like today's and behavior is unchanged. +- Slide-to-cancel keeps working in both modes, with the §4 caveat surfaced simply: cancel stops and discards; it can't unplay what was heard. +- VoiceOver: mirror the existing `accessibilityAction` toggle-record pattern on the mic button. +- Foreground-only in v1: no `audio` UIBackgroundMode (App Store review + battery implications). If the app backgrounds mid-burst, capture stops cleanly with END. Background listen/talk is a v2 candidate. + +## 8. Latency budget (1 hop, DM) + +| Stage | Cost | +|---|---| +| Frame accumulation (1 × 64 ms) | 64 ms | +| Encode + packetize | ~5 ms | +| BLE write + delivery | 30–60 ms | +| Jitter buffer | 350 ms | +| **Mouth-to-ear** | **~470 ms** | + +Each relay hop adds ~10–40 ms jitter + radio time. 2–3 hops stays under ~800 ms — solidly in walkie-talkie territory (commercial PoC apps run 300 ms–1 s+). + +## 9. Security & privacy summary + +- Public frames signed with the existing packet signature; verified against the sender's announce before decode. Unsigned → dropped. +- DM frames ride inside Noise; content confidentiality/integrity as any DM. +- Codec input is validated by frame-length prefixes and total-size caps before touching `AVAudioConverter` (malformed frames dropped, assembly aborted over cap). +- Timing fingerprint accepted per §3.4. No PTT in geohash channels (would put voice on public Nostr relays). +- Mic capture only while the button is held; recording state is always visible. + +## 10. New components & touch points + +| Piece | Where | +|---|---| +| `PTTStreamEncoder` (tap → AAC → packets) | `bitchat/Features/voice/` | +| `PTTBurstPlayer` (jitter buffer → decode → engine) | `bitchat/Features/voice/` | +| `VoiceBurstFramer` / `VoiceBurstAssembler` (wire encode/decode, caps) | `bitchat/Protocols/` + `bitchat/Services/PTT/` | +| `MessageType.voiceFrame = 0x29` | `localPackages/BitFoundation/.../MessageType.swift` + `BLEReceivePipeline` / `BLEService.handleReceivedPacket` | +| `NoisePayloadType.voiceFrame = 0x08` | `BitchatProtocol.swift` + `ChatTransportEventCoordinator` dispatch | +| Relay policy case | `RelayController` (fragment-like clamp/jitter) | +| No-padding rule | `BLEOutboundPacketPolicy` | +| Pacing/cap constants | `TransportConfig` | +| Live bubble + LIVE HUD + busy mic tint + settings toggle | `VoiceNoteView`/`MediaMessageView`, `ContentComposerView`, conversation settings | +| Delivery-mode selection | `ChatViewModel` / `ChatMediaTransferCoordinator` (reuse `isPeerConnected` / `canDeliverPromptly` / `hasEstablishedSession`) | + +## 11. Phasing + +1. **Phase 1 — DM live (highest value, lowest blast radius):** encoder, framer, Noise inner type, assembler/player, live bubble, finalize-as-note dedup. Single-hop DMs are the dominant real-world case. +2. **Phase 2 — public mesh:** `voiceFrame` 0x29, signing/verification, relay policy, floor-courtesy UI, autoplay defaults. +3. **Phase 3 (v2 candidates):** background audio mode, mid-burst repair requests (piggyback on REQUEST_SYNC), talk-over mixing, AAC-ELD low-delay profile, dedicated walkie-mode screen, media-over-Nostr (unlocks live-ish PTT for internet peers), Android protocol spec sync. + +## 12. Testing + +- Unit: framer round-trip (START/data/END/CANCELED, seq gaps, oversize frames), assembler caps + stale cleanup + burstID note dedup, packetizer batch sizing vs. MTU (assert no fragmentation), relay-policy clamps. +- Integration: two-simulator loopback via the existing mesh test harness; loss injection (drop every Nth frame) → verify silence-fill + partial-file persistence. +- Device: two-phone live DM (latency measurement vs. §8 budget), three-phone relay chain, talk-over behavior, cancel semantics, mic-permission-denied path. diff --git a/docs/REQUEST_SYNC_MANAGER.md b/docs/REQUEST_SYNC_MANAGER.md index b304508e..a885f159 100644 --- a/docs/REQUEST_SYNC_MANAGER.md +++ b/docs/REQUEST_SYNC_MANAGER.md @@ -20,9 +20,11 @@ The new implementation introduces a **RequestSyncManager** to track outgoing syn ### Request Sync Payload The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include: -* **Future Filters**: - * `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian). - * `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string). +* `sinceTimestamp` (Type 0x05): filter-coverage cursor (UInt64 big-endian). The requester's GCS filter only covers packets at or after this timestamp; the responder skips older packets instead of re-sending them every round. +* `fragmentIdFilter` (Type 0x06): targeted fragment resync (UTF-8 string). Comma-separated 16-hex-char (8-byte) fragment **stream IDs** — the ID that prefixes every fragment payload. + * **Requester**: when a broadcast reassembly stalls (no new fragment for 5 s), the fragment assembler reports the stream ID and a `REQUEST_SYNC` with `types = fragment` and this filter goes to each connected peer (re-requested at most every 10 s per stream). Directed reassemblies are excluded — peers only archive broadcast fragments for sync. + * **Responder**: when the filter is present, the fragment diff is restricted to exactly the named streams and the `sinceTimestamp` cursor is bypassed for them; the GCS filter still excludes pieces the requester already holds. Responses keep RSR marking, TTL 0, per-peer response rate limiting (8/30 s), and `REQUEST_SYNC` itself remains link-local (TTL 0, never relayed). + * **Bounds**: at most 60 IDs per request. Each ID encodes as 16 hex chars plus a comma separator, so the largest value is 60 × 17 − 1 = 1019 bytes, within the decoder's 1024-byte acceptance cap; oversized filter values are ignored (the rest of the request still decodes). ## Architecture diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index 6832a16e..55d00232 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -2,7 +2,7 @@ This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays. -**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). +**Status:** Implemented in Android and iOS: both decode routed packets, forward along routes, and originate routes. iOS origination is policy-gated (see §8). Backward compatible (v1 clients never receive routed frames from iOS: routes are only originated when every node on the path has been observed speaking v2). --- @@ -144,3 +144,44 @@ When a node receives a packet **not** addressed to itself: * **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery. 3. **If NO (Standard):** * Flood the packet to all connected neighbors (subject to TTL and probability rules). + +--- + +## 8. iOS Origination Policy + +iOS attaches a route (upgrading the packet to v2 and re-signing it) only when +**all** of the following hold at send time (`BLESourceRouteOriginationPolicy`): + +1. **Authored locally.** The packet's `SenderID` is our own peer ID. Relays + never rewrite someone else's packet — adding a route would force a + re-sign under the wrong key. Relays only *follow* existing routes + (`BLERouteForwardingPolicy`). +2. **Directed.** The packet has a single-peer `RecipientID` (not the + broadcast ID). In practice this covers Noise-encrypted private traffic, + private file transfers, and their fragments (fragments inherit the + parent's route and version, per §5). +3. **TTL headroom.** `TTL > 1`. Link-local packets (e.g. `REQUEST_SYNC`, + always TTL 0) never carry routes. +4. **Recipient not directly connected.** A direct write already delivers in + one hop; a route would only add bytes. +5. **Complete v2 path exists.** BFS over the confirmed-edge mesh graph + (`MeshTopologyTracker`, built from verified announce `directNeighbors` + claims, entries expiring after 60 s) finds a path with **at most 4 + intermediate hops** where every intermediate hop **and the recipient** + has been observed originating or relaying a v2 packet. Nodes never seen + speaking v2 are assumed v1-only and are excluded — a v1 client cannot + decode a v2 frame, so routing through it would silently drop the packet. +6. **No recent route failure.** See below. + +If any gate fails, behavior is exactly the pre-routing flood/direct-write +path — v1 peers observe no change. + +### Failure Fallback + +A routed unicast rides one path; a broken hop loses the packet where a flood +would heal around it. iOS keeps a small per-recipient health cache +(`BLESourceRouteFailureCache`): a routed send that sees no inbound packet +authored by the recipient within 10 s counts as a route failure, and directed +sends to that recipient fall back to flooding for the next 60 s before +routing is attempted again. Retransmission of the payload itself stays where +it always was (MessageRouter and higher layers). diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index bcb5c178..645e5df7 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -1,79 +1,99 @@ -BitChat Privacy Assessment -========================== +# bitchat Privacy Assessment -Scope -- Mesh transport (BLE) behavior and metadata minimization -- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted) -- Nostr-backed public geohash channels, presence heartbeats, and location notes -- Optional CoreLocation use for geohash channel discovery -- Read receipts and delivery acknowledgments -- Logging/telemetry posture and controls +Last reviewed: July 2026 -Summary -- No accounts and no project-operated servers. Mesh traffic is peer-to-peer; Nostr is used for mutual-favorite private fallback and public geohash features. -- BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts. -- Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads. -- UI and storage remain mostly ephemeral; message content is not persisted to disk by default. Minimal local state (e.g., read-receipt IDs, favorites, selected/bookmarked geohashes) is stored for UX and is bounded or user-wipeable. -- Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed. +## Scope -BLE Privacy Considerations -- Announce content: Unchanged — nickname + Noise public key only. -- Local Name: Not used (explicitly disabled). Avoids leaking device/OS identity. -- Address: iOS uses BLE MAC randomization; BitChat does not attempt to set static addresses. -- Announce jitter: Each announce is delayed by a small random jitter to avoid synchronization-based correlation. -- Scanning: Foreground scanning uses “allow duplicates” briefly to improve discovery latency; background uses standard scanning parameters. -- RSSI gating: The acceptance threshold adapts to nearby density (approx. -95 to -80 dBm) to reduce long-distance observations in dense areas and improve connectivity in sparse ones. -- Fragmentation: Fragments use write-with-response for reliability (less re-broadcast churn = fewer repeated signals). -- GATT permissions: Private characteristic disallows .read; we use notify/write/writeWithoutResponse to avoid exposing plaintext attributes over GATT. +- BLE discovery, mesh routing, gossip sync, private delivery, and courier behavior +- Nostr private fallback, bridge courier drops, mesh bridging, geohash channels, and notices +- CoreLocation and reverse geocoding +- Local persistence, panic wipe, logging, and App Store privacy manifests -Mesh Routing and Multi-hop Limits -- Encrypted relays permitted with random per-hop delay (small jitter) to smooth floods. -- TTL cap: Encrypted payloads are capped at 2 hops, limiting metadata spread and path reconstruction risk while enabling close-range relays. +The user-facing contract is `PRIVACY_POLICY.md`. This document records implementation-level behavior and residual risks that should be re-audited when storage or transport semantics change. -Nostr Private Messaging Fallback -- Usage criteria: Only attempted for mutual favorites or where a Nostr key has been exchanged (stored in favorites). -- Payload confidentiality: Messages embed a BitChat Noise-encrypted packet inside a NIP-17 gift wrap; relays see only random-looking ciphertext. -- Timestamp handling: Gift wraps add small randomized offsets to reduce exact timing correlation. -- Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata. -- Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available. +## Current Posture -Location Channels and Geohash Public Chats -- Location permission: Optional when-in-use CoreLocation access computes local geohash channel options. Exact coordinates are held in memory only and are not included in BitChat or Nostr payloads. -- Local state: Selected channel, teleported geohashes, bookmarks, and bookmark display names are stored in `UserDefaults`; the panic action clears location presence state along with identity/session state. -- Geohash precision: User-selected channels can range from region-level to building-level. Public geohash messages and location notes expose the selected geohash tag to relays and participants. -- Presence minimization: Automatic presence heartbeats are restricted to low-precision region/province/city geohashes and use randomized timing. -- Per-geohash identities: Public geohash Nostr identities are derived from a device seed stored in the keychain, reducing cross-channel linkability compared with a single stable public key. -- Relay metadata: Relays can observe event kind, geohash tag, public key, timestamp, and network metadata. Content in public geohash channels is intentionally public to that channel. +- The project operates no account system, analytics pipeline, advertising SDK, or project-owned messaging backend. +- Mesh transport is peer-to-peer. Optional internet features use third-party Nostr relays and can expose public content, coarse geohashes, timing, relay, and network metadata. +- Private payloads are end-to-end encrypted, but public mesh, board, bridge, and geohash content is intentionally visible to its participants. +- Local storage is bounded where practical and included in panic wipe, but it is not wholly ephemeral. The app persists the stores listed below. +- The app and share extension each bundle a privacy manifest declaring their actual required-reason API use. -Read Receipts and Delivery Acks -- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists. -- Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs. -- Coalescing (optional future): When entering a chat with many unread, only send READ for the latest message, marking older as read locally to reduce metadata. +## BLE Discovery and Metadata -Data Retention and State -- Messages: Ephemeral in-memory only; history is bounded per chat and trimmed. -- Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory. -- Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action. -- Location channels: Exact coordinates are not persisted by BitChat. Selected/bookmarked geohashes, teleport flags, and bookmark display names persist locally until removed, panic-wiped, or the app is deleted. -- Geohash identities: Device seed is stored in the keychain and used to derive per-geohash Nostr identities deterministically. -- Relay persistence: Public geohash events, location notes, and encrypted gift wraps may be retained by relays according to each relay's policy. -- Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports. +Signed announces can expose: -Logging and Telemetry -- Centralized `SecureLogger` filters potential secrets and uses OSLog privacy markers. -- Default level: `info`; release builds suppress debug. Developers can set `BITCHAT_LOG_LEVEL=debug|info|warning|error|fault`. -- Transport routing, ACK sends, subscribe/connect noise were downgraded from info→debug. -- OS/system errors (e.g., transient WebSocket disconnects) may still appear in system logs; BitChat avoids re-logging those unless actionable. +- Nickname, persistent Noise public key, and Ed25519 signing public key +- Capability flags +- A bounded set of short direct-neighbor identifiers +- A coarse rendezvous geohash when the bridge capability is enabled -Residual Risks and Mitigations -- RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization. -- Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts. -- Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range. -- Geohash inference: Public location-channel tags reveal approximate area. Mitigated by explicit channel selection, low-precision automatic presence, and per-geohash identities. -- Bookmark persistence: Locally stored geohash bookmarks may reveal places of interest on a seized/unlocked device. Mitigated by panic wipe and local-only storage. +The app does not advertise the device's user-assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address. RSSI, timing, traffic volume, and radio fingerprints remain observable to nearby receivers. -Recommendations (Next) -- Add optional coalesced READ behavior for large backlogs. -- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts. -- Allow user-configurable Nostr relay set with a “private relays only” toggle. -- Add a user-facing precision warning before posting in block/building-level geohash channels. +Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded. + +## Private Messaging and Courier Delivery + +- Direct mesh sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256. +- Undelivered outgoing private messages remain in a bounded, ChaChaPoly-sealed outbox for at most 24 hours. Its key is stored in the keychain. +- Physical couriers store opaque Noise-sealed envelopes, not plaintext. Deposits have trust-tier quotas, per-depositor caps, a global cap, and at most a 24-hour lifetime. +- Spray-and-wait copies are bounded and progress cannot be replenished by replaying a deposit. +- Delivery status advances only after real transport admission or explicit relay acceptance; late failure cannot downgrade a delivered/read message. +- Panic wipe deletes the outbox, courier mail, keys, dedup state, and active transport state. + +Residual risk: private-message metadata such as timing, radio adjacency, ciphertext size, rotating recipient tags, and relay connections remains observable. A compromised recipient device can disclose plaintext. + +## Public Gossip, Boards, and Media + +- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions. +- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas. +- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe. +- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. + +Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away. + +## Nostr and Mesh Bridge + +- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata. +- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags. +- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable. +- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants. +- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event. + +Residual risk: Nostr relay retention and logging are outside project control. Public events may be copied indefinitely. Timing, coarse location, and participation can be correlated even when content is encrypted or per-cell identities are used. + +## Location + +- When-in-use CoreLocation access computes geohash choices and bridge cells. Permission revocation stops live sampling and releases subscriptions. +- Exact coordinates are not persisted by bitchat or placed into mesh/Nostr payloads. +- Selected/bookmarked geohashes, teleport flags, and display names persist in local preferences; a fine geohash can identify a small area. +- Friendly place names use `CLGeocoder.reverseGeocodeLocation`. Apple may process the supplied location under its own privacy terms, so this operation is not accurately described as wholly on-device. +- Automatic presence is limited to lower-precision geohashes; precise posts occur through user-selected channels, notes, notices, or the bridge behavior presented in the UI. + +## Logging and Telemetry + +- `SecureLogger` uses OSLog privacy markers and filters likely secrets. Release builds suppress debug verbosity. +- No project analytics or telemetry endpoint exists. +- Apple system logs, Nostr relays, network providers, and nearby radios can still observe operational metadata outside the project's logging layer. + +## Privacy Manifests + +`bitchat/PrivacyInfo.xcprivacy` declares: + +- UserDefaults: `CA92.1` for app-only preferences and `1C8F.1` for the shared app group +- File timestamps/metadata: `C617.1` for app-container files and `3B52.1` for user-granted files +- System boot time: `35F9.1` for elapsed-time deadlines and timers + +`bitchatShareExtension/PrivacyInfo.xcprivacy` declares app-group UserDefaults reason `1C8F.1`. Both manifests declare no tracking domains and no data collection by the app developer. They must remain bundled in their respective executable bundles. + +## Panic Wipe Coverage + +The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test. + +## Release Review Checklist + +- Reconcile every new Application Support, UserDefaults, keychain, cache, or relay write with this assessment and `PRIVACY_POLICY.md`. +- Re-scan required-reason APIs and validate both bundled `PrivacyInfo.xcprivacy` files before archive submission. +- Verify panic wipe reaches any newly added persistent store. +- Treat geohash precision, bridge-cell changes, new relay tags, and announce fields as privacy-surface changes. +- Re-run real-device Bluetooth, background/locked-device recovery, location revocation, and audio-route checks; simulators cannot validate the physical side of those behaviors. diff --git a/localPackages/Arti/Frameworks/arti.xcframework/Info.plist b/localPackages/Arti/Frameworks/arti.xcframework/Info.plist index 2cfc3283..42143638 100644 --- a/localPackages/Arti/Frameworks/arti.xcframework/Info.plist +++ b/localPackages/Arti/Frameworks/arti.xcframework/Info.plist @@ -26,12 +26,13 @@ HeadersPath Headers LibraryIdentifier - ios-arm64-simulator + ios-arm64_x86_64-simulator LibraryPath libarti_bitchat.a SupportedArchitectures arm64 + x86_64 SupportedPlatform ios @@ -44,12 +45,13 @@ HeadersPath Headers LibraryIdentifier - macos-arm64 + macos-arm64_x86_64 LibraryPath libarti_bitchat.a SupportedArchitectures arm64 + x86_64 SupportedPlatform macos diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a index 86b0de10..ba2f3583 100644 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a and b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/Headers/arti.h similarity index 100% rename from localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h rename to localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/Headers/arti.h diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a similarity index 51% rename from localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a rename to localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a index 9ab40ec7..18c0372b 100644 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a and b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a deleted file mode 100644 index e9525474..00000000 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a and /dev/null differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/Headers/arti.h similarity index 100% rename from localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h rename to localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/Headers/arti.h diff --git a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a new file mode 100644 index 00000000..f43f8a76 Binary files /dev/null and b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a differ diff --git a/localPackages/Arti/Package.swift b/localPackages/Arti/Package.swift index 1fe587da..c0e0ff5f 100644 --- a/localPackages/Arti/Package.swift +++ b/localPackages/Arti/Package.swift @@ -5,16 +5,16 @@ let package = Package( name: "Tor", // Keep name "Tor" for drop-in compatibility platforms: [ .iOS(.v16), - .macOS(.v13), + .macOS(.v13) ], products: [ .library( name: "Tor", targets: ["Tor"] - ), + ) ], dependencies: [ - .package(path: "../BitLogger"), + .package(path: "../BitLogger") ], targets: [ // Main Swift target @@ -22,19 +22,19 @@ let package = Package( name: "Tor", dependencies: [ "arti", - .product(name: "BitLogger", package: "BitLogger"), + .product(name: "BitLogger", package: "BitLogger") ], path: "Sources", exclude: ["C"], sources: [ "TorManager.swift", "TorURLSession.swift", - "TorNotifications.swift", + "TorNotifications.swift" ], linkerSettings: [ .linkedLibrary("resolv"), .linkedLibrary("z"), - .linkedLibrary("sqlite3"), + .linkedLibrary("sqlite3") ] ), // Binary framework containing the Rust static library. @@ -42,6 +42,6 @@ let package = Package( .binaryTarget( name: "arti", path: "Frameworks/arti.xcframework" - ), + ) ] ) diff --git a/localPackages/Arti/Sources/TorManager.swift b/localPackages/Arti/Sources/TorManager.swift index cd831ba5..384f5239 100644 --- a/localPackages/Arti/Sources/TorManager.swift +++ b/localPackages/Arti/Sources/TorManager.swift @@ -30,12 +30,6 @@ private func arti_bootstrap_progress() -> Int32 @_silgen_name("arti_bootstrap_summary") private func arti_bootstrap_summary(_ buf: UnsafeMutablePointer, _ len: Int32) -> Int32 -@_silgen_name("arti_go_dormant") -private func arti_go_dormant() -> Int32 - -@_silgen_name("arti_wake") -private func arti_wake() -> Int32 - /// Arti-based Tor integration for BitChat. /// - Boots a local Arti client and exposes a SOCKS5 proxy /// on 127.0.0.1:socksPort. All app networking should await readiness and @@ -75,10 +69,14 @@ public final class TorManager: ObservableObject { } private var didStart = false + // shutdownCompletely() resets `didStart` asynchronously (after Arti has + // actually stopped). A startIfNeeded() arriving in that window must not be + // dropped — it is recorded here and honored when the shutdown finishes. + private var shutdownsInFlight = 0 + private var startPendingAfterShutdown = false private var bootstrapMonitorStarted = false private var pathMonitor: NWPathMonitor? private var isAppForeground: Bool = true - private var isDormant: Bool = false private var lastRestartAt: Date? = nil private var startedAt: Date? = nil // Tracks initial startup time for grace period private(set) var allowAutoStart: Bool = false @@ -90,9 +88,13 @@ public final class TorManager: ObservableObject { public func startIfNeeded() { guard allowAutoStart else { return } guard isAppForeground else { return } + if shutdownsInFlight > 0 { + SecureLogger.debug("TorManager: startIfNeeded() deferred - shutdown in flight", category: .session) + startPendingAfterShutdown = true + return + } guard !didStart else { return } didStart = true - isDormant = false isStarting = true startedAt = Date() // Track startup time for grace period SecureLogger.debug("TorManager: startIfNeeded() - startedAt set", category: .session) @@ -329,6 +331,8 @@ public final class TorManager: ObservableObject { public func shutdownCompletely() { SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session) + startPendingAfterShutdown = false + shutdownsInFlight += 1 Task.detached { [weak self] in guard let self = self else { return } _ = arti_stop() @@ -341,7 +345,6 @@ public final class TorManager: ObservableObject { } await MainActor.run { - self.isDormant = false self.isReady = false self.socksReady = false self.bootstrapProgress = 0 @@ -352,6 +355,12 @@ public final class TorManager: ObservableObject { self.bootstrapMonitorStarted = false // Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded() // Clearing it here races with startup and defeats the grace period + self.shutdownsInFlight -= 1 + if self.shutdownsInFlight == 0 && self.startPendingAfterShutdown { + self.startPendingAfterShutdown = false + SecureLogger.debug("TorManager: honoring start deferred during shutdown", category: .session) + self.startIfNeeded() + } } } } @@ -365,7 +374,6 @@ public final class TorManager: ObservableObject { self.bootstrapProgress = 0 self.bootstrapSummary = "" self.isStarting = true - self.isDormant = false self.lastRestartAt = Date() } diff --git a/localPackages/Arti/build-ios.sh b/localPackages/Arti/build-ios.sh index ca01025f..97278a48 100755 --- a/localPackages/Arti/build-ios.sh +++ b/localPackages/Arti/build-ios.sh @@ -5,11 +5,20 @@ # Output: Frameworks/arti.xcframework containing static libraries for: # - aarch64-apple-ios (iOS device) # - aarch64-apple-ios-sim (iOS simulator, Apple Silicon) -# - x86_64-apple-ios (iOS simulator, Intel - optional) -# - aarch64-apple-darwin (macOS) +# - x86_64-apple-ios (iOS simulator, Intel) +# - aarch64-apple-darwin (macOS, Apple Silicon) +# - x86_64-apple-darwin (macOS, Intel) # set -e +# rustup/cargo install tools here on standard setups; GUI/Xcode shells often +# omit it from PATH even though `cargo` itself is available elsewhere. +export PATH="$HOME/.cargo/bin:$PATH" +RUST_TOOLCHAIN="${RUST_TOOLCHAIN:-1.96.0}" +RUSTC_VERSION="${RUSTC_VERSION:-1.96.0}" +CBINDGEN_VERSION="${CBINDGEN_VERSION:-0.29.4}" +export RUSTC="$(rustup which --toolchain "$RUST_TOOLCHAIN" rustc)" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" @@ -23,7 +32,9 @@ OUTPUT_DIR="$SCRIPT_DIR/Frameworks" TARGETS=( "aarch64-apple-ios" # iOS device "aarch64-apple-ios-sim" # iOS simulator (Apple Silicon) - "aarch64-apple-darwin" # macOS + "x86_64-apple-ios" # iOS simulator (Intel) + "aarch64-apple-darwin" # macOS (Apple Silicon) + "x86_64-apple-darwin" # macOS (Intel) ) # Colors for output @@ -45,23 +56,35 @@ check_prerequisites() { exit 1 fi - if ! command -v cargo &> /dev/null; then - log_error "Cargo is not installed. Please install via rustup." + if ! rustup run "$RUST_TOOLCHAIN" cargo --version &> /dev/null; then + log_error "Cargo is not installed in rustup toolchain $RUST_TOOLCHAIN." + exit 1 + fi + + local actual_rustc + actual_rustc="$(rustup run "$RUST_TOOLCHAIN" rustc --version)" + if [[ "$actual_rustc" != "rustc $RUSTC_VERSION "* ]]; then + log_error "Expected rustc $RUSTC_VERSION in $RUST_TOOLCHAIN; found $actual_rustc." exit 1 fi # Check/install targets for target in "${TARGETS[@]}"; do - if ! rustup target list --installed | grep -q "$target"; then + if ! rustup target list --toolchain "$RUST_TOOLCHAIN" --installed | grep -q "$target"; then log_info "Installing target: $target" - rustup target add "$target" + rustup target add --toolchain "$RUST_TOOLCHAIN" "$target" fi done # Install cbindgen if needed if ! command -v cbindgen &> /dev/null; then - log_info "Installing cbindgen..." - cargo install cbindgen + log_info "Installing cbindgen $CBINDGEN_VERSION..." + rustup run "$RUST_TOOLCHAIN" cargo install cbindgen --version "$CBINDGEN_VERSION" --locked + fi + + if [[ "$(cbindgen --version)" != "cbindgen $CBINDGEN_VERSION" ]]; then + log_error "Expected cbindgen $CBINDGEN_VERSION; found $(cbindgen --version)." + exit 1 fi log_info "Prerequisites OK" @@ -100,7 +123,7 @@ build_target() { setup_rustflags "$target" # Build release - cargo build --release --target "$target" -p "$CRATE_NAME" + rustup run "$RUST_TOOLCHAIN" cargo build --release --target "$target" -p "$CRATE_NAME" # Check output local lib_path="target/$target/release/$LIB_NAME" @@ -123,9 +146,7 @@ create_xcframework() { rm -rf "$xcframework_path" mkdir -p "$OUTPUT_DIR" - # Build the xcodebuild command - local cmd="xcodebuild -create-xcframework" - + # Normalize each architecture before combining the universal slices. for target in "${TARGETS[@]}"; do local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME" if [[ -f "$lib_path" ]]; then @@ -138,18 +159,43 @@ create_xcframework() { xcrun libtool -static -D -no_warning_for_no_symbols "$lib_path" -o "$normalized_path" mv "$normalized_path" "$lib_path" - cmd="$cmd -library $lib_path" - - # Add headers if they exist - local header_dir="$OUTPUT_DIR/include" - if [[ -d "$header_dir" ]]; then - cmd="$cmd -headers $header_dir" - fi else - log_warn "Skipping missing library: $lib_path" + log_error "Missing required library: $lib_path" + exit 1 fi done + local simulator_dir="$SCRIPT_DIR/target/ios-universal-simulator/release" + local simulator_lib="$simulator_dir/$LIB_NAME" + mkdir -p "$simulator_dir" + xcrun lipo -create \ + "$SCRIPT_DIR/target/aarch64-apple-ios-sim/release/$LIB_NAME" \ + "$SCRIPT_DIR/target/x86_64-apple-ios/release/$LIB_NAME" \ + -output "$simulator_lib" + + # Normalize the universal archive after lipo so repeated rebuilds remain + # byte-stable just like the single-architecture inputs. + local normalized_simulator="$simulator_lib.normalized" + xcrun libtool -static -D -no_warning_for_no_symbols "$simulator_lib" -o "$normalized_simulator" + mv "$normalized_simulator" "$simulator_lib" + + local macos_dir="$SCRIPT_DIR/target/macos-universal/release" + local macos_lib="$macos_dir/$LIB_NAME" + mkdir -p "$macos_dir" + xcrun lipo -create \ + "$SCRIPT_DIR/target/aarch64-apple-darwin/release/$LIB_NAME" \ + "$SCRIPT_DIR/target/x86_64-apple-darwin/release/$LIB_NAME" \ + -output "$macos_lib" + + local normalized_macos="$macos_lib.normalized" + xcrun libtool -static -D -no_warning_for_no_symbols "$macos_lib" -o "$normalized_macos" + mv "$normalized_macos" "$macos_lib" + + local header_dir="$OUTPUT_DIR/include" + local cmd="xcodebuild -create-xcframework" + cmd="$cmd -library $SCRIPT_DIR/target/aarch64-apple-ios/release/$LIB_NAME -headers $header_dir" + cmd="$cmd -library $simulator_lib -headers $header_dir" + cmd="$cmd -library $macos_lib -headers $header_dir" cmd="$cmd -output $xcframework_path" log_info "Running: $cmd" @@ -185,12 +231,13 @@ create_xcframework() { HeadersPath Headers LibraryIdentifier - ios-arm64-simulator + ios-arm64_x86_64-simulator LibraryPath libarti_bitchat.a SupportedArchitectures arm64 + x86_64 SupportedPlatform ios @@ -203,12 +250,13 @@ create_xcframework() { HeadersPath Headers LibraryIdentifier - macos-arm64 + macos-arm64_x86_64 LibraryPath libarti_bitchat.a SupportedArchitectures arm64 + x86_64 SupportedPlatform macos diff --git a/localPackages/BitFoundation/Package.swift b/localPackages/BitFoundation/Package.swift index a1de1be1..fbcf8e4e 100644 --- a/localPackages/BitFoundation/Package.swift +++ b/localPackages/BitFoundation/Package.swift @@ -21,7 +21,7 @@ let package = Package( .target( name: "BitFoundation", dependencies: [ - .product(name: "BitLogger", package: "BitLogger"), + .product(name: "BitLogger", package: "BitLogger") ], path: "Sources" ), diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift b/localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift index 34ce30d8..e326eed2 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift @@ -105,10 +105,6 @@ public struct BinaryProtocol { // Field offsets within packet header public struct Offsets { - static let version = 0 - static let type = 1 - static let ttl = 2 - static let timestamp = 3 public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift index ced3a9b7..cf7039fa 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift @@ -30,6 +30,9 @@ public final class BitchatMessage: Codable { public let senderPeerID: PeerID? public let mentions: [String]? // Array of mentioned nicknames public var deliveryStatus: DeliveryStatus? // Delivery tracking + /// True when this message reached us across a mesh bridge (signed by its + /// author for an internet rendezvous) rather than over local radio. + public let isBridged: Bool // Cached formatted text (not included in Codable) private var _cachedFormattedText: [String: AttributedString] = [:] @@ -46,8 +49,26 @@ public final class BitchatMessage: Codable { enum CodingKeys: String, CodingKey { case id, sender, content, timestamp, isRelay, originalSender case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus + case isBridged } - + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + sender = try container.decode(String.self, forKey: .sender) + content = try container.decode(String.self, forKey: .content) + timestamp = try container.decode(Date.self, forKey: .timestamp) + isRelay = try container.decode(Bool.self, forKey: .isRelay) + originalSender = try container.decodeIfPresent(String.self, forKey: .originalSender) + isPrivate = try container.decode(Bool.self, forKey: .isPrivate) + recipientNickname = try container.decodeIfPresent(String.self, forKey: .recipientNickname) + senderPeerID = try container.decodeIfPresent(PeerID.self, forKey: .senderPeerID) + mentions = try container.decodeIfPresent([String].self, forKey: .mentions) + deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus) + // Absent in archives written before bridging existed. + isBridged = try container.decodeIfPresent(Bool.self, forKey: .isBridged) ?? false + } + public init( id: String? = nil, sender: String, @@ -59,7 +80,8 @@ public final class BitchatMessage: Codable { recipientNickname: String? = nil, senderPeerID: PeerID? = nil, mentions: [String]? = nil, - deliveryStatus: DeliveryStatus? = nil + deliveryStatus: DeliveryStatus? = nil, + isBridged: Bool = false ) { self.id = id ?? UUID().uuidString self.sender = sender @@ -72,6 +94,7 @@ public final class BitchatMessage: Codable { self.senderPeerID = senderPeerID self.mentions = mentions self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) + self.isBridged = isBridged } } @@ -89,7 +112,8 @@ extension BitchatMessage: Equatable { lhs.recipientNickname == rhs.recipientNickname && lhs.senderPeerID == rhs.senderPeerID && lhs.mentions == rhs.mentions && - lhs.deliveryStatus == rhs.deliveryStatus + lhs.deliveryStatus == rhs.deliveryStatus && + lhs.isBridged == rhs.isBridged } } @@ -121,6 +145,7 @@ extension BitchatMessage { if recipientNickname != nil { flags |= 0x08 } if senderPeerID != nil { flags |= 0x10 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } + if isBridged { flags |= 0x40 } data.append(flags) @@ -213,6 +238,7 @@ extension BitchatMessage { let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 + let isBridged = (flags & 0x40) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -321,7 +347,8 @@ extension BitchatMessage { isPrivate: isPrivate, recipientNickname: recipientNickname, senderPeerID: senderPeerID, - mentions: mentions + mentions: mentions, + isBridged: isBridged ) } } @@ -340,22 +367,3 @@ extension BitchatMessage { Self.timestampFormatter.string(from: timestamp) } } - -extension Array where Element == BitchatMessage { - /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) - public func cleanedAndDeduped() -> [Element] { - let arr = filter { $0.content.trimmed.isEmpty == false } - guard arr.count > 1 else { - return arr - } - var seen = Set() - var dedup: [BitchatMessage] = [] - for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) { - if !seen.contains(m.id) { - dedup.append(m) - seen.insert(m.id) - } - } - return dedup - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift index b46a552d..db286ae3 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift @@ -7,14 +7,13 @@ // import struct Foundation.Data -import struct Foundation.Date /// The core packet structure for all BitChat protocol messages. /// Encapsulates all data needed for routing through the mesh network, /// including TTL for hop limiting and optional encryption. /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented public struct BitchatPacket: Codable { - let version: UInt8 + public let version: UInt8 public let type: UInt8 public let senderID: Data public let recipientID: Data? @@ -37,35 +36,7 @@ public struct BitchatPacket: Codable { self.route = route self.isRSR = isRSR } - - // Convenience initializer for new binary format - init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) { - self.version = 1 - self.type = type - // Convert hex string peer ID to binary data (8 bytes) - var senderData = Data() - var tempID = senderID.id - while tempID.count >= 2 { - let hexByte = String(tempID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - senderData.append(byte) - } - tempID = String(tempID.dropFirst(2)) - } - self.senderID = senderData - self.recipientID = nil - self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds - self.payload = payload - self.signature = nil - self.ttl = ttl - self.route = nil - self.isRSR = isRSR - } - - var data: Data? { - BinaryProtocol.encode(self) - } - + public func toBinaryData(padding: Bool = true) -> Data? { BinaryProtocol.encode(self, padding: padding) } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift new file mode 100644 index 00000000..acba7bf7 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift @@ -0,0 +1,194 @@ +// +// CourierEnvelope.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +private import CryptoKit + +/// TLV payload for store-and-forward courier envelopes. +/// +/// A courier envelope lets a mutual favorite physically carry an encrypted +/// message to a peer who is currently offline. The envelope is opaque to the +/// courier: the only routing information is a rotating recipient tag derived +/// from the recipient's Noise static public key and the UTC day, so envelopes +/// addressed to the same peer on different days do not correlate for +/// observers who don't already know that peer's public key. +public struct CourierEnvelope: Equatable { + /// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated. + public let recipientTag: Data + /// Milliseconds since epoch after which the envelope must be discarded. + public let expiry: UInt64 + /// Opaque one-way Noise X ciphertext (sender identity rides inside). + public let ciphertext: Data + /// Spray-and-wait copy budget: how many redundant copies of this envelope + /// the holder may still hand to other couriers (binary split on each + /// spray). 1 means carry-only — deliver to the recipient, never re-spray. + public let copies: UInt8 + /// Seal-format discriminator: nil means v1 (ciphertext is one-way Noise X + /// to the recipient's *static* key); a value means v2 (Noise X to the + /// recipient's one-time prekey with this ID, forward secret). Encoded as + /// an optional TLV so v1 decoders skip it as unknown: an old client still + /// carries and hands over v2 envelopes opaquely, and when one is addressed + /// to it the static-key open simply fails and is dropped quietly. + public let prekeyID: UInt32? + + public static let tagLength = 16 + /// Couriered messages are text-sized; media transfers are out of scope. + public static let maxCiphertextBytes = 16 * 1024 + /// Matches the outbox retention policy in MessageRouter. + public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60 + /// Cap on the copy budget a depositor can claim, so a malicious envelope + /// cannot turn the courier network into an amplifier. + public static let maxCopies: UInt8 = 8 + + private enum TLVType: UInt8 { + case recipientTag = 0x01 + case expiry = 0x02 + case ciphertext = 0x03 + case copies = 0x04 + case prekeyID = 0x05 + } + + public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1, prekeyID: UInt32? = nil) { + self.recipientTag = recipientTag + self.expiry = expiry + self.ciphertext = ciphertext + self.copies = min(max(copies, 1), Self.maxCopies) + self.prekeyID = prekeyID + } + + /// The same envelope with a different remaining copy budget. + public func withCopies(_ copies: UInt8) -> CourierEnvelope { + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) + } + + public var isExpired: Bool { + isExpired(at: Date()) + } + + public func isExpired(at date: Date) -> Bool { + UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry + } + + public func encode() -> Data? { + guard recipientTag.count == Self.tagLength else { return nil } + guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil } + + func appendBE(_ value: T, into data: inout Data) { + var big = value.bigEndian + withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } + } + + var encoded = Data() + encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count) + + encoded.append(TLVType.recipientTag.rawValue) + appendBE(UInt16(recipientTag.count), into: &encoded) + encoded.append(recipientTag) + + encoded.append(TLVType.expiry.rawValue) + appendBE(UInt16(8), into: &encoded) + appendBE(expiry, into: &encoded) + + encoded.append(TLVType.ciphertext.rawValue) + appendBE(UInt16(ciphertext.count), into: &encoded) + encoded.append(ciphertext) + + // Omitted when 1 so carry-only envelopes stay byte-identical to the + // pre-spray wire format (old clients skip the TLV as unknown anyway). + if copies > 1 { + encoded.append(TLVType.copies.rawValue) + appendBE(UInt16(1), into: &encoded) + encoded.append(copies) + } + + // Omitted for v1 static-sealed envelopes so they stay byte-identical + // to the pre-prekey wire format. + if let prekeyID { + encoded.append(TLVType.prekeyID.rawValue) + appendBE(UInt16(4), into: &encoded) + appendBE(prekeyID, into: &encoded) + } + + return encoded + } + + public static func decode(_ data: Data) -> CourierEnvelope? { + var cursor = data.startIndex + let end = data.endIndex + + var recipientTag: Data? + var expiry: UInt64? + var ciphertext: Data? + var copies: UInt8 = 1 + var prekeyID: UInt32? + + while cursor < end { + let typeRaw = data[cursor] + cursor = data.index(after: cursor) + + guard data.distance(from: cursor, to: end) >= 2 else { return nil } + let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) + cursor = data.index(cursor, offsetBy: 2) + guard data.distance(from: cursor, to: end) >= length else { return nil } + let value = data[cursor.. 0, length <= maxCiphertextBytes else { return nil } + ciphertext = Data(value) + case .copies: + guard length == 1 else { return nil } + copies = value.first ?? 1 + case .prekeyID: + guard length == 4 else { return nil } + prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + case nil: + // Unknown TLV: skip for forward compatibility. + continue + } + } + + guard let recipientTag, let expiry, let ciphertext else { return nil } + return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) + } + + // MARK: - Recipient Tags + + private static let tagContext = Data("bitchat-courier-tag-v1".utf8) + + /// UTC day number used to rotate recipient tags. + public static func epochDay(for date: Date) -> UInt32 { + UInt32(max(0, date.timeIntervalSince1970) / 86_400) + } + + /// Rotating recipient hint for a given day. Computable only by parties + /// who already know the recipient's Noise static public key. + public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data { + var message = tagContext + withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) } + let mac = HMAC.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey)) + return Data(mac).prefix(tagLength) + } + + /// Tags to test when checking whether an envelope is addressed to a peer. + /// Covers the adjacent days so envelopes sealed near midnight (or across + /// modest clock skew) still match while being carried. + public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] { + let day = epochDay(for: date) + return [day == 0 ? 0 : day - 1, day, day + 1].map { + recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0) + } + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift index 8f06ff74..32fa4e76 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift @@ -11,17 +11,20 @@ import struct Foundation.Date public enum DeliveryStatus: Codable, Equatable, Hashable { case sending case sent // Left our device + case carried // Sealed envelope handed to a courier; best-effort physical delivery case delivered(to: String, at: Date) // Confirmed by recipient case read(by: String, at: Date) // Seen by recipient case failed(reason: String) case partiallyDelivered(reached: Int, total: Int) // For rooms - + public var displayText: String { switch self { case .sending: return "Sending..." case .sent: return "Sent" + case .carried: + return "Carried by a friend" case .delivered(let nickname, _): return "Delivered to \(nickname)" case .read(let nickname, _): diff --git a/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift b/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift index ea78288e..575174e4 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift @@ -32,8 +32,25 @@ public protocol KeychainManagerProtocol { func save(key: String, data: Data, service: String, accessible: CFString?) /// Load data from a custom service func load(key: String, service: String) -> Data? + /// Load data from a custom service while preserving Keychain status. + /// Callers that own encrypted files need to distinguish a missing key + /// from a temporarily inaccessible key before replacing those files. + func loadWithResult(key: String, service: String) -> KeychainReadResult /// Delete data from a custom service func delete(key: String, service: String) + /// Delete every item stored under a custom service + func deleteAll(service: String) +} + +public extension KeychainManagerProtocol { + /// Source-compatible fallback for lightweight/test implementations. The + /// production manager overrides this with the underlying OSStatus. + func loadWithResult(key: String, service: String) -> KeychainReadResult { + if let data = load(key: key, service: service) { + return .success(data) + } + return .itemNotFound + } } // MARK: - Keychain Error Types diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift new file mode 100644 index 00000000..54265b8c --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift @@ -0,0 +1,57 @@ +// +// MeshPingPayload.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import struct Foundation.Data + +/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types. +/// +/// Layout (9 bytes): +/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers) +/// - 1 byte: origin TTL — the TTL the packet was launched with, so the +/// receiver can compute the hop count as `originTTL - receivedTTL`. +/// +/// Both directions are unencrypted and unsigned: the payload carries no +/// private data, and the unguessable nonce already binds a pong to a probe +/// the local device actually sent. +public struct MeshPingPayload: Equatable { + public static let nonceLength = 8 + private static let encodedLength = nonceLength + 1 + + public let nonce: Data + public let originTTL: UInt8 + + public init?(nonce: Data, originTTL: UInt8) { + guard nonce.count == Self.nonceLength else { return nil } + self.nonce = nonce + self.originTTL = originTTL + } + + public func encode() -> Data { + var data = Data(capacity: Self.encodedLength) + data.append(nonce) + data.append(originTTL) + return data + } + + /// Accepts payloads with trailing bytes so future revisions can extend + /// the format without breaking older clients. + public static func decode(_ data: Data) -> MeshPingPayload? { + guard data.count >= encodedLength else { return nil } + let nonce = Data(data.prefix(nonceLength)) + let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)] + return MeshPingPayload(nonce: nonce, originTTL: originTTL) + } + + /// Number of links a packet crossed, derived from TTL decrements plus the + /// final delivery link (a directly connected peer is 1 hop away). + /// Returns nil when the TTLs are inconsistent (received above origin). + public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? { + guard originTTL >= receivedTTL else { return nil } + return Int(originTTL - receivedTTL) + 1 + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 5c54781f..0ba2730f 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -7,33 +7,57 @@ // /// Simplified BitChat protocol message types. -/// Reduced from 24 types to just 6 essential ones. +/// Consolidated from the original 24 wire types down to the 9 cases below. /// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. public enum MessageType: UInt8 { // Public messages (unencrypted) case announce = 0x01 // "I'm here" with nickname - case message = 0x02 // Public chat message + case message = 0x02 // Public chat message case leave = 0x03 // "I'm leaving" + case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer case requestSync = 0x21 // GCS filter-based sync request (local-only) - + // Noise encryption case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) - + // Fragmentation (simplified) case fragment = 0x20 // Single fragment type for large messages case fileTransfer = 0x22 // Binary file/audio/image payloads - + case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone + case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped) + case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body) + + // Mesh diagnostics + case ping = 0x26 // Directed echo request (nonce + origin TTL) + case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL) + + // Gateway mode: signed Nostr event ferried between a mesh-only peer and + // an internet gateway peer. + case nostrCarrier = 0x28 + + // Live voice: one signed push-to-talk burst packet (ephemeral broadcast, + // never gossip-synced). Private bursts ride noiseEncrypted instead. + case voiceFrame = 0x29 + public var description: String { switch self { case .announce: return "announce" case .message: return "message" case .leave: return "leave" + case .courierEnvelope: return "courierEnvelope" case .requestSync: return "requestSync" case .noiseHandshake: return "noiseHandshake" case .noiseEncrypted: return "noiseEncrypted" case .fragment: return "fragment" case .fileTransfer: return "fileTransfer" + case .boardPost: return "boardPost" + case .prekeyBundle: return "prekeyBundle" + case .groupMessage: return "groupMessage" + case .ping: return "ping" + case .pong: return "pong" + case .nostrCarrier: return "nostrCarrier" + case .voiceFrame: return "voiceFrame" } } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift new file mode 100644 index 00000000..167f9884 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Feature capabilities a peer advertises in its announce packet. +/// +/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the +/// wire form grows only when high bits are assigned. Decoders keep the low 64 +/// bits and ignore any longer field, and unknown bits are preserved verbatim — +/// old clients skip the TLV entirely, new clients degrade per-feature. +public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static let prekeys = PeerCapabilities(rawValue: 1 << 0) + public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1) + public static let gateway = PeerCapabilities(rawValue: 1 << 2) + public static let groups = PeerCapabilities(rawValue: 1 << 3) + public static let board = PeerCapabilities(rawValue: 1 << 4) + public static let vouch = PeerCapabilities(rawValue: 1 << 5) + public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6) + /// Bridges the local mesh channel to the geohash-cell rendezvous on Nostr + /// (uplink/downlink carriers for mesh-only peers). Advertised alongside + /// a `bridgeGeohash` TLV carrying the rendezvous cell. + public static let bridge = PeerCapabilities(rawValue: 1 << 7) + + /// Minimal little-endian byte encoding; always at least one byte so an + /// empty set is distinguishable from an absent TLV. + public func encoded() -> Data { + var value = rawValue + var bytes = Data() + repeat { + bytes.append(UInt8(truncatingIfNeeded: value)) + value >>= 8 + } while value != 0 + return bytes + } + + /// Accepts any length; bytes beyond the low 64 bits are ignored for + /// forward compatibility. + public init(encoded data: Data) { + var value: UInt64 = 0 + for (index, byte) in data.prefix(8).enumerated() { + value |= UInt64(byte) << (8 * index) + } + self.init(rawValue: value) + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift index 0b2721c2..a9a18973 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift @@ -34,6 +34,13 @@ public struct PeerID: Equatable, Hashable, Sendable { case geoDM = "nostr_" /// `"nostr:"` (+ 8 characters hex) case geoChat = "nostr:" + /// `"group_"` (+ 32 characters hex) — virtual conversation ID for a + /// private group (16-byte group ID). Never routed to a single peer. + case group = "group_" + /// `"bridge:"` (+ 16 characters hex) — a sender reached across a mesh + /// bridge, identified by their rendezvous Nostr pubkey. Not a + /// routable mesh peer. + case bridge = "bridge:" } public let prefix: Prefix @@ -64,6 +71,12 @@ public extension PeerID { self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength)) } + /// Convenience init to create a bridged-sender PeerID by appending + /// `"bridge:"` to the first 16 characters of the rendezvous Nostr pubkey. + init(bridge pubKey: String) { + self.init(prefix: .bridge, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength)) + } + /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts init(str: any StringProtocol) { if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) { @@ -96,6 +109,22 @@ public extension PeerID { } } +// MARK: - Group Conversation Helpers + +public extension PeerID { + /// Convenience init to create a virtual group conversation PeerID from a + /// 16-byte group ID ("group_" + 32 hex characters). + init(groupID: Data) { + self.init(str: Prefix.group.rawValue + groupID.hexEncodedString()) + } + + /// The 16-byte group ID behind a "group_" PeerID, if this is one. + var groupIDData: Data? { + guard isGroup, bare.count == 32 else { return nil } + return Data(hexString: bare) + } +} + // MARK: - Noise Public Key Helpers public extension PeerID { @@ -143,6 +172,16 @@ public extension PeerID { prefix == .geoDM } + /// Returns true if `id` starts with "`group_`" + var isGroup: Bool { + prefix == .group + } + + /// Returns true if `id` starts with "`bridge:`" + var isBridge: Bool { + prefix == .bridge + } + func toPercentEncoded() -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift new file mode 100644 index 00000000..0e73a2c0 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift @@ -0,0 +1,195 @@ +// +// PrekeyBundle.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// TLV payload for gossiped one-time prekey bundles (MessageType 0x24). +/// +/// A bundle publishes a batch of one-time Curve25519 public prekeys bound to +/// the owner's Noise static key by an Ed25519 signature over domain-prefixed +/// canonical bytes. Anyone holding the owner's announce-verified signing key +/// can verify a bundle offline, which is what lets bundles spread and persist +/// mesh-wide via gossip sync while the owner is away. Senders seal courier +/// mail to one of these prekeys (one-way Noise X) instead of the owner's +/// long-lived static key, restoring forward secrecy for async first contact. +public struct PrekeyBundle: Equatable { + public struct Prekey: Equatable { + public let id: UInt32 + /// Curve25519.KeyAgreement public key (32 bytes). + public let publicKey: Data + + public init(id: UInt32, publicKey: Data) { + self.id = id + self.publicKey = publicKey + } + } + + /// Noise static public key identifying whose prekeys these are (32 bytes). + public let noiseStaticPublicKey: Data + /// One-time prekeys, at most `maxPrekeys` per bundle. + public let prekeys: [Prekey] + /// Milliseconds since epoch when this bundle was generated; newer bundles + /// replace older ones for the same noise key. + public let generatedAt: UInt64 + /// Ed25519 signature over `signableBytes()` by the owner's announce-bound + /// signing key. + public let signature: Data + + public static let keyLength = 32 + public static let signatureLength = 64 + public static let maxPrekeys = 8 + private static let prekeyEntryLength = 4 + keyLength + + /// Domain separation for the bundle signature so it can never be confused + /// with announce or packet signatures. + private static let signingContext = Data("bitchat-prekey-bundle-v1".utf8) + + private enum TLVType: UInt8 { + case noiseStaticPublicKey = 0x01 + case prekeys = 0x02 + case generatedAt = 0x03 + case signature = 0x04 + } + + public init(noiseStaticPublicKey: Data, prekeys: [Prekey], generatedAt: UInt64, signature: Data) { + self.noiseStaticPublicKey = noiseStaticPublicKey + self.prekeys = prekeys + self.generatedAt = generatedAt + self.signature = signature + } + + /// Canonical bytes covered by the Ed25519 signature: domain context, + /// owner key, prekey count, each (id, key) pair, and the generation time. + /// Encoders and verifiers must derive these identically. + public func signableBytes() -> Data { + var out = Data() + out.reserveCapacity(1 + Self.signingContext.count + Self.keyLength + 1 + + prekeys.count * Self.prekeyEntryLength + 8) + out.append(UInt8(min(Self.signingContext.count, 255))) + out.append(Self.signingContext.prefix(255)) + out.append(paddedKey(noiseStaticPublicKey)) + out.append(UInt8(min(prekeys.count, 255))) + for prekey in prekeys.prefix(255) { + appendBE(prekey.id, into: &out) + out.append(paddedKey(prekey.publicKey)) + } + appendBE(generatedAt, into: &out) + return out + } + + public func encode() -> Data? { + guard noiseStaticPublicKey.count == Self.keyLength, + signature.count == Self.signatureLength, + !prekeys.isEmpty, prekeys.count <= Self.maxPrekeys, + prekeys.allSatisfy({ $0.publicKey.count == Self.keyLength }) else { + return nil + } + + var entries = Data() + entries.reserveCapacity(prekeys.count * Self.prekeyEntryLength) + for prekey in prekeys { + appendBE(prekey.id, into: &entries) + entries.append(prekey.publicKey) + } + + var encoded = Data() + encoded.reserveCapacity(4 * 3 + Self.keyLength + entries.count + 8 + Self.signatureLength) + + encoded.append(TLVType.noiseStaticPublicKey.rawValue) + appendBE(UInt16(noiseStaticPublicKey.count), into: &encoded) + encoded.append(noiseStaticPublicKey) + + encoded.append(TLVType.prekeys.rawValue) + appendBE(UInt16(entries.count), into: &encoded) + encoded.append(entries) + + encoded.append(TLVType.generatedAt.rawValue) + appendBE(UInt16(8), into: &encoded) + appendBE(generatedAt, into: &encoded) + + encoded.append(TLVType.signature.rawValue) + appendBE(UInt16(signature.count), into: &encoded) + encoded.append(signature) + + return encoded + } + + public static func decode(_ data: Data) -> PrekeyBundle? { + var cursor = data.startIndex + let end = data.endIndex + + var noiseStaticPublicKey: Data? + var prekeys: [Prekey]? + var generatedAt: UInt64? + var signature: Data? + + while cursor < end { + let typeRaw = data[cursor] + cursor = data.index(after: cursor) + + guard data.distance(from: cursor, to: end) >= 2 else { return nil } + let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) + cursor = data.index(cursor, offsetBy: 2) + guard data.distance(from: cursor, to: end) >= length else { return nil } + let value = data[cursor.. 0, length % prekeyEntryLength == 0, + length / prekeyEntryLength <= maxPrekeys else { return nil } + var parsed: [Prekey] = [] + var entryStart = value.startIndex + while entryStart < value.endIndex { + let idEnd = value.index(entryStart, offsetBy: 4) + let id = value[entryStart.. Data { + let fixed = key.prefix(Self.keyLength) + guard fixed.count < Self.keyLength else { return Data(fixed) } + return Data(fixed) + Data(repeating: 0, count: Self.keyLength - fixed.count) + } +} + +private func appendBE(_ value: T, into data: inout Data) { + var big = value.bigEndian + withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift index 4016080f..ddf7c8fc 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift @@ -46,7 +46,8 @@ struct BinaryProtocolTests { // Verify recipient #expect(decodedPacket.recipientID != nil) let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() - // TODO: Check if this is intended that the decoding only gets the first 8 + // Recipient IDs are a fixed 8-byte wire field: encode pads or truncates + // to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive. #expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01") } @@ -294,7 +295,7 @@ struct BinaryProtocolTests { @Test("Create a large, compressible payload above current threshold (2048B)") func payloadCompression() throws { let repeatedString = String(repeating: "This is a test message. ", count: 200) - let largePayload = repeatedString.data(using: .utf8)! + let largePayload = Data(repeatedString.utf8) let packet = TestHelpers.createTestPacket(payload: largePayload) @@ -314,7 +315,7 @@ struct BinaryProtocolTests { @Test("Small payloads should not be compressed") func smallPayloadNoCompression() throws { - let smallPayload = "Hi".data(using: .utf8)! + let smallPayload = Data("Hi".utf8) let packet = TestHelpers.createTestPacket(payload: smallPayload) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") @@ -362,7 +363,7 @@ struct BinaryProtocolTests { var encodedSizes = Set() for payload in payloads { - let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!) + let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8)) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet") // Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently) diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift new file mode 100644 index 00000000..c0c8910f --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift @@ -0,0 +1,57 @@ +// +// BitchatMessageBridgedFlagTests.swift +// BitFoundationTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import BitFoundation + +@Suite("BitchatMessage bridged flag") +struct BitchatMessageBridgedFlagTests { + @Test func bridgedFlagSurvivesBinaryRoundTrip() throws { + let message = BitchatMessage( + sender: "far-friend", + content: "hello from across the hill", + timestamp: Date(), + isRelay: false, + senderPeerID: PeerID(bridge: "deadbeefcafe0123deadbeefcafe0123"), + isBridged: true + ) + let binary = try #require(message.toBinaryPayload()) + let decoded = try #require(BitchatMessage(binary)) + #expect(decoded.isBridged) + #expect(decoded.senderPeerID?.isBridge == true) + } + + @Test func plainMessageStaysUnbridgedThroughBinary() throws { + let plain = BitchatMessage( + sender: "neighbor", + content: "radio only", + timestamp: Date(), + isRelay: false + ) + let binary = try #require(plain.toBinaryPayload()) + let decoded = try #require(BitchatMessage(binary)) + #expect(!decoded.isBridged) + } + + @Test func legacyBinaryWithoutBridgedBitDecodesUnbridged() throws { + // A pre-bridge encoder never sets flag bit 0x40; decoding such a + // payload must default to unbridged. + let message = BitchatMessage( + sender: "old", + content: "hi", + timestamp: Date(), + isRelay: false, + isBridged: true + ) + var binary = try #require(message.toBinaryPayload()) + binary[0] &= ~UInt8(0x40) // strip the bridged bit like an old client + let decoded = try #require(BitchatMessage(binary)) + #expect(!decoded.isBridged) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift new file mode 100644 index 00000000..9631ee81 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift @@ -0,0 +1,154 @@ +// +// CourierEnvelopeTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct CourierEnvelopeTests { + + private func makeEnvelope( + tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength), + expiry: UInt64 = 1_900_000_000_000, + ciphertext: Data = Data(repeating: 0x42, count: 128) + ) -> CourierEnvelope { + CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext) + } + + // MARK: - Spray copies + + @Test func copiesRoundTrip() throws { + let envelope = makeEnvelope().withCopies(4) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.copies == 4) + #expect(decoded == envelope) + } + + @Test func carryOnlyEnvelopeEncodesIdenticallyToLegacyFormat() throws { + // copies == 1 must be byte-identical to the pre-spray wire format so + // old and new clients dedup the same envelope the same way. + let envelope = makeEnvelope() + #expect(envelope.copies == 1) + let encoded = try #require(envelope.encode()) + let withExplicitOne = try #require(envelope.withCopies(1).encode()) + #expect(encoded == withExplicitOne) + #expect(!encoded.contains(0x04) || CourierEnvelope.decode(encoded)?.copies == 1) + } + + @Test func decodeWithoutCopiesTLVDefaultsToCarryOnly() throws { + let encoded = try #require(makeEnvelope().encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.copies == 1) + } + + @Test func copiesAreClampedToPolicyBounds() { + #expect(makeEnvelope().withCopies(0).copies == 1) + #expect(makeEnvelope().withCopies(200).copies == CourierEnvelope.maxCopies) + } + + // MARK: - Codec + + @Test func roundTrip() throws { + let envelope = makeEnvelope() + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + } + + @Test func roundTripAtMaxCiphertextSize() throws { + let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes)) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + } + + @Test func encodeRejectsInvalidFields() { + #expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil) + #expect(makeEnvelope(ciphertext: Data()).encode() == nil) + #expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil) + } + + @Test func decodeRejectsMissingFields() throws { + // Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only. + let encoded = try #require(makeEnvelope().encode()) + let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8) + #expect(CourierEnvelope.decode(Data(truncated)) == nil) + } + + @Test func decodeRejectsTruncatedValue() throws { + let encoded = try #require(makeEnvelope().encode()) + #expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil) + } + + @Test func decodeSkipsUnknownTLVs() throws { + var encoded = try #require(makeEnvelope().encode()) + // Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it. + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == makeEnvelope()) + } + + @Test func decodeOffsetSlice() throws { + // Decoder must handle slices with non-zero startIndex. + let encoded = try #require(makeEnvelope().encode()) + let padded = Data([0xFF, 0xFF]) + encoded + let slice = padded.dropFirst(2) + #expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope()) + #expect(CourierEnvelope.decode(slice) == makeEnvelope()) + } + + // MARK: - Expiry + + @Test func expiryComparison() { + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + #expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired) + #expect(makeEnvelope(expiry: nowMs - 60_000).isExpired) + #expect(makeEnvelope(expiry: 0).isExpired) + } + + // MARK: - Recipient Tags + + @Test func tagIsDeterministicPerKeyAndDay() { + let key = Data(repeating: 0x11, count: 32) + let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) + let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) + #expect(tag1 == tag2) + #expect(tag1.count == CourierEnvelope.tagLength) + } + + @Test func tagRotatesAcrossDaysAndKeys() { + let key = Data(repeating: 0x11, count: 32) + let otherKey = Data(repeating: 0x22, count: 32) + let day: UInt32 = 20_000 + #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) + != CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)) + #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) + != CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day)) + } + + @Test func candidateTagsCoverAdjacentDays() { + let key = Data(repeating: 0x33, count: 32) + let date = Date(timeIntervalSince1970: 1_750_000_000) + let day = CourierEnvelope.epochDay(for: date) + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date) + #expect(candidates.count == 3) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1))) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day))) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))) + } + + @Test func sealedYesterdayMatchesToday() { + // An envelope sealed late on day D must still match the recipient on day D+1. + let key = Data(repeating: 0x44, count: 32) + let sealedAt = Date(timeIntervalSince1970: 1_750_000_000) + let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60) + let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt)) + #expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag)) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift new file mode 100644 index 00000000..8032bd9c --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift @@ -0,0 +1,70 @@ +// +// MeshPingPayloadTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct MeshPingPayloadTests { + + @Test func encodeDecodeRoundTrip() throws { + let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF]) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) + + let encoded = payload.encode() + #expect(encoded.count == 9) + #expect(encoded.prefix(8) == nonce) + #expect(encoded.last == 7) + + let decoded = try #require(MeshPingPayload.decode(encoded)) + #expect(decoded == payload) + } + + @Test func decodeToleratesTrailingBytes() throws { + let nonce = Data(repeating: 0x42, count: 8) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3)) + var extended = payload.encode() + extended.append(contentsOf: [0xDE, 0xAD]) + + let decoded = try #require(MeshPingPayload.decode(extended)) + #expect(decoded == payload) + } + + @Test func decodeRespectsSliceIndices() throws { + // Data slices keep their parent's indices; decoding must not assume + // startIndex == 0. + let nonce = Data(repeating: 0x11, count: 8) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5)) + let framed = Data([0x00, 0x00]) + payload.encode() + let slice = framed.dropFirst(2) + + let decoded = try #require(MeshPingPayload.decode(slice)) + #expect(decoded == payload) + } + + @Test func rejectsTruncatedPayload() { + #expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil) + #expect(MeshPingPayload.decode(Data()) == nil) + } + + @Test func rejectsWrongNonceLength() { + #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil) + #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil) + } + + @Test func hopCountMath() { + // Direct link: no TTL decrement, one hop. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1) + // One relay in between: two hops. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2) + // Full TTL consumed. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7) + // Inconsistent TTLs (received above origin) are rejected. + #expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift index 5d4071ec..3a86759a 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift @@ -9,7 +9,7 @@ import Foundation import BitFoundation -// TODO: Create a module for test helpers +// Kept local until the test-helper module is split out. final class MockKeychain: KeychainManagerProtocol { private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] @@ -85,6 +85,10 @@ final class MockKeychain: KeychainManagerProtocol { func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } + + func deleteAll(service: String) { + serviceStorage.removeValue(forKey: service) + } } /// Typealias for backwards compatibility with tests using MockKeychainHelper @@ -198,4 +202,8 @@ final class TrackingMockKeychain: KeychainManagerProtocol { func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } + + func deleteAll(service: String) { + serviceStorage.removeValue(forKey: service) + } } diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift new file mode 100644 index 00000000..1c83530f --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -0,0 +1,43 @@ +// +// PeerCapabilitiesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct PeerCapabilitiesTests { + @Test + func encodingIsMinimalAndRoundTrips() { + #expect(PeerCapabilities([]).encoded() == Data([0x00])) + #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) + #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) + + let high = PeerCapabilities(rawValue: 1 << 9) + #expect(high.encoded() == Data([0x00, 0x02])) + + let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics] + #expect(PeerCapabilities(encoded: all.encoded()) == all) + #expect(PeerCapabilities(encoded: high.encoded()) == high) + #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) + } + + @Test + func decodingToleratesUnknownBitsAndOversizedFields() { + // Unknown bits survive a round-trip untouched. + let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF])) + #expect(unknown.rawValue == 0xFFFF) + #expect(unknown.contains(.gateway)) + + // Fields longer than 8 bytes keep the low 64 bits and ignore the rest. + let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB]) + #expect(PeerCapabilities(encoded: oversized) == .prekeys) + + // Empty value decodes to no capabilities. + #expect(PeerCapabilities(encoded: Data()) == []) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift index f2f7b35b..2bb3a0e8 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift @@ -8,7 +8,7 @@ import Foundation -// TODO: Create a module for test helpers +// Kept local until the test-helper module is split out. struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 static let shortTimeout: TimeInterval = 1.0 diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift index 5c8ca7c0..7b2ff6a3 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift @@ -10,7 +10,7 @@ import Foundation import CryptoKit @testable import BitFoundation -// TODO: Create a module for test helpers +// Kept local until the test-helper module is split out. final class TestHelpers { // MARK: - Key Generation @@ -54,13 +54,13 @@ final class TestHelpers { type: UInt8 = 0x01, senderID: PeerID = PeerID(str: UUID().uuidString), recipientID: PeerID? = nil, - payload: Data = "test payload".data(using: .utf8)!, + payload: Data = Data("test payload".utf8), signature: Data? = nil, ttl: UInt8 = 3 ) -> BitchatPacket { return BitchatPacket( type: type, - senderID: senderID.id.data(using: .utf8)!, + senderID: Data(senderID.id.utf8), recipientID: recipientID?.id.data(using: .utf8), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, diff --git a/localPackages/BitLogger/Sources/OSLog+Categories.swift b/localPackages/BitLogger/Sources/OSLog+Categories.swift index 1d74e27e..4810b2fb 100644 --- a/localPackages/BitLogger/Sources/OSLog+Categories.swift +++ b/localPackages/BitLogger/Sources/OSLog+Categories.swift @@ -18,6 +18,5 @@ public extension OSLog { static let keychain = OSLog(subsystem: subsystem, category: "keychain") static let session = OSLog(subsystem: subsystem, category: "session") static let security = OSLog(subsystem: subsystem, category: "security") - static let handshake = OSLog(subsystem: subsystem, category: "handshake") static let sync = OSLog(subsystem: subsystem, category: "sync") } diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index 5aaffd53..1a82f437 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -202,10 +202,6 @@ public extension SecureLogger { } } - static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { - logSecurityEvent(event, level: .debug, file: file, line: line, function: function) - } - static func info(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { logSecurityEvent(event, level: .info, file: file, line: line, function: function) } @@ -279,15 +275,3 @@ private extension SecureLogger { return "[\(timestamp)] [\(fileName):\(line) \(function)]" } } - -// MARK: - Migration Helper - -/// Helper to migrate from print statements to SecureLogger -/// Usage: Replace print(...) with secureLog(...) -public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n", - file: String = #file, line: Int = #line, function: String = #function) { - #if DEBUG - let message = items.map { String(describing: $0) }.joined(separator: separator) - SecureLogger.debug(message, file: file, line: line, function: function) - #endif -} diff --git a/scripts/check-perf-floors.sh b/scripts/check-perf-floors.sh index 27d72a9e..b51eb390 100755 --- a/scripts/check-perf-floors.sh +++ b/scripts/check-perf-floors.sh @@ -11,16 +11,34 @@ # never runner variance. Raise floors deliberately after intentional # improvements; never tune them to chase noise. # +# Retry-on-noise: even generous floors can be dipped under by a saturated +# runner (observed: gcs.buildAndDecode at 85% of floor on a loaded GitHub +# macOS runner). When a benchmark lands below its floor, the gate re-runs the +# benchmark suite — appending to the same PERF log — and keeps each +# benchmark's BEST observed value across attempts. Runner noise clears on a +# retry; a real algorithmic regression stays below floor on every attempt and +# still fails. Floors themselves are never lowered by this mechanism. +# # Usage: scripts/check-perf-floors.sh [floors-file] # +# Environment: +# BITCHAT_PERF_GATE_ATTEMPTS total measurement attempts (default 3) +# BITCHAT_PERF_REMEASURE_CMD command run to re-measure on a below-floor +# result (default: swift test --quiet +# --filter PerformanceBaselineTests). The +# command runs with BITCHAT_PERF_LOG pointed at +# the output file so new PERF lines append. +# # Skips gracefully (exit 0) when: # - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or # - the output contains no PERF lines (e.g. package-only matrix entries). # -# Fails (exit 1) when: -# - any benchmark reports throughput below its floor, or -# - PERF lines are present but a floored benchmark is missing -# (a silently-dropped benchmark must be an explicit floors-file change). +# Fails when: +# - any benchmark reports throughput below its floor on every attempt +# (exit 1), or +# - PERF lines are present but a floored benchmark is missing — a +# silently-dropped benchmark must be an explicit floors-file change and +# is not retried (exit 3). set -euo pipefail @@ -31,6 +49,8 @@ fi OUTPUT_FILE="$1" FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}" +MAX_ATTEMPTS="${BITCHAT_PERF_GATE_ATTEMPTS:-3}" +REMEASURE_CMD="${BITCHAT_PERF_REMEASURE_CMD:-swift test --quiet --filter PerformanceBaselineTests}" if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate." @@ -52,7 +72,17 @@ if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then exit 0 fi -OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF' +# Absolute path so re-measurement appends to the same file regardless of the +# working directory the test process runs in. +case "$OUTPUT_FILE" in + /*) ;; + *) OUTPUT_FILE="$(pwd)/$OUTPUT_FILE" ;; +esac + +# Exit codes: 0 = all floors met, 1 = below floor (retryable — noise vs +# regression undecided), 3 = floored benchmark missing (not retryable). +check_floors() { + OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF' import json import os import re @@ -72,15 +102,20 @@ with open(output_file, errors="replace") as f: for line in f: m = pattern.search(line) if m: - # Keep the last reported value if a benchmark prints twice. - measured[m.group(1)] = (float(m.group(2)), m.group(3)) + # Keep the BEST reported value: measurement retries append to the + # same log, and a healthy benchmark only needs to clear its floor + # once — a real regression never does. + name, value, unit = m.group(1), float(m.group(2)), m.group(3) + if name not in measured or value > measured[name][0]: + measured[name] = (value, unit) -failures = [] +below_floor = [] +missing = [] print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)") for name in sorted(set(floors) | set(measured)): floor = floors.get(name) if name not in measured: - failures.append( + missing.append( f" MISSING {name}: floored benchmark reported no PERF line " f"(removed/renamed? update perf-floors.json in the same change)") continue @@ -92,13 +127,18 @@ for name in sorted(set(floors) | set(measured)): line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})" print(line) if value < floor: - failures.append( + below_floor.append( f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} " f"({value / floor * 100:.0f}% of floor)") -if failures: - print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:") - print("\n".join(failures)) +if missing: + print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:") + print("\n".join(missing + below_floor)) + sys.exit(3) + +if below_floor: + print("\nperf-floors: below floor — order-of-magnitude-class regression suspected:") + print("\n".join(below_floor)) print("\nFloors are ~25% of healthy local throughput; falling below one means an") print("algorithmic regression, not runner noise. If the change is intentional,") print("update bitchatTests/Performance/perf-floors.json deliberately.") @@ -106,3 +146,36 @@ if failures: print("perf-floors: all benchmarks at or above their floors.") PYEOF +} + +attempt=1 +while true; do + gate_status=0 + check_floors || gate_status=$? + + case "$gate_status" in + 0) + exit 0 + ;; + 1) + # Below floor: retry to separate runner noise from regression. + ;; + *) + # Missing benchmark or parse/setup error: re-measuring can't help. + exit "$gate_status" + ;; + esac + + if (( attempt >= MAX_ATTEMPTS )); then + echo "perf-floors: still below floor after $attempt measurement attempt(s) — treating as a real regression." >&2 + exit 1 + fi + + attempt=$((attempt + 1)) + echo "perf-floors: re-measuring (attempt $attempt of $MAX_ATTEMPTS) to separate runner noise from a real regression." + # Word splitting of REMEASURE_CMD is deliberate: it is a command line. + if ! BITCHAT_PERF_LOG="$OUTPUT_FILE" $REMEASURE_CMD; then + echo "perf-floors: re-measurement command failed: $REMEASURE_CMD" >&2 + exit 1 + fi +done diff --git a/scripts/generate-mac-appicon.swift b/scripts/generate-mac-appicon.swift new file mode 100644 index 00000000..fa98934a --- /dev/null +++ b/scripts/generate-mac-appicon.swift @@ -0,0 +1,81 @@ +#!/usr/bin/env swift +// Generates macOS app-icon PNGs from a square 1024x1024 source image. +// +// macOS does not mask icons at render time the way iOS does, so the rounded +// rectangle must be baked into the artwork. This applies Apple's Big Sur icon +// grid: an 824x824 rounded-rect body (corner radius 185.4 @1024) centered on a +// transparent 1024 canvas, with the standard subtle drop shadow. +// +// Usage: swift scripts/generate-mac-appicon.swift +// e.g. swift scripts/generate-mac-appicon.swift icon_1024x1024.png bitchat/Assets.xcassets/AppIcon.appiconset icon + +import AppKit +import UniformTypeIdentifiers + +guard CommandLine.arguments.count == 4 else { + FileHandle.standardError.write(Data("usage: generate-mac-appicon.swift \n".utf8)) + exit(1) +} + +let sourcePath = CommandLine.arguments[1] +let outDir = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) +let prefix = CommandLine.arguments[3] + +guard let dataProvider = CGDataProvider(url: URL(fileURLWithPath: sourcePath) as CFURL), + let source = CGImage(pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { + FileHandle.standardError.write(Data("error: could not read PNG at \(sourcePath)\n".utf8)) + exit(1) +} + +let sRGB = CGColorSpace(name: CGColorSpace.sRGB)! + +func render(pixels: Int) -> CGImage { + let ctx = CGContext( + data: nil, width: pixels, height: pixels, + bitsPerComponent: 8, bytesPerRow: 0, space: sRGB, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + let side = CGFloat(pixels) + let inset = side * 100.0 / 1024.0 + let body = CGRect(x: inset, y: inset, width: side - 2 * inset, height: side - 2 * inset) + let radius = side * 185.4 / 1024.0 + let path = CGPath(roundedRect: body, cornerWidth: radius, cornerHeight: radius, transform: nil) + + ctx.saveGState() + ctx.setShadow( + offset: CGSize(width: 0, height: -side * 10.0 / 1024.0), + blur: side * 20.0 / 1024.0, + color: CGColor(colorSpace: sRGB, components: [0, 0, 0, 0.3]) + ) + ctx.addPath(path) + ctx.setFillColor(CGColor(colorSpace: sRGB, components: [0, 0, 0, 1])!) + ctx.fillPath() + ctx.restoreGState() + + ctx.saveGState() + ctx.addPath(path) + ctx.clip() + ctx.interpolationQuality = .high + ctx.draw(source, in: body) + ctx.restoreGState() + + return ctx.makeImage()! +} + +func writePNG(_ image: CGImage, to url: URL) { + let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { + FileHandle.standardError.write(Data("error: failed to write \(url.path)\n".utf8)) + exit(1) + } +} + +// (point size, scale) for every mac slot in an appiconset +let slots: [(Int, Int)] = [(16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2), (256, 1), (256, 2), (512, 1), (512, 2)] +for (points, scale) in slots { + let suffix = scale == 1 ? "" : "@\(scale)x" + let url = outDir.appendingPathComponent("\(prefix)_\(points)x\(points)\(suffix).png") + writePNG(render(pixels: points * scale), to: url) + print("wrote \(url.lastPathComponent)") +}