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 c82ade66..81856b24 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -131,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 @@ -143,14 +143,29 @@ 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 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 index e35a75d0..833662e9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,6 +2,7 @@ # (CI checkouts are fresh, so this only matters in a working tree). excluded: - .build + - .claude - .swiftpm - .DerivedData - DerivedData diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig index e4035c9e..f018b882 100644 --- a/Configs/Release.xcconfig +++ b/Configs/Release.xcconfig @@ -1,4 +1,4 @@ -MARKETING_VERSION = 1.5.4 +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/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 137f6ab7..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 */ 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 9138aa93..9a20528d 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 @@ -71,6 +75,11 @@ 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. diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 3e084bce..1bbf26e3 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 @@ -42,7 +40,7 @@ final class AppRuntime: ObservableObject { #endif init( - keychain: KeychainManagerProtocol = KeychainManager(), + keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), idBridge: NostrIdentityBridge = NostrIdentityBridge() ) { self.idBridge = idBridge @@ -51,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, @@ -330,7 +326,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) @@ -417,21 +422,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 8c669bcc..fa338d22 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 @@ -927,16 +944,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 f8633e0d..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 @@ -150,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) } @@ -175,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 diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift index d7431d6d..25cb2b9f 100644 --- a/bitchat/App/LocationChannelsModel.swift +++ b/bitchat/App/LocationChannelsModel.swift @@ -1,4 +1,3 @@ -import BitFoundation import Combine import Foundation @@ -17,7 +16,6 @@ final class LocationChannelsModel: ObservableObject { private let manager: LocationChannelManager private let network: NetworkActivationService private let gateway: GatewayService - private var cancellables = Set() init( manager: LocationChannelManager? = nil, @@ -102,10 +100,6 @@ final class LocationChannelsModel: ObservableObject { network.setUserTorEnabled(enabled) } - func setGatewayEnabled(_ enabled: Bool) { - gateway.setEnabled(enabled) - } - func refreshMeshChannelsIfNeeded() { guard case .mesh = selectedChannel, permissionState == .authorized, 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/VerificationModel.swift b/bitchat/App/VerificationModel.swift index 5916a958..d9c2ce08 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -3,7 +3,6 @@ import Combine import Foundation struct FingerprintPresentationState: Equatable { - let statusPeerID: PeerID let peerNickname: String let encryptionStatus: EncryptionStatus let theirFingerprint: String? @@ -56,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 @@ -110,7 +105,6 @@ final class VerificationModel: ObservableObject { } return FingerprintPresentationState( - statusPeerID: statusPeerID, peerNickname: peerNickname, encryptionStatus: encryptionStatus, theirFingerprint: theirFingerprint, 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 41a01f6d..c1a39fff 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -104,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 921a071a..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. @@ -193,9 +189,6 @@ struct IdentityCache: Codable { // Fingerprint -> when we verified it (orders outgoing vouch batches; // entries verified before this field exists sort as oldest) var verifiedAt: [String: Date]? = nil - - // Schema version for future migrations - var version: Int = 1 } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 021ea776..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) @@ -139,7 +136,6 @@ protocol SecureIdentityStateManagerProtocol { func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool func validVouchers(for fingerprint: String) -> [VouchRecord] func isVouched(fingerprint: String) -> Bool - func effectiveTrustLevel(for fingerprint: String) -> TrustLevel func lastVouchBatchSent(to fingerprint: String) -> Date? func markVouchBatchSent(to fingerprint: String, at date: Date) func signingPublicKey(forFingerprint fingerprint: String) -> Data? @@ -322,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 { @@ -345,8 +333,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey, - firstSeen: now, - lastHandshake: now + firstSeen: now ) self.cryptographicIdentities[fingerprint] = entry } @@ -511,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) } } 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 01b0ea76..19270cd3 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,8 +1,190 @@ { "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", @@ -187,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" : { @@ -1080,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" : { @@ -1438,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" : { @@ -4308,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" : "• 輕點人物圖示打開列表" } } } @@ -4666,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" } } } @@ -5052,6 +5414,12 @@ "value" : "bloqueado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "na-block" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -5124,6 +5492,12 @@ "value" : "bloqueado" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -5171,6 +5545,197 @@ "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 橋接送達" + } } } }, @@ -5208,6 +5773,12 @@ "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", @@ -5280,6 +5851,12 @@ "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", @@ -5327,6 +5904,18 @@ "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" : "端到端加密會話" + } } } }, @@ -5364,6 +5953,12 @@ "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", @@ -5436,6 +6031,12 @@ "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", @@ -5483,6 +6084,18 @@ "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" : "加密失敗 — 訊息未受保護" + } } } }, @@ -5520,6 +6133,12 @@ "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", @@ -5592,6 +6211,12 @@ "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", @@ -5639,6 +6264,18 @@ "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 收發離線訊息" + } } } }, @@ -5676,6 +6313,12 @@ "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", @@ -5748,6 +6391,12 @@ "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", @@ -5795,6 +6444,18 @@ "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" : "實際位於此位置頻道的區域內" + } } } }, @@ -5832,6 +6493,12 @@ "value" : "conectado directamente por Bluetooth" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "direktang nakakonekta sa bluetooth" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -5904,6 +6571,12 @@ "value" : "ligado diretamente por bluetooth" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conectado diretamente por bluetooth" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -5951,6 +6624,18 @@ "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 直接連線" + } } } }, @@ -5988,6 +6673,12 @@ "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", @@ -6060,6 +6751,12 @@ "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", @@ -6107,6 +6804,18 @@ "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 到達,由其他人中繼" + } } } }, @@ -6144,6 +6853,12 @@ "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", @@ -6216,6 +6931,12 @@ "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", @@ -6263,6 +6984,18 @@ "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) 到達 — 僅限互相收藏" + } } } }, @@ -6300,6 +7033,12 @@ "value" : "sin conexión — no alcanzable ahora" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — kasalukuyang hindi maabot" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -6372,6 +7111,12 @@ "value" : "offline — atualmente inacessível" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — inalcançável no momento" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -6419,6 +7164,18 @@ "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" : "離線 — 目前無法到達" + } } } }, @@ -6456,6 +7213,12 @@ "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", @@ -6528,6 +7291,12 @@ "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", @@ -6575,6 +7344,18 @@ "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" : "瞬移 — 從其他地方加入此頻道" + } } } }, @@ -6612,6 +7393,12 @@ "value" : "SÍMBOLOS" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "MGA SIMBOLO" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -6684,6 +7471,12 @@ "value" : "SÍMBOLOS" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SÍMBOLOS" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -6731,6 +7524,18 @@ "state" : "needs_review", "value" : "KÝ HIỆU" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "符号" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "符號" + } } } }, @@ -6768,6 +7573,12 @@ "value" : "mensajes privados sin leer" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi pa nababasang mga pribadong mensahe" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -6840,6 +7651,12 @@ "value" : "mensagens privadas não lidas" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagens privadas não lidas" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -6887,6 +7704,18 @@ "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" : "未讀私信" + } } } }, @@ -6924,6 +7753,12 @@ "value" : "identidad verificada" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beripikado ang pagkakakilanlan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -6996,6 +7831,12 @@ "value" : "identidade verificada" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identidade verificada" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -7043,6 +7884,378 @@ "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" : "位置留言" + } } } }, @@ -7080,6 +8293,12 @@ "value" : "RED" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NETWORK" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -7152,6 +8371,12 @@ "value" : "REDE" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "REDE" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -7199,6 +8424,18 @@ "state" : "needs_review", "value" : "MẠNG" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "网络" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "網路" + } } } }, @@ -7236,6 +8473,12 @@ "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", @@ -7308,6 +8551,12 @@ "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", @@ -7355,6 +8604,18 @@ "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 宣告得知的同伴與連線地圖" + } } } }, @@ -7392,6 +8653,12 @@ "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", @@ -7464,6 +8731,12 @@ "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", @@ -7511,6 +8784,18 @@ "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 拓撲地圖" + } } } }, @@ -7548,6 +8833,12 @@ "value" : "topología del mesh" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topology" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -7620,6 +8911,12 @@ "value" : "topologia mesh" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia do mesh" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -7667,6 +8964,18 @@ "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 拓撲" + } } } }, @@ -8923,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" : { @@ -9102,360 +12738,899 @@ } } }, - "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" : "橋的另一端" } } } @@ -11285,6 +15460,12 @@ "value" : "muestra la información de la app" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapakita ang impormasyon ng app" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -11357,6 +15538,12 @@ "value" : "mostra as informações da app" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra informações do app" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -11404,6 +15591,18 @@ "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" : "顯示應用程式資訊" + } } } }, @@ -11441,6 +15640,12 @@ "value" : "adjuntar foto" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "maglakip ng larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -11513,6 +15718,12 @@ "value" : "anexar foto" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anexar foto" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -11560,6 +15771,18 @@ "state" : "needs_review", "value" : "đính kèm ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "附加照片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "附加照片" + } } } }, @@ -11597,6 +15820,12 @@ "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", @@ -11669,6 +15898,12 @@ "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", @@ -11716,6 +15951,18 @@ "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" : "打開照片圖庫;要用相機請使用拍照操作" + } } } }, @@ -12077,6 +16324,364 @@ } } }, + "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", @@ -12111,6 +16716,12 @@ "value" : "elegir foto" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pumili ng larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -12183,6 +16794,12 @@ "value" : "escolher foto" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "escolher foto" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -12230,6 +16847,18 @@ "state" : "needs_review", "value" : "chọn ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "选择照片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "選擇照片" + } } } }, @@ -12446,6 +17075,12 @@ "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", @@ -12518,6 +17153,12 @@ "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", @@ -12565,6 +17206,18 @@ "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" : "輕點顯示送達詳情" + } } } }, @@ -12781,6 +17434,12 @@ "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", @@ -12853,6 +17512,12 @@ "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", @@ -12900,6 +17565,197 @@ "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" : "開啟設定以開啟或關閉閘道" + } } } }, @@ -12936,6 +17792,12 @@ "value" : "Chat de grupo" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Group chat" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -13008,6 +17870,12 @@ "value" : "Conversa de grupo" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chat de grupo" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -13055,6 +17923,18 @@ "state" : "needs_review", "value" : "trò chuyện nhóm" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群聊" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群組聊天" + } } } }, @@ -13092,6 +17972,12 @@ "value" : "ir a los mensajes más recientes" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tumalon sa pinakabagong mga mensahe" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -13164,6 +18050,12 @@ "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", @@ -13211,6 +18103,18 @@ "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" : "跳至最新訊息" + } } } }, @@ -13393,181 +18297,360 @@ } } }, - "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" : "僅限附近:訊息只在無線電範圍內傳播" } } } @@ -14145,6 +19228,12 @@ "value" : "conectado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nakakonekta" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -14217,6 +19306,12 @@ "value" : "ligado" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conectado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -14264,6 +19359,18 @@ "state" : "needs_review", "value" : "đã kết nối" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已連線" + } } } }, @@ -14301,6 +19408,12 @@ "value" : "nadie alcanzable" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "walang taong maabot" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -14373,6 +19486,12 @@ "value" : "ninguém acessível" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ninguém alcançável" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -14420,6 +19539,18 @@ "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" : "沒有可到達的人" + } } } }, @@ -15378,6 +20509,12 @@ "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", @@ -15450,6 +20587,12 @@ "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", @@ -15497,6 +20640,18 @@ "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" : "雙擊開始錄音,再次雙擊發送" + } } } }, @@ -15534,6 +20689,12 @@ "value" : "grabar nota de voz" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mag-record ng voice note" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -15606,6 +20767,12 @@ "value" : "gravar nota de voz" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gravar mensagem de voz" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -15653,6 +20820,18 @@ "state" : "needs_review", "value" : "ghi chú giọng nói" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "录制语音消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "錄製語音訊息" + } } } }, @@ -15690,6 +20869,12 @@ "value" : "grabando" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nagre-record" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -15762,6 +20947,12 @@ "value" : "a gravar" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gravando" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -15809,6 +21000,18 @@ "state" : "needs_review", "value" : "đang ghi" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "录音中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "錄音中" + } } } }, @@ -16528,6 +21731,185 @@ } } }, + "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", @@ -16562,6 +21944,12 @@ "value" : "tomar foto con la cámara" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumuha ng larawan gamit ang camera" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -16634,6 +22022,12 @@ "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", @@ -16681,6 +22075,18 @@ "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" : "用相機拍照" + } } } }, @@ -16863,185 +22269,6 @@ } } }, - "content.accessibility.toggle_favorite_hint" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اضغط مرتين لتبديل حالة المفضلة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "প্রিয় অবস্থা বদলাতে দুইবার ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "doppelt tippen, um favoritenstatus zu wechseln" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "double tap to toggle favorite status" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "toca dos veces para alternar el estado de favorito" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "i-double tap para i-toggle ang paborito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tape deux fois pour basculer le statut favori" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הקש פעמיים כדי להחליף מצב מועדפים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "पसंदीदा स्थिति बदलने के लिए डबल टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "ketuk dua kali untuk mengubah status favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "tocca due volte per cambiare stato preferito" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ダブルタップでお気に入り状態を切り替え" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "두 번 탭하여 즐겨찾기 상태 토글" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "ketuk dua kali untuk mengubah status favorit" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "मनपर्ने स्थिति बदल्न दोहोरो ट्याप गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dubbelklikken om favoriet te schakelen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "stuknij dwukrotnie, aby zmienić stan ulubionych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "toca duas vezes para alternar o estado de favorito" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "toque duas vezes para alternar status de favorito" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "дважды тапни, чтобы переключить статус избранного" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "dubbeltryck för att växla favoritstatus" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பிரியப்பட்ட நிலையை மாற்ற இருமுறை தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "แตะสองครั้งเพื่อสลับสถานะรายการโปรด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "favori durumunu değiştirmek için çift dokunun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "торкни двічі, щоб змінити статус вибраного" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پسندیدہ حالت بدلنے کیلئے دو بار ٹیپ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chạm hai lần để chuyển trạng thái yêu thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "双击切换收藏状态" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "雙擊切換收藏狀態" - } - } - } - }, "content.accessibility.verification" : { "comment" : "Accessibility label for the verification QR button", "extractionState" : "manual", @@ -17076,6 +22303,12 @@ "value" : "verificar cifrado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beripikahin ang pag-encrypt" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -17148,6 +22381,12 @@ "value" : "verificar a encriptação" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verificar criptografia" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -17195,6 +22434,18 @@ "state" : "needs_review", "value" : "xác minh mã hóa" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "验证加密" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "驗證加密" + } } } }, @@ -18127,6 +23378,12 @@ "value" : "reenviar" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipadala muli" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -18199,6 +23456,12 @@ "value" : "reenviar" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reenviar" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -18246,6 +23509,18 @@ "state" : "needs_review", "value" : "gửi lại" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "重新发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "重新發送" + } } } }, @@ -19894,6 +25169,12 @@ "value" : "limpiar chat" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang chat" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -19966,6 +25247,12 @@ "value" : "limpar conversa" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar chat" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -20013,6 +25300,18 @@ "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" : "清除聊天" + } } } }, @@ -20050,6 +25349,12 @@ "value" : "¿limpiar este chat?" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang chat na ito?" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -20122,6 +25427,12 @@ "value" : "limpar esta conversa?" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar este chat?" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -20169,6 +25480,18 @@ "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" : "清除此聊天?" + } } } }, @@ -20530,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" : { @@ -20742,6 +26245,12 @@ "value" : "crear o gestionar grupos privados" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lumikha o mamahala ng mga pribadong grupo" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -20814,6 +26323,12 @@ "value" : "criar ou gerir grupos privados" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "criar ou gerenciar grupos privados" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -20861,6 +26376,18 @@ "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" : "建立或管理私密群組" + } } } }, @@ -20898,6 +26425,12 @@ "value" : "mostrar los comandos disponibles" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipakita ang mga available na utos" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -20970,6 +26503,12 @@ "value" : "mostrar os comandos disponíveis" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar comandos disponíveis" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -21017,6 +26556,18 @@ "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" : "顯示可用指令" + } } } }, @@ -21412,6 +26963,12 @@ "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", @@ -21484,6 +27041,12 @@ "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", @@ -21531,6 +27094,18 @@ "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 代幣" + } } } }, @@ -21568,6 +27143,12 @@ "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", @@ -21640,6 +27221,12 @@ "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", @@ -21687,6 +27274,18 @@ "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 同伴的往返時間" + } } } }, @@ -21903,6 +27502,12 @@ "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", @@ -21975,6 +27580,12 @@ "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", @@ -22022,6 +27633,18 @@ "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 路徑" + } } } }, @@ -22055,7 +27678,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "desbloquear a un usuario" + "value" : "desbloquear a una persona" } }, "fil" : { @@ -22562,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" : { @@ -23284,175 +29265,175 @@ "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" : "此人已被屏蔽" } } } @@ -23491,6 +29472,12 @@ "value" : "cifrado fallido" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nabigo ang pag-encrypt" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -23563,6 +29550,12 @@ "value" : "falha na encriptação" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha na criptografia" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -23610,6 +29603,18 @@ "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" : "加密失敗" + } } } }, @@ -23647,6 +29652,12 @@ "value" : "no entregado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi naihatid" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -23719,6 +29730,12 @@ "value" : "não entregue" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não entregue" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -23766,6 +29783,18 @@ "state" : "needs_review", "value" : "chưa gửi được" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未送達" + } } } }, @@ -24306,185 +30335,6 @@ } } }, - "content.delivery.reason.unreachable" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "القرين غير متاح" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "পিয়ার পৌঁছানো যাচ্ছে না" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer nicht erreichbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer not reachable" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "el destinatario no es alcanzable" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi maabot ang peer" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pair injoignable" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "עמית לא זמין" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "पीयर पहुंच योग्य नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer tidak dapat dijangkau" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer irraggiungibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ピアに到達できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "피어에 연결할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer tidak dapat dijangkau" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पीयर पहुँचयोग्य छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer onbereikbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer nieosiągalny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "par inacessível" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "par inalcançável" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "пир недостижим" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer ej nåbar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer அணுக முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ติดต่อเพียร์ไม่ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "eşe ulaşılamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "пір недосяжний" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer تک رسائی نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không liên lạc được với nút" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "同伴不可达" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "同伴不可達" - } - } - } - }, "content.delivery.reason.voice_send_failed" : { "comment" : "Failure reason shown when a voice note could not be sent", "extractionState" : "manual", @@ -24519,6 +30369,12 @@ "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", @@ -24591,6 +30447,12 @@ "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", @@ -24638,6 +30500,18 @@ "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" : "語音訊息發送失敗" + } } } }, @@ -24675,6 +30549,12 @@ "value" : "nota de voz demasiado grande" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "masyadong malaki ang voice note" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -24747,6 +30627,12 @@ "value" : "nota de voz demasiado grande" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem de voz grande demais" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -24794,6 +30680,18 @@ "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" : "語音訊息過大" + } } } }, @@ -24831,6 +30729,12 @@ "value" : "enviando..." } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapadala..." + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -24903,6 +30807,12 @@ "value" : "a enviar..." } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviando..." + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -24950,6 +30860,18 @@ "state" : "needs_review", "value" : "đang gửi..." } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "发送中..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "發送中..." + } } } }, @@ -24987,6 +30909,12 @@ "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", @@ -25059,6 +30987,12 @@ "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", @@ -25106,6 +31040,738 @@ "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" : "narinig dito kanina · huling 6 oras" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "entendu ici plus tôt · 6 dernières h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נשמע כאן קודם · 6 שעות אחרונות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पहले यहाँ सुना गया · पिछले 6 घंटे" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "terdengar di sini sebelumnya · 6 jam terakhir" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "sentito qui prima · ultime 6 ore" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここで聞こえた · 過去6時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기서 들린 대화 · 지난 6시간" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "didengar di sini tadi · 6 jam lepas" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पहिले यहाँ सुनिएको · पछिल्लो ६ घण्टा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "eerder hier gehoord · afgelopen 6 u" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "słyszane tu wcześniej · ostatnie 6 godz." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvido aqui antes · últimas 6 h" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvido aqui antes · últimas 6 h" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "слышано здесь ранее · последние 6 ч" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "hört här tidigare · senaste 6 tim" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "முன்பு இங்கே கேட்டது · கடந்த 6 மணி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ได้ยินที่นี่ก่อนหน้านี้ · 6 ชม.ที่ผ่านมา" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "daha önce burada duyuldu · son 6 sa" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "почуто тут раніше · останні 6 год" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پہلے یہاں سنا گیا · گزشتہ 6 گھنٹے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã nghe ở đây trước đó · 6 giờ qua" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "之前在这里听到 · 最近6小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "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" : "查看這裡留下的留言" + } } } }, @@ -25143,6 +31809,12 @@ "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", @@ -25215,6 +31887,12 @@ "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", @@ -25262,6 +31940,18 @@ "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" : "你在 #%@ — 一個透過網際網路的公開位置頻道" + } } } }, @@ -25299,6 +31989,12 @@ "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", @@ -25371,6 +32067,12 @@ "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", @@ -25418,6 +32120,18 @@ "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 範圍內的人" + } } } }, @@ -25455,6 +32169,12 @@ "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", @@ -25527,6 +32247,12 @@ "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", @@ -25574,6 +32300,738 @@ "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 台裝置經過範圍內" + } } } }, @@ -25611,6 +33069,12 @@ "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", @@ -25683,6 +33147,12 @@ "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", @@ -25730,6 +33200,18 @@ "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/ 取得協助" + } } } }, @@ -25767,6 +33249,12 @@ "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", @@ -25839,6 +33327,12 @@ "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", @@ -25886,6 +33380,18 @@ "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 同伴分享你的網際網路連線" + } } } }, @@ -26484,6 +33990,12 @@ "value" : "create|invite|leave|list" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -26556,6 +34068,12 @@ "value" : "create|invite|leave|list" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -26603,6 +34121,18 @@ "state" : "needs_review", "value" : "create|invite|leave|list" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } } } }, @@ -26785,6 +34315,186 @@ } } }, + "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", @@ -26819,6 +34529,12 @@ "value" : "mensaje #%@ — público" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensahe sa #%@ — pampubliko" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -26891,6 +34607,12 @@ "value" : "mensagem para #%@ — público" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #%@ — público" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -26938,6 +34660,18 @@ "state" : "needs_review", "value" : "nhắn #%@ — công khai" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "发消息到 #%@ — 公开" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "發訊息到 #%@ — 公開" + } } } }, @@ -26975,6 +34709,12 @@ "value" : "mensaje #mesh — público, cerca" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensahe sa #mesh — pampubliko, malapit" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -27047,6 +34787,12 @@ "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", @@ -27094,6 +34840,18 @@ "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 — 公開、附近" + } } } }, @@ -27131,6 +34889,12 @@ "value" : "mensaje %@ — privado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensahe kay %@ — pribado" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -27203,6 +34967,12 @@ "value" : "mensagem para %@ — privado" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para %@ — privado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -27250,6 +35020,18 @@ "state" : "needs_review", "value" : "nhắn %@ — riêng tư" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "发消息给 %@ — 私密" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "發訊息給 %@ — 私密" + } } } }, @@ -27287,6 +35069,12 @@ "value" : "token" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -27359,6 +35147,12 @@ "value" : "token" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -27406,6 +35200,18 @@ "state" : "needs_review", "value" : "token" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "代币" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } } } }, @@ -27443,6 +35249,12 @@ "value" : "%lld nuevos" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld bago" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -27515,6 +35327,12 @@ "value" : "%lld novas" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld novas" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -27562,6 +35380,18 @@ "state" : "needs_review", "value" : "%lld mới" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld 条新消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld 則新訊息" + } } } }, @@ -28460,185 +36290,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" : { @@ -28852,6 +36503,12 @@ "value" : "copiar token" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kopyahin ang token" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -28924,6 +36581,12 @@ "value" : "copiar token" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copiar token" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -28971,6 +36634,18 @@ "state" : "needs_review", "value" : "sao chép token" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "复制代币" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "複製代幣" + } } } }, @@ -29187,6 +36862,12 @@ "value" : "canjear en la cartera" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-redeem sa wallet" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -29259,6 +36940,12 @@ "value" : "resgatar na wallet" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na carteira" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -29306,6 +36993,18 @@ "state" : "needs_review", "value" : "đổi trong ví" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在钱包中兑换" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在錢包中兌換" + } } } }, @@ -29343,6 +37042,12 @@ "value" : "canjear en la web" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-redeem sa web" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -29415,6 +37120,12 @@ "value" : "resgatar na web" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na web" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -29462,6 +37173,18 @@ "state" : "needs_review", "value" : "đổi trên web" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在网页上兑换" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在網頁上兌換" + } } } }, @@ -29499,6 +37222,12 @@ "value" : "conversación privada" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pribadong pag-uusap" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -29571,6 +37300,12 @@ "value" : "conversa privada" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversa privada" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -29618,6 +37353,18 @@ "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" : "私密對話" + } } } }, @@ -29655,6 +37402,12 @@ "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", @@ -29727,6 +37480,12 @@ "value" : "privado · encriptado ponta a ponta" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privado · criptografado ponto a ponto" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -29774,6 +37533,18 @@ "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" : "私密 · 端到端加密" + } } } }, @@ -29810,6 +37581,12 @@ "value" : "grupo cifrado · solo miembros" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "naka-encrypt na grupo · mga miyembro lamang" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -29882,6 +37659,12 @@ "value" : "grupo encriptado · apenas membros" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupo criptografado · apenas membros" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -29929,6 +37712,18 @@ "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" : "加密群組 · 僅限成員" + } } } }, @@ -32472,6 +40267,12 @@ "value" : "✓ AVALADO" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ GINARANTIYAHAN" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -32544,6 +40345,12 @@ "value" : "✓ ATESTADO" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ENDOSSADO" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -32591,6 +40398,18 @@ "state" : "needs_review", "value" : "✓ ĐÃ BẢO CHỨNG" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 已担保" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 已擔保" + } } } }, @@ -33135,6 +40954,58 @@ "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" : { @@ -33153,6 +41024,34 @@ } } }, + "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", @@ -33209,6 +41108,102 @@ } } }, + "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" : { @@ -33255,6 +41250,34 @@ } } }, + "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", @@ -33345,6 +41368,130 @@ } } }, + "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", @@ -33385,6 +41532,34 @@ } } }, + "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" : { @@ -33425,6 +41600,102 @@ } } }, + "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", @@ -33446,6 +41717,50 @@ } } } + }, + "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 人" + } + } + } + } + } + } } } }, @@ -34557,6 +42872,12 @@ "value" : "en esta zona" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nasa lugar na ito" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -34629,6 +42950,12 @@ "value" : "nesta área" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nesta área" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -34676,6 +43003,18 @@ "state" : "needs_review", "value" : "trong khu vực này" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在此区域内" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在此區域內" + } } } }, @@ -34713,6 +43052,12 @@ "value" : "teletransportado desde otro lugar" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nag-teleport mula sa ibang lugar" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -34785,6 +43130,12 @@ "value" : "teletransportado de outro lugar" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletransportado de outro lugar" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -34832,6 +43183,18 @@ "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" : "從其他地方瞬移而來" + } } } }, @@ -34869,6 +43232,12 @@ "value" : "tú" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ikaw" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -34941,6 +43310,12 @@ "value" : "tu" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -34988,6 +43363,18 @@ "state" : "needs_review", "value" : "bạn" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你" + } } } }, @@ -35382,6 +43769,12 @@ "value" : "Abre el chat de grupo" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Binubuksan ang group chat" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -35454,6 +43847,12 @@ "value" : "Abre a conversa de grupo" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Abre o chat do grupo" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -35501,6 +43900,18 @@ "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" : "打開群組聊天" + } } } }, @@ -35537,6 +43948,12 @@ "value" : "(%@)" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -35609,6 +44026,12 @@ "value" : "(%@)" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -35656,6 +44079,18 @@ "state" : "needs_review", "value" : "(%@)" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } } } }, @@ -35692,6 +44127,12 @@ "value" : "grupos" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mga grupo" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -35764,6 +44205,12 @@ "value" : "grupos" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupos" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -35811,6 +44258,18 @@ "state" : "needs_review", "value" : "nhóm" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群组" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "群組" + } } } }, @@ -35847,6 +44306,12 @@ "value" : "Creador" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tagalikha" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -35919,6 +44384,12 @@ "value" : "Criador" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Criador" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -35966,6 +44437,18 @@ "state" : "needs_review", "value" : "người tạo" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "创建者" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "建立者" + } } } }, @@ -36148,6 +44631,185 @@ } } }, + "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", @@ -36182,6 +44844,12 @@ "value" : "marcar canal" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-bookmark ang channel" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -36254,6 +44922,12 @@ "value" : "marcar canal" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "marcar canal" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -36301,6 +44975,18 @@ "state" : "needs_review", "value" : "đánh dấu kênh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "为频道添加书签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "將頻道加入書簽" + } } } }, @@ -36338,6 +45024,12 @@ "value" : "quitar marcador" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "alisin ang bookmark" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -36410,6 +45102,12 @@ "value" : "remover marcador" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "remover marcador" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -36457,6 +45155,18 @@ "state" : "needs_review", "value" : "bỏ đánh dấu" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "移除书签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "移除書簽" + } } } }, @@ -36494,6 +45204,12 @@ "value" : "cambia a este canal" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lumilipat sa channel na ito" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -36566,6 +45282,12 @@ "value" : "muda para este canal" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "troca para este canal" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -36613,6 +45335,18 @@ "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" : "切換到此頻道" + } } } }, @@ -37869,314 +46603,182 @@ } } }, - "location_channels.gateway.subtitle" : { - "comment" : "Explanation under the internet gateway toggle", + "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" : "needs_review", - "value" : "شارك إنترنتك مع أقران mesh القريبين لتصل رسائل geohash الخاصة بهم إلى الشبكة" + "state" : "translated", + "value" : "امنح إذن الموقع للعثور على القنوات القريبة" } }, "bn" : { "stringUnit" : { - "state" : "needs_review", - "value" : "কাছাকাছি মেশ পিয়ারদের সঙ্গে আপনার ইন্টারনেট ভাগ করুন যাতে তাদের জিওহ্যাশ বার্তা নেটওয়ার্কে পৌঁছায়" + "state" : "translated", + "value" : "কাছাকাছি চ্যানেল খুঁজতে অবস্থানের অনুমতি দিন" } }, "de" : { "stringUnit" : { - "state" : "needs_review", - "value" : "teile dein internet mit nahen mesh-peers, damit ihre geohash-nachrichten das netzwerk erreichen" + "state" : "translated", + "value" : "standortzugriff erlauben, um kanäle in der nähe zu finden" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" + "value" : "grant location access to find nearby channels" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "comparte tu internet con peers cercanos del mesh para que sus mensajes geohash lleguen a la red" + "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" : "needs_review", - "value" : "partage ton internet avec les pairs mesh à proximité pour que leurs messages geohash atteignent le réseau" + "state" : "translated", + "value" : "autorisez l'accès à la position pour trouver les canaux à proximité" } }, "he" : { "stringUnit" : { - "state" : "needs_review", - "value" : "שתף את האינטרנט שלך עם עמיתי mesh קרובים כדי שהודעות ה-geohash שלהם יגיעו לרשת" + "state" : "translated", + "value" : "אפשרו גישה למיקום כדי למצוא ערוצים בקרבת מקום" } }, "hi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "अपना इंटरनेट आसपास के मेश पीयरों के साथ साझा करें ताकि उनके जियोहैश संदेश नेटवर्क तक पहुँचें" + "state" : "translated", + "value" : "आस-पास के चैनल खोजने के लिए स्थान की अनुमति दें" } }, "id" : { "stringUnit" : { - "state" : "needs_review", - "value" : "bagikan internetmu ke peer mesh terdekat agar pesan geohash mereka sampai ke jaringan" + "state" : "translated", + "value" : "izinkan akses lokasi untuk menemukan channel terdekat" } }, "it" : { "stringUnit" : { - "state" : "needs_review", - "value" : "condividi il tuo internet con i peer mesh vicini così i loro messaggi geohash raggiungono la rete" + "state" : "translated", + "value" : "consenti l'accesso alla posizione per trovare i canali vicini" } }, "ja" : { "stringUnit" : { - "state" : "needs_review", - "value" : "近くのmeshピアとインターネットを共有して、そのgeohashメッセージをネットワークに届けます" + "state" : "translated", + "value" : "近くのチャンネルを見つけるには位置情報へのアクセスを許可してください" } }, "ko" : { "stringUnit" : { - "state" : "needs_review", - "value" : "근처 mesh 피어와 인터넷을 공유하여 그들의 geohash 메시지가 네트워크에 도달하도록 합니다" + "state" : "translated", + "value" : "주변 채널을 찾으려면 위치 접근을 허용하세요" } }, "ms" : { "stringUnit" : { - "state" : "needs_review", - "value" : "kongsi internetmu dengan peer mesh berdekatan supaya pesan geohash mereka sampai ke rangkaian" + "state" : "translated", + "value" : "benarkan akses lokasi untuk mencari saluran berdekatan" } }, "ne" : { "stringUnit" : { - "state" : "needs_review", - "value" : "नजिकका mesh सहकर्मीसँग तिम्रो इन्टरनेट साझेदारी गर ताकि उनीहरूका geohash सन्देश नेटवर्कसम्म पुगून्" + "state" : "translated", + "value" : "नजिकैका च्यानलहरू फेला पार्न स्थान पहुँच दिनुहोस्" } }, "nl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "deel je internet met mesh-peers in de buurt zodat hun geohash-berichten het netwerk bereiken" + "state" : "translated", + "value" : "geef locatietoegang om kanalen in de buurt te vinden" } }, "pl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "udostępnij internet pobliskim peerom mesh, aby ich wiadomości geohash docierały do sieci" + "state" : "translated", + "value" : "przyznaj dostęp do lokalizacji, aby znaleźć pobliskie kanały" } }, "pt" : { "stringUnit" : { - "state" : "needs_review", - "value" : "partilha a tua internet com pares mesh próximos para que as mensagens geohash deles cheguem à rede" + "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" : "needs_review", - "value" : "раздай интернет ближайшим mesh-пирам, чтобы их geohash-сообщения дошли до сети" + "state" : "translated", + "value" : "разрешите доступ к геопозиции, чтобы найти каналы поблизости" } }, "sv" : { "stringUnit" : { - "state" : "needs_review", - "value" : "dela ditt internet med mesh-peers i närheten så att deras geohash-meddelanden når nätverket" + "state" : "translated", + "value" : "ge platsåtkomst för att hitta kanaler i närheten" } }, "ta" : { "stringUnit" : { - "state" : "needs_review", - "value" : "உங்கள் இணையத்தை அருகிலுள்ள mesh peer-களுடன் பகிர்ந்து அவர்களின் geohash செய்திகள் நெட்வொர்க்கை அடையச் செய்யவும்" + "state" : "translated", + "value" : "அருகிலுள்ள சேனல்களைக் கண்டறிய இருப்பிட அணுகலை வழங்கவும்" } }, "th" : { "stringUnit" : { - "state" : "needs_review", - "value" : "แชร์อินเทอร์เน็ตของคุณกับเพียร์ mesh ที่อยู่ใกล้เพื่อให้ข้อความ geohash ของพวกเขาไปถึงเครือข่าย" + "state" : "translated", + "value" : "อนุญาตการเข้าถึงตำแหน่งเพื่อค้นหาช่องใกล้เคียง" } }, "tr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "geohash mesajlarının ağa ulaşması için internetini yakındaki mesh eşleriyle paylaş" + "state" : "translated", + "value" : "yakındaki kanalları bulmak için konum erişimine izin verin" } }, "uk" : { "stringUnit" : { - "state" : "needs_review", - "value" : "поділися інтернетом з ближніми пірами mesh, щоб їхні повідомлення geohash досягали мережі" + "state" : "translated", + "value" : "надайте доступ до геолокації, щоб знайти канали поблизу" } }, "ur" : { "stringUnit" : { - "state" : "needs_review", - "value" : "قریبی mesh ہم منصبوں کے ساتھ اپنا انٹرنیٹ شیئر کریں تاکہ ان کے geohash پیغامات نیٹ ورک تک پہنچیں" + "state" : "translated", + "value" : "قریبی چینلز تلاش کرنے کے لیے مقام تک رسائی دیں" } }, "vi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "chia sẻ internet của bạn với các nút mesh gần đó để tin nhắn geohash của họ đến được mạng" - } - } - } - }, - "location_channels.gateway.title" : { - "comment" : "Title for the internet gateway toggle in the location channels sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بوابة الإنترنت" + "state" : "translated", + "value" : "cấp quyền truy cập vị trí để tìm các kênh gần đây" } }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ইন্টারনেট গেটওয়ে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internet-gateway" - } - }, - "en" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "internet gateway" + "value" : "授予位置权限以查找附近的频道" } }, - "es" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "puerta de enlace a internet" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "passerelle internet" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שער אינטרנט" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इंटरनेट गेटवे" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "インターネットゲートウェイ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "인터넷 게이트웨이" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इन्टरनेट गेटवे" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internetgateway" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "brama internetowa" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway de internet" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "интернет-шлюз" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internetgateway" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இணைய நுழைவாயில்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เกตเวย์อินเทอร์เน็ต" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internet ağ geçidi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "інтернет-шлюз" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "انٹرنیٹ گیٹ وے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cổng internet" + "value" : "授予位置權限以尋找附近的頻道" } } } @@ -41608,6 +50210,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" : { @@ -41787,364 +50569,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" : { @@ -42503,569 +50927,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" : { @@ -43245,185 +51106,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" : { @@ -43603,364 +51285,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" : { @@ -44174,6 +51498,12 @@ "value" : "cancelar envío" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanselahin ang pagpapadala" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -44246,6 +51576,12 @@ "value" : "cancelar envio" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cancelar envio" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -44293,6 +51629,18 @@ "state" : "needs_review", "value" : "hủy gửi" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "取消发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "取消發送" + } } } }, @@ -44330,6 +51678,12 @@ "value" : "imagen oculta" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nakatagong larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -44402,6 +51756,12 @@ "value" : "imagem oculta" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem oculta" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -44449,6 +51809,18 @@ "state" : "needs_review", "value" : "hình ảnh ẩn" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已隐藏的图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "隱藏的圖片" + } } } }, @@ -44486,6 +51858,12 @@ "value" : "abre la imagen a pantalla completa" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "binubuksan ang larawan nang full screen" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -44558,6 +51936,12 @@ "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", @@ -44605,6 +51989,18 @@ "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" : "全螢幕打開圖片" + } } } }, @@ -44642,6 +52038,12 @@ "value" : "revela la imagen" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapakita ang larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -44714,6 +52116,12 @@ "value" : "revela a imagem" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revela a imagem" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -44761,6 +52169,18 @@ "state" : "needs_review", "value" : "hiện hình ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示圖片" + } } } }, @@ -44798,6 +52218,12 @@ "value" : "imagen" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -44870,6 +52296,12 @@ "value" : "imagem" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -44917,6 +52349,18 @@ "state" : "needs_review", "value" : "hình ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "圖片" + } } } }, @@ -44954,6 +52398,12 @@ "value" : "enviando imagen" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipinapadala ang larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -45026,6 +52476,12 @@ "value" : "a enviar imagem" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviando imagem" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -45073,6 +52529,18 @@ "state" : "needs_review", "value" : "đang gửi hình ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "正在发送图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "圖片發送中" + } } } }, @@ -45110,6 +52578,12 @@ "value" : "imagen no disponible" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi magagamit ang larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -45182,6 +52656,12 @@ "value" : "imagem indisponível" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem indisponível" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -45229,6 +52709,18 @@ "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" : "圖片不可用" + } } } }, @@ -45266,6 +52758,12 @@ "value" : "eliminar imagen" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -45338,6 +52836,12 @@ "value" : "eliminar imagem" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apagar imagem" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -45385,6 +52889,18 @@ "state" : "needs_review", "value" : "xóa hình ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "删除图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "刪除圖片" + } } } }, @@ -45422,6 +52938,12 @@ "value" : "ocultar imagen" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "itago ang larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -45494,6 +53016,12 @@ "value" : "ocultar imagem" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ocultar imagem" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -45541,6 +53069,18 @@ "state" : "needs_review", "value" : "ẩn hình ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "隐藏图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "隱藏圖片" + } } } }, @@ -45578,6 +53118,12 @@ "value" : "abrir imagen" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buksan ang larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -45650,6 +53196,12 @@ "value" : "abrir imagem" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abrir imagem" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -45697,6 +53249,18 @@ "state" : "needs_review", "value" : "mở hình ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打开图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "打開圖片" + } } } }, @@ -45734,6 +53298,12 @@ "value" : "revelar imagen" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipakita ang larawan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -45806,6 +53376,12 @@ "value" : "revelar imagem" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revelar imagem" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -45853,6 +53429,18 @@ "state" : "needs_review", "value" : "hiện hình ảnh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示圖片" + } } } }, @@ -45890,6 +53478,12 @@ "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", @@ -45962,6 +53556,12 @@ "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", @@ -46009,6 +53609,18 @@ "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" : "此操作無法復原 — 發送者可能不在範圍內,無法再次發送。" + } } } }, @@ -46046,6 +53658,12 @@ "value" : "¿eliminar esta imagen?" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "burahin ang larawang ito?" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -46118,6 +53736,12 @@ "value" : "eliminar esta imagem?" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apagar esta imagem?" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -46165,6 +53789,18 @@ "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" : "刪除此圖片?" + } } } }, @@ -46202,6 +53838,12 @@ "value" : "toca para revelar" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-tap para ipakita" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -46274,6 +53916,12 @@ "value" : "toca para revelar" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toque para revelar" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -46321,6 +53969,197 @@ "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" : "正在接收即時語音訊息" + } } } }, @@ -46358,6 +54197,12 @@ "value" : "pausar nota de voz" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-pause ang voice note" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -46430,6 +54275,12 @@ "value" : "colocar a nota de voz em pausa" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pausar mensagem de voz" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -46477,6 +54328,18 @@ "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" : "暫停語音訊息" + } } } }, @@ -46514,6 +54377,12 @@ "value" : "reproducir nota de voz" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-play ang voice note" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -46586,6 +54455,12 @@ "value" : "reproduzir a nota de voz" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reproduzir mensagem de voz" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -46633,6 +54508,197 @@ "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" : "直播" + } } } }, @@ -46670,6 +54736,12 @@ "value" : "abre un chat privado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "binubuksan ang isang pribadong chat" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -46742,6 +54814,12 @@ "value" : "abre uma conversa privada" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre um chat privado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -46789,6 +54867,18 @@ "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" : "打開私聊" + } } } }, @@ -46826,6 +54916,12 @@ "value" : "mostrar huella" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ipakita ang fingerprint" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -46898,6 +54994,12 @@ "value" : "mostrar impressão digital" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar impressão digital" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -46945,6 +55047,18 @@ "state" : "needs_review", "value" : "hiện vân tay" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示指纹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "顯示指紋" + } } } }, @@ -46982,6 +55096,12 @@ "value" : "bloqueado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "na-block" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -47054,6 +55174,12 @@ "value" : "bloqueado" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -47101,6 +55227,18 @@ "state" : "needs_review", "value" : "đã chặn" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已屏蔽" + } } } }, @@ -47138,6 +55276,12 @@ "value" : "favorito" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paborito" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -47210,6 +55354,12 @@ "value" : "favorito" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorito" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -47257,6 +55407,18 @@ "state" : "needs_review", "value" : "yêu thích" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "收藏" + } } } }, @@ -47294,6 +55456,12 @@ "value" : "sin conexión" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -47366,6 +55534,12 @@ "value" : "offline" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -47413,6 +55587,18 @@ "state" : "needs_review", "value" : "ngoại tuyến" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "离线" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "離線" + } } } }, @@ -47450,6 +55636,12 @@ "value" : "mensajes nuevos" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "may bagong mensahe" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -47522,6 +55714,12 @@ "value" : "novas mensagens" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "novas mensagens" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -47569,6 +55767,18 @@ "state" : "needs_review", "value" : "tin nhắn mới" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新訊息" + } } } }, @@ -47606,6 +55816,12 @@ "value" : "avalado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ginarantiyahan" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -47678,6 +55894,12 @@ "value" : "atestado" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "endossado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -47725,6 +55947,18 @@ "state" : "needs_review", "value" : "đã bảo chứng" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已担保" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已擔保" + } } } }, @@ -47941,6 +56175,12 @@ "value" : "avalado por alguien que verificaste" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ginarantiyahan ng taong na-beripika mo" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -48013,6 +56253,12 @@ "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", @@ -48060,6 +56306,18 @@ "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" : "由你驗證過的人擔保" + } } } }, @@ -48963,6 +57221,366 @@ } } }, + "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", @@ -50251,7 +58869,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" : { @@ -50263,7 +58881,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" : { @@ -50287,7 +58905,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile avviare una chat con %@: utente bloccato." + "value" : "impossibile avviare una chat con %@: questa persona è bloccata." } }, "ja" : { @@ -50335,7 +58953,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" : { @@ -50400,364 +59018,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" : { @@ -50788,7 +59048,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" : { @@ -50800,7 +59060,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "envoi impossible : utilisateur bloqué." + "value" : "envoi impossible : cette personne est bloquée." } }, "he" : { @@ -50824,7 +59084,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "invio non riuscito: utente bloccato." + "value" : "invio non riuscito: questa persona è bloccata." } }, "ja" : { @@ -50872,7 +59132,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" : { @@ -50967,7 +59227,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" : { @@ -50979,7 +59239,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "impossible d'envoyer à %@ : utilisateur bloqué." + "value" : "impossible d'envoyer à %@ : cette personne est bloquée." } }, "he" : { @@ -51003,7 +59263,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile inviare a %@: utente bloccato." + "value" : "impossibile inviare a %@: questa persona è bloccata." } }, "ja" : { @@ -51051,7 +59311,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" : { @@ -51116,185 +59376,6 @@ } } }, - "system.dm.unreachable" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে বার্তা পাঠানো যায় না - পিয়ার মেশ বা নোস্টরে পৌঁছানো যাচ্ছে না।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot send message to %@ - peer is not reachable via mesh or nostr." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi maipadala kay %@ - hindi maabot ang peer sa mesh o nostr." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को संदेश नहीं भेज सकते - पीयर मेश या नोस्ट्र पर पहुँच योग्य नहीं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@に送れません: 受信者はmeshやnostrで到達できません。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@에게 메시지를 보낼 수 없습니다 - mesh 또는 nostr를 통해 피어에 연결할 수 없습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa menghantar ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan geen bericht sturen naar %@ – peer niet bereikbaar via mesh of nostr." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można wysłać do %@ – peer nieosiągalny przez mesh ani nostr." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível enviar mensagem a %@ - o par não está acessível por mesh ou Nostr." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "нельзя отправить %@: адресат недоступен через mesh или nostr." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte skicka till %@ – peer nås inte via mesh eller nostr." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ க்கு செய்தி அனுப்ப முடியவில்லை - peer mesh அல்லது nostr மூலமாக அணுக முடியவில்லை." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่สามารถส่งข้อความถึง %@ - ติดต่อเพียร์ผ่าน mesh หรือ nostr ไม่ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@'a mesaj gönderilemiyor - eş mesh veya Nostr üzerinden ulaşılamıyor." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "неможливо надіслати %@: одержувач недосяжний через mesh або nostr." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو پیغام نہیں بھیج سکتے - peer mesh یا nostr کے ذریعے دستیاب نہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể gửi tin cho %@ - nút không thể liên lạc qua mesh hoặc nostr." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法向 %@ 发送:对方无法通过 mesh 或 Nostr 到达。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法向 %@ 發送:對方無法透過 mesh 或 Nostr 到達。" - } - } - } - }, "system.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", @@ -51329,6 +59410,12 @@ "value" : "enviado por la puerta de enlace del mesh" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "naipadala sa pamamagitan ng mesh gateway" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -51401,6 +59488,12 @@ "value" : "enviado através do gateway mesh" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviado pelo gateway do mesh" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -51448,6 +59541,18 @@ "state" : "needs_review", "value" : "đã gửi qua cổng mesh" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已通过 mesh 网关发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已透過 mesh 閘道發送" + } } } }, @@ -51842,6 +59947,12 @@ "value" : "%@ ya es miembro" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "miyembro na si %@" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -51914,6 +60025,12 @@ "value" : "%@ já é membro" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ já é membro" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -51961,6 +60078,18 @@ "state" : "needs_review", "value" : "%@ đã là thành viên" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 已是成员" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 已是成員" + } } } }, @@ -51997,6 +60126,12 @@ "value" : "no se puede eliminar al creador" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi maaaring alisin ang tagalikha" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -52069,6 +60204,12 @@ "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", @@ -52116,6 +60257,18 @@ "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" : "無法移除建立者" + } } } }, @@ -52152,6 +60305,12 @@ "value" : "no se pudo crear el grupo" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi malikha ang grupo" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -52224,6 +60383,12 @@ "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", @@ -52271,6 +60436,18 @@ "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" : "無法建立群組" + } } } }, @@ -52307,6 +60484,12 @@ "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", @@ -52379,6 +60562,12 @@ "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", @@ -52426,6 +60615,18 @@ "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 添加成員" + } } } }, @@ -52462,6 +60663,12 @@ "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", @@ -52534,6 +60741,12 @@ "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", @@ -52581,6 +60794,18 @@ "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" : "只有群組建立者可以這麼做" + } } } }, @@ -52617,6 +60842,12 @@ "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", @@ -52689,6 +60920,12 @@ "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", @@ -52736,6 +60973,18 @@ "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" : "群組已滿(最多 %@ 位成員)" + } } } }, @@ -52772,6 +61021,12 @@ "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", @@ -52844,6 +61099,12 @@ "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", @@ -52891,6 +61152,18 @@ "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" : "你的身份金鑰尚未就緒" + } } } }, @@ -52927,6 +61200,12 @@ "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", @@ -52999,6 +61278,12 @@ "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", @@ -53046,6 +61331,18 @@ "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" : "無法建立群組邀請" + } } } }, @@ -53082,6 +61379,12 @@ "value" : "invitaste a %1$@ a '%2$@'" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inimbitahan si %1$@ sa '%2$@'" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -53154,6 +61457,12 @@ "value" : "%1$@ convidado para '%2$@'" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ foi convidado para '%2$@'" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -53201,6 +61510,18 @@ "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$@」" + } } } }, @@ -53237,6 +61558,12 @@ "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", @@ -53309,6 +61636,12 @@ "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", @@ -53356,6 +61689,18 @@ "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$@」— 現在會顯示在你的成員列表中" + } } } }, @@ -53392,6 +61737,12 @@ "value" : "saliste del grupo '%@'" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "umalis sa grupong '%@'" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -53464,6 +61815,12 @@ "value" : "saíste do grupo '%@'" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você saiu do grupo '%@'" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -53511,6 +61868,18 @@ "state" : "needs_review", "value" : "đã rời nhóm '%@'" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已退出群组 '%@'" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已離開群組「%@」" + } } } }, @@ -53547,6 +61916,12 @@ "value" : "tus grupos:" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mga grupo mo:" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -53619,6 +61994,12 @@ "value" : "os teus grupos:" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "seus grupos:" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -53666,6 +62047,18 @@ "state" : "needs_review", "value" : "nhóm của bạn:" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你的群组:" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你的群組:" + } } } }, @@ -53702,6 +62095,12 @@ "value" : "'%@' no es miembro de este grupo" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi miyembro ng grupong ito si '%@'" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -53774,6 +62173,12 @@ "value" : "'%@' não é membro deste grupo" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não é membro deste grupo" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -53821,6 +62226,18 @@ "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" : "「%@」不是此群組的成員" + } } } }, @@ -53857,6 +62274,12 @@ "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", @@ -53929,6 +62352,12 @@ "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", @@ -53976,6 +62405,18 @@ "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 個字元" + } } } }, @@ -54012,6 +62453,12 @@ "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", @@ -54084,6 +62531,12 @@ "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", @@ -54131,6 +62584,18 @@ "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 建立一個" + } } } }, @@ -54167,6 +62632,12 @@ "value" : "abre primero un chat de grupo" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "magbukas muna ng group chat" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -54239,6 +62710,12 @@ "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", @@ -54286,6 +62763,18 @@ "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" : "請先打開一個群組聊天" + } } } }, @@ -54322,6 +62811,12 @@ "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", @@ -54394,6 +62889,12 @@ "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", @@ -54441,6 +62942,18 @@ "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" : "尚無法驗證 %@ 的身份 — 請等待對方的宣告" + } } } }, @@ -54477,6 +62990,12 @@ "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", @@ -54549,6 +63068,12 @@ "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", @@ -54596,6 +63121,18 @@ "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 連線才能被邀請" + } } } }, @@ -54632,6 +63169,12 @@ "value" : "'%@' no encontrado" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi nahanap si '%@'" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -54704,6 +63247,12 @@ "value" : "'%@' não encontrado" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não encontrado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -54751,6 +63300,18 @@ "state" : "needs_review", "value" : "không tìm thấy '%@'" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未找到 '%@'" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "找不到「%@」" + } } } }, @@ -54787,6 +63348,12 @@ "value" : "fuiste eliminado del grupo '%@'" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inalis ka sa grupong '%@'" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -54859,6 +63426,12 @@ "value" : "foste removido do grupo '%@'" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "você foi removido do grupo '%@'" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -54906,6 +63479,18 @@ "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" : "你已被移出群組「%@」" + } } } }, @@ -54942,6 +63527,12 @@ "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", @@ -55014,6 +63605,12 @@ "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", @@ -55061,6 +63658,18 @@ "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" : "已移除 %@ 並輪換了群組金鑰" + } } } }, @@ -55097,6 +63706,12 @@ "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", @@ -55169,6 +63784,12 @@ "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", @@ -55216,6 +63837,18 @@ "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" : "無法輪換群組金鑰" + } } } }, @@ -55252,6 +63885,12 @@ "value" : "no se pudo cifrar el mensaje" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hindi ma-encrypt ang mensahe" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -55324,6 +63963,12 @@ "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", @@ -55371,6 +64016,18 @@ "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" : "無法加密訊息" + } } } }, @@ -55407,6 +64064,12 @@ "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", @@ -55479,6 +64142,12 @@ "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", @@ -55526,6 +64195,18 @@ "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" : "你已不在此群組中" + } } } }, @@ -55562,6 +64243,12 @@ "value" : "uso: /group create " } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paggamit: /group create " + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -55634,6 +64321,12 @@ "value" : "utilização: /group create " } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group create " + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -55681,6 +64374,18 @@ "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 " + } } } }, @@ -55717,6 +64422,12 @@ "value" : "uso: /group invite @name" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paggamit: /group invite @name" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -55789,6 +64500,12 @@ "value" : "utilização: /group invite @name" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group invite @nome" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -55836,6 +64553,18 @@ "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" + } } } }, @@ -55872,6 +64601,12 @@ "value" : "uso: /group remove @name" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "paggamit: /group remove @name" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -55944,6 +64679,12 @@ "value" : "utilização: /group remove @name" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group remove @nome" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -55991,6 +64732,18 @@ "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" + } } } }, @@ -56386,6 +65139,12 @@ "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", @@ -56458,6 +65217,12 @@ "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", @@ -56505,6 +65270,18 @@ "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" : "無法屏蔽 %@:未找到或無法驗證身份" + } } } }, @@ -56542,6 +65319,12 @@ "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", @@ -56614,6 +65397,12 @@ "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", @@ -56661,6 +65450,18 @@ "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" : "已屏蔽 %@。你將不再收到對方的訊息" + } } } }, @@ -56698,6 +65499,12 @@ "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", @@ -56770,6 +65577,12 @@ "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", @@ -56817,6 +65630,18 @@ "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" : "無法取消屏蔽 %@:未找到" + } } } }, @@ -56854,6 +65679,12 @@ "value" : "se desbloqueó a %@" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in-unblock si %@" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -56926,6 +65757,12 @@ "value" : "%@ desbloqueado" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ foi desbloqueado" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -56973,6 +65810,18 @@ "state" : "needs_review", "value" : "đã bỏ chặn %@" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已取消屏蔽 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已取消屏蔽 %@" + } } } }, @@ -57905,6 +66754,12 @@ "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", @@ -57977,6 +66832,12 @@ "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", @@ -58024,6 +66885,18 @@ "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 個)— 你的裝置已高亮顯示" + } } } }, @@ -58061,6 +66934,12 @@ "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", @@ -58133,6 +67012,12 @@ "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", @@ -58180,6 +67065,18 @@ "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 連線 — 地圖會隨著同伴宣告到達而逐漸填滿" + } } } }, @@ -58217,6 +67114,12 @@ "value" : "actualizar topología" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i-refresh ang topology" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -58289,6 +67192,12 @@ "value" : "atualizar topologia" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atualizar topologia" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -58336,6 +67245,18 @@ "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" : "重新整理拓撲" + } } } }, @@ -58373,6 +67294,12 @@ "value" : "%1$ld peers · %2$ld enlaces" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld link" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -58445,6 +67372,12 @@ "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", @@ -58492,6 +67425,18 @@ "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 條連線" + } } } }, @@ -58529,6 +67474,12 @@ "value" : "topología del mesh" } }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topology" + } + }, "fr" : { "stringUnit" : { "state" : "needs_review", @@ -58601,6 +67552,12 @@ "value" : "topologia mesh" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia do mesh" + } + }, "ru" : { "stringUnit" : { "state" : "needs_review", @@ -58648,6 +67605,18 @@ "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 拓撲" + } } } }, @@ -60622,4 +69591,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} 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 b4f309d9..2b077759 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -28,6 +28,7 @@ enum CommandInfo: String, Identifiable { case unfavorite = "unfav" case ping case trace + case drop var id: String { rawValue } @@ -41,6 +42,8 @@ enum CommandInfo: String, Identifiable { 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 } @@ -62,11 +65,12 @@ enum CommandInfo: String, Identifiable { 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] { - var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] + 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). diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index be130f10..a42b86a0 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -723,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 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/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 7e39bdb4..72144e9e 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -20,6 +20,10 @@ struct NostrProtocol { 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 @@ -256,6 +260,94 @@ 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. @@ -264,7 +356,8 @@ struct NostrProtocol { geohash: String, senderIdentity: NostrIdentity, nickname: String? = nil, - expiresAt: Date? = nil + expiresAt: Date? = nil, + urgent: Bool = false ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { @@ -273,6 +366,9 @@ struct NostrProtocol { if let expiresAt { tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))]) } + if urgent { + tags.append(["t", "urgent"]) + } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), @@ -548,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 @@ -708,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) diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index e1de49d6..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 @@ -184,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 @@ -202,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 @@ -298,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 @@ -331,6 +357,7 @@ final class NostrRelayManager: ObservableObject { duplicateInboundEventDropCountBySubscription.removeAll() inboundEventLogCount = 0 Self.pendingGiftWrapIDs.removeAll() + confirmedSends.removeAll() messageQueueLock.lock() messageQueue.removeAll() @@ -347,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 @@ -406,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)) @@ -795,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 @@ -910,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) @@ -919,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 + ) } } } @@ -935,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 } @@ -966,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) } } } @@ -1018,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 { @@ -1027,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) @@ -1043,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 { @@ -1056,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) } } @@ -1075,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 { @@ -1100,9 +1247,11 @@ final class NostrRelayManager: ObservableObject { /// 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 { @@ -1111,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 @@ -1463,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/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 476ae876..6c259f5f 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -77,6 +77,8 @@ enum NoisePayloadType: UInt8 { // 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 @@ -90,6 +92,7 @@ enum NoisePayloadType: UInt8 { 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" @@ -127,6 +130,9 @@ protocol BitchatDelegate: AnyObject { // 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?) @@ -150,6 +156,10 @@ extension BitchatDelegate { // 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/Geohash.swift b/bitchat/Protocols/Geohash.swift index 6fbb2b90..d0061c5c 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -10,14 +10,6 @@ enum Geohash { return map }() - /// Validates a geohash string for building-level precision (8 characters). - /// - Parameter geohash: The geohash string to validate - /// - Returns: true if valid 8-character base32 geohash, false otherwise - static func isValidBuildingGeohash(_ geohash: String) -> Bool { - guard geohash.count == 8 else { return false } - return geohash.lowercased().allSatisfy { base32Map[$0] != nil } - } - /// Validates a geohash string at any channel precision (1-12 characters). /// - Parameter geohash: The geohash string to validate /// - Returns: true if a non-empty base32 geohash of at most 12 characters 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 index e65f2b66..8e7b7c06 100644 --- a/bitchat/Protocols/NostrCarrierPacket.swift +++ b/bitchat/Protocols/NostrCarrierPacket.swift @@ -32,6 +32,14 @@ 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 diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index 309860aa..1f691dd4 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -9,19 +9,25 @@ struct AnnouncementPacket { let signingPublicKey: Data // Ed25519 public key for signing let directNeighbors: [Data]? // 8-byte peer IDs let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients) + /// Rendezvous geohash cell this peer bridges, when advertising `.bridge`. + /// Coarse (cell-level) by design; lets mesh-only peers compose correctly + /// tagged rendezvous events without their own location fix. + let bridgeGeohash: String? init( nickname: String, noisePublicKey: Data, signingPublicKey: Data, directNeighbors: [Data]?, - capabilities: PeerCapabilities? = nil + capabilities: PeerCapabilities? = nil, + bridgeGeohash: String? = nil ) { self.nickname = nickname self.noisePublicKey = noisePublicKey self.signingPublicKey = signingPublicKey self.directNeighbors = directNeighbors self.capabilities = capabilities + self.bridgeGeohash = bridgeGeohash } private enum TLVType: UInt8 { @@ -30,6 +36,7 @@ struct AnnouncementPacket { case signingPublicKey = 0x03 case directNeighbors = 0x04 case capabilities = 0x05 + case bridgeGeohash = 0x06 } func encode() -> Data? { @@ -74,6 +81,15 @@ struct AnnouncementPacket { 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 } @@ -84,6 +100,7 @@ struct AnnouncementPacket { var signingPublicKey: Data? var directNeighbors: [Data]? var capabilities: PeerCapabilities? + var bridgeGeohash: String? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -116,6 +133,10 @@ struct AnnouncementPacket { } 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) @@ -129,7 +150,8 @@ struct AnnouncementPacket { noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, directNeighbors: directNeighbors, - capabilities: capabilities + capabilities: capabilities, + bridgeGeohash: bridgeGeohash ) } } 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/Services/AutocompleteService.swift b/bitchat/Services/AutocompleteService.swift index 6b672d53..9ae47df9 100644 --- a/bitchat/Services/AutocompleteService.swift +++ b/bitchat/Services/AutocompleteService.swift @@ -11,14 +11,7 @@ import Foundation /// Manages autocomplete functionality for chat final class AutocompleteService { private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) - private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) - - private let commands = [ - "/msg", "/who", "/clear", - "/hug", "/slap", "/fav", "/unfav", - "/block", "/unblock" - ] - + /// Get autocomplete suggestions for current text func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) { let textToPosition = String(text.prefix(cursorPosition)) @@ -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 e33248e3..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. @@ -135,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 @@ -152,7 +175,7 @@ final class BLEAnnounceHandler { let update = env.upsertVerifiedAnnounce( peerID, announcement, - isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), now ) isNewPeer = update.isNewPeer diff --git a/bitchat/Services/BLE/BLEFanoutSelector.swift b/bitchat/Services/BLE/BLEFanoutSelector.swift index 6cabcdd2..8cc8aefe 100644 --- a/bitchat/Services/BLE/BLEFanoutSelector.swift +++ b/bitchat/Services/BLE/BLEFanoutSelector.swift @@ -15,7 +15,10 @@ 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 { @@ -31,10 +34,14 @@ enum BLEFanoutSelector { to: directedPeerHint, links: rawAllowed, peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings + centralPeerBindings: centralPeerBindings, + preferredPeripheralPerPeer: preferredPeripheralPerPeer ) { return directedSelection } + if directedPeerHint != nil, requireDirectPeerLink { + return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) + } if let directedPeerHint, hasBoundLink( to: directedPeerHint, @@ -46,11 +53,20 @@ enum BLEFanoutSelector { return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) } - let allowed = collapseDuplicateLinksPerPeer( - rawAllowed, - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings - ) + // 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( @@ -97,7 +113,8 @@ enum BLEFanoutSelector { to peerID: PeerID, links: (peripheralIDs: [String], centralIDs: [String]), peripheralPeerBindings: [String: PeerID], - centralPeerBindings: [String: PeerID] + centralPeerBindings: [String: PeerID], + preferredPeripheralPerPeer: [PeerID: String] ) -> BLEFanoutSelection? { let directLinks = collapseDuplicateLinksPerPeer( ( @@ -105,7 +122,8 @@ enum BLEFanoutSelector { centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID } ), peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings + centralPeerBindings: centralPeerBindings, + preferredPeripheralPerPeer: preferredPeripheralPerPeer ) guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else { @@ -140,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 @@ -148,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 83311584..9550e196 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -61,7 +61,6 @@ struct BLEFragmentAssemblyBuffer { } private struct Metadata { - let type: UInt8 let total: Int let timestamp: Date let isBroadcast: Bool @@ -150,7 +149,6 @@ struct BLEFragmentAssemblyBuffer { fragmentsByKey[header.key] = [:] metadataByKey[header.key] = Metadata( - type: header.originalType, total: header.total, timestamp: now, isBroadcast: header.isBroadcastFragment, 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 442ba0d2..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,12 +109,15 @@ 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. - if packet.type == MessageType.requestSync.rawValue { return true } - return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL + packet.type == MessageType.requestSync.rawValue + } + + static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { + packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL } private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { 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 64cfa73e..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, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage: + // 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 41ef8098..792107d8 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -10,6 +10,8 @@ struct BLEPeerInfo: Equatable { 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 { @@ -117,6 +119,15 @@ struct BLEPeerRegistry { 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) } @@ -141,20 +152,22 @@ struct BLEPeerRegistry { } } - 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 @@ -168,7 +181,8 @@ struct BLEPeerRegistry { signingPublicKey: Data?, isConnected: Bool, now: Date, - capabilities: PeerCapabilities = [] + capabilities: PeerCapabilities = [], + bridgeGeohash: String? = nil ) -> BLEPeerAnnounceUpdate { let existing = peers[peerID] let update = BLEPeerAnnounceUpdate( @@ -185,7 +199,8 @@ struct BLEPeerRegistry { signingPublicKey: signingPublicKey, isVerifiedNickname: true, lastSeen: now, - capabilities: capabilities + 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/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 92e3bec8..e81fabd5 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -70,6 +70,7 @@ struct BLEReceivePipeline { // 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/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 8d9234af..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() @@ -81,7 +94,11 @@ final class BLEService: NSObject { // 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 @@ -122,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 @@ -169,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() @@ -197,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, @@ -207,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? @@ -254,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() } } @@ -485,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 { @@ -650,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() } @@ -670,6 +707,19 @@ 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) @@ -708,6 +758,33 @@ final class BLEService: NSObject { } } + /// 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) @@ -733,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) @@ -888,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) { @@ -898,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) } } @@ -1101,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 @@ -1124,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 @@ -1139,36 +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) { + @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 } - sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) + 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 @@ -1224,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) } @@ -1243,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) }, @@ -1294,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) { @@ -1303,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) @@ -1342,8 +1648,12 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync { - (peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities)) + let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync { + ( + peerRegistry.connectedRoutingData, + PeerCapabilities.localSupported.union(runtimeCapabilities), + runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil + ) } let announcement = AnnouncementPacket( @@ -1351,7 +1661,8 @@ final class BLEService: NSObject { noisePublicKey: noisePub, signingPublicKey: signingPub, directNeighbors: connectedPeerIDs, - capabilities: advertisedCapabilities + capabilities: advertisedCapabilities, + bridgeGeohash: advertisedBridgeCell ) guard let payload = announcement.encode() else { @@ -1452,6 +1763,55 @@ final class BLEService: NSObject { 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. @@ -1515,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() @@ -1530,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() @@ -1609,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( @@ -1635,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) @@ -1660,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) @@ -1671,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 @@ -1682,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() @@ -1702,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) @@ -1788,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() @@ -1836,6 +2282,23 @@ 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 } @@ -1858,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]? @@ -2031,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?) { @@ -2187,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()) @@ -2243,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 @@ -2254,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) @@ -2409,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 { @@ -2463,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") @@ -2493,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 { @@ -2749,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 @@ -2787,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 @@ -2872,6 +3466,102 @@ extension BLEService { 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 @@ -2921,7 +3611,8 @@ extension BLEService { } } - private func openCourierEnvelope(_ envelope: CourierEnvelope) { + @discardableResult + private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool { do { let typedPayload: Data let senderStaticKey: Data @@ -2946,14 +3637,31 @@ extension BLEService { let payloadType = NoisePayloadType(rawValue: typeRaw), payloadType == .privateMessage else { SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session) - return + 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 + return true } // A present sender resolves to their live mesh thread via the // derived short ID. An absent sender — the usual courier case — @@ -2963,7 +3671,6 @@ extension BLEService { let shortID = PeerID(publicKey: senderStaticKey) let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey) - let payload = Data(typedPayload.dropFirst()) SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) sfMetrics?.record(.courierOpened) notifyUI { [weak self] in @@ -2974,9 +3681,11 @@ extension BLEService { 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 } } @@ -3018,13 +3727,23 @@ extension BLEService { /// Hand over any carried envelopes addressed to a peer we just heard from. private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) { - let envelopes = courierStore.takeEnvelopes(for: noiseKey) - guard !envelopes.isEmpty else { return } - SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session) - for envelope in envelopes { - guard let payload = envelope.encode() else { continue } - sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID) - sfMetrics?.record(.courierHandedOver) + 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) } } @@ -3054,13 +3773,23 @@ extension BLEService { private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) { let store = courierStore let metrics = sfMetrics - let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in - guard let self, !envelopes.isEmpty else { return } - SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session) - for envelope in envelopes { - guard let payload = envelope.encode() else { continue } - self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID) + 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 @@ -3068,7 +3797,7 @@ extension BLEService { // 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(store.takeSprayCopies(for: noiseKey)) + sendSpray() } } @@ -3270,7 +3999,7 @@ extension BLEService { /// 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: PeerID) { + private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) { let senderID = PeerID(hexData: packet.senderID) let directedToUs: Bool if let recipientID = packet.recipientID { @@ -3349,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 @@ -3402,23 +4187,27 @@ 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() @@ -3426,11 +4215,99 @@ extension BLEService { 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() @@ -3551,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 { @@ -3577,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) @@ -3600,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 @@ -3626,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() { @@ -3638,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) } @@ -3658,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) @@ -3774,6 +4719,16 @@ extension BLEService { } } + #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: @@ -3795,7 +4750,10 @@ 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) @@ -3812,6 +4770,11 @@ extension BLEService { 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 @@ -3890,6 +4853,20 @@ extension BLEService { 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. @@ -3897,15 +4874,236 @@ extension BLEService { let result, result.isVerified else { return } let noiseKey = result.announcement.noisePublicKey - if result.isDirectAnnounce { - // Established link: destructive handover is safe, and the peer is - // close enough to become a courier for other carried mail. + 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: recipient is multi-hop away. Push a copy - // toward them speculatively; the carried copy stays put. + // 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)) } } @@ -3928,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() } }, @@ -3940,7 +5157,8 @@ extension BLEService { signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, now: now, - capabilities: announcement.capabilities ?? [] + capabilities: announcement.capabilities ?? [], + bridgeGeohash: announcement.bridgeGeohash ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) }, shouldEmitReconnectLog: { [weak self] peerID, now in @@ -4144,7 +5362,7 @@ extension BLEService { /// 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: PeerID) { + 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 } @@ -4160,8 +5378,58 @@ extension BLEService { } } + /// 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) { @@ -4258,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) @@ -4270,6 +5536,7 @@ extension BLEService { private func performMaintenance() { maintenanceCounter += 1 + lastMaintenanceAt = Date() let now = Date() let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } @@ -4334,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 } diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift index 1f307bd4..60c6b827 100644 --- a/bitchat/Services/Board/BoardManager.swift +++ b/bitchat/Services/Board/BoardManager.swift @@ -19,11 +19,10 @@ final class BoardManager: ObservableObject { @Published private(set) var posts: [BoardPostPacket] = [] private let transport: Transport - private let store: BoardStore /// 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) -> String? + 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: @@ -35,11 +34,10 @@ final class BoardManager: ObservableObject { init( transport: Transport, store: BoardStore = .shared, - publishToNostr: ((String, String, String, UInt64) -> String?)? = nil, + publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil, deleteFromNostr: ((String, String) -> Void)? = nil ) { self.transport = transport - self.store = store self.publishToNostr = publishToNostr ?? Self.livePublishToNostr self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr cancellable = store.$postsSnapshot @@ -128,7 +126,7 @@ final class BoardManager: ObservableObject { // 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) { + if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) { bridgedEventIDs[postID] = eventID } return true @@ -160,7 +158,7 @@ final class BoardManager: ObservableObject { return true } - private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? { + 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) @@ -173,7 +171,8 @@ final class BoardManager: ObservableObject { geohash: geohash, senderIdentity: identity, nickname: nickname, - expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000) + expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000), + urgent: urgent ) NostrRelayManager.shared.sendEvent(event, to: relays) return event.id diff --git a/bitchat/Services/Board/BoardStore.swift b/bitchat/Services/Board/BoardStore.swift index c96670d0..db6a9605 100644 --- a/bitchat/Services/Board/BoardStore.swift +++ b/bitchat/Services/Board/BoardStore.swift @@ -146,14 +146,6 @@ final class BoardStore { // MARK: - Maintenance - func pruneExpired() { - let nowMs = currentMs() - queue.sync { - pruneExpiredLocked(nowMs: nowMs) - persistLocked() - } - } - /// Panic wipe: drop all board data from memory and disk. func wipe() { queue.sync { diff --git a/bitchat/Services/Board/UnifiedNotices.swift b/bitchat/Services/Board/UnifiedNotices.swift index 16d88a97..31bc14c8 100644 --- a/bitchat/Services/Board/UnifiedNotices.swift +++ b/bitchat/Services/Board/UnifiedNotices.swift @@ -23,6 +23,9 @@ struct NoticeItem: Identifiable, Equatable { 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 { @@ -36,6 +39,9 @@ struct NoticeItem: Identifiable, Equatable { 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) } @@ -46,7 +52,8 @@ struct NoticeItem: Identifiable, Equatable { .first.map(String.init) ?? display content = note.content createdAt = note.createdAt - isUrgent = false + isUrgent = note.isUrgent + expiresAt = note.expiresAt source = .nostr(note) } } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index b8a4785e..a68bf65b 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -146,6 +146,8 @@ final class CommandProcessor { return handleTrace(args) case "/pay": return handlePay(args) + case "/drop": + return handleDrop(args) case "/help": return .success(message: Self.helpText) default: @@ -171,9 +173,40 @@ final class CommandProcessor { /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 { @@ -336,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 diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index a24eb371..77c7d100 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -10,6 +10,9 @@ 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 @@ -42,6 +45,8 @@ final class CourierStore { 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? @@ -59,6 +64,7 @@ final class CourierStore { copies: UInt8, sprayedTo: Set = [], lastRemoteHandoverAt: Date? = nil, + lastBridgePublishAt: Date? = nil, prekeyID: UInt32? = nil ) { self.recipientTag = recipientTag @@ -70,6 +76,7 @@ final class CourierStore { self.copies = copies self.sprayedTo = sprayedTo self.lastRemoteHandoverAt = lastRemoteHandoverAt + self.lastBridgePublishAt = lastBridgePublishAt self.prekeyID = prekeyID } @@ -86,6 +93,7 @@ final class CourierStore { 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) } } @@ -115,13 +123,45 @@ final class CourierStore { 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) { + 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) @@ -149,10 +189,16 @@ final class CourierStore { return queue.sync { pruneExpiredLocked(at: date) - // Identical ciphertext is the same envelope; accept idempotently, - // keeping the larger spray budget (bounded by maxCopies either way). + // 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 }) { - envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies) + if envelopes[existing].sprayedTo.isEmpty { + envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies) + } persistLocked() return true } @@ -202,20 +248,52 @@ final class CourierStore { // MARK: - Handover (on encountering a peer) /// Remove and return all envelopes addressed to the given peer, matching - /// the rotating recipient tag across adjacent days. Envelopes are removed - /// optimistically: handover happens over a live link, and the depositor's - /// outbox still retains the original for direct delivery. + /// 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) - return queue.sync { + let offered = queue.sync { pruneExpiredLocked(at: date) - let matched = envelopes.filter { candidates.contains($0.recipientTag) } - guard !matched.isEmpty else { return [] } - envelopes.removeAll { stored in matched.contains(stored) } - persistLocked() - return matched.map(\.envelope) + 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* @@ -242,6 +320,36 @@ final class CourierStore { } } + /// 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 @@ -249,41 +357,69 @@ final class CourierStore { /// 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) - return queue.sync { + let offered = queue.sync { pruneExpiredLocked(at: date) - var sprayed: [CourierEnvelope] = [] - for index in envelopes.indices { - let stored = envelopes[index] + return envelopes.compactMap { stored -> CourierEnvelope? in guard stored.copies > 1, stored.depositorNoiseKey != courierNoiseKey, !stored.sprayedTo.contains(courierNoiseKey), - !courierTags.contains(stored.recipientTag) else { continue } - let given = stored.copies / 2 - envelopes[index].copies = stored.copies - given - envelopes[index].sprayedTo.insert(courierNoiseKey) - sprayed.append(stored.envelope.withCopies(given)) + !courierTags.contains(stored.recipientTag) else { return nil } + return stored.envelope.withCopies(stored.copies / 2) } - if !sprayed.isEmpty { persistLocked() } - return sprayed } + + 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 - func pruneExpired() { - let date = now() - queue.sync { - pruneExpiredLocked(at: date) - persistLocked() - } - } - /// 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) } @@ -291,6 +427,16 @@ final class CourierStore { } } + /// 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) { @@ -311,6 +457,12 @@ final class CourierStore { 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) @@ -323,7 +475,7 @@ final class CourierStore { let data = try JSONEncoder().encode(envelopes) var options: Data.WritingOptions = [.atomic] #if os(iOS) - options.insert(.completeFileProtection) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) #endif try data.write(to: fileURL, options: options) } catch { @@ -334,16 +486,135 @@ final class CourierStore { private func loadFromDisk() { guard let fileURL else { return } queue.sync { - guard let data = try? Data(contentsOf: fileURL), - let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + diskLoadDeferred = false return } - envelopes = stored + 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, diff --git a/bitchat/Services/Courier/MessageOutboxStore.swift b/bitchat/Services/Courier/MessageOutboxStore.swift index b38616a2..5543a8d0 100644 --- a/bitchat/Services/Courier/MessageOutboxStore.swift +++ b/bitchat/Services/Courier/MessageOutboxStore.swift @@ -11,6 +11,9 @@ 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. @@ -60,76 +63,550 @@ final class MessageOutboxStore { 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) { + 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() -> [PeerID: [QueuedMessage]] { - guard let fileURL, - let sealed = try? Data(contentsOf: fileURL), - let key = encryptionKey(createIfMissing: false), - 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) else { - return [:] + func load() -> Snapshot { + lock.lock() + defer { lock.unlock() } + + if case .loaded = diskState { + return cachedSnapshot } - var outbox: [PeerID: [QueuedMessage]] = [:] - for (peerID, queue) in decoded where !queue.isEmpty { - outbox[PeerID(str: peerID)] = queue + // 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 } - return outbox } - func save(_ outbox: [PeerID: [QueuedMessage]]) { - guard let fileURL else { return } - let flattened = outbox.filter { !$0.value.isEmpty } - guard !flattened.isEmpty else { - try? FileManager.default.removeItem(at: fileURL) - return + 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 } - guard let key = encryptionKey(createIfMissing: true) else { - SecureLogger.error("Outbox not persisted: no encryption key available", category: .session) - return - } - do { - let keyed = Dictionary(uniqueKeysWithValues: flattened.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 + switch diskState { + case .loaded: + cachedSnapshot = applyingPendingRemovalsLocked( + recoveryStateWasPending + ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) + : flattened ) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtection) - #endif - try sealed.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist outbox: \(error)", category: .session) + 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? { - if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 { - return SymmetricKey(data: data) + 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. @@ -139,9 +616,222 @@ final class MessageOutboxStore { 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, diff --git a/bitchat/Services/Courier/StoreAndForwardMetrics.swift b/bitchat/Services/Courier/StoreAndForwardMetrics.swift index 5452f357..4fddf251 100644 --- a/bitchat/Services/Courier/StoreAndForwardMetrics.swift +++ b/bitchat/Services/Courier/StoreAndForwardMetrics.swift @@ -58,12 +58,6 @@ final class StoreAndForwardMetrics { SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session) } - func snapshot() -> [String: Int] { - lock.lock() - defer { lock.unlock() } - return counts - } - /// Included in the panic wipe alongside the stores it describes. func reset() { lock.lock() diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index aa045841..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() 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 index c8d13756..0c7bdc9f 100644 --- a/bitchat/Services/Gateway/GatewayService.swift +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -82,7 +82,6 @@ final class GatewayService: ObservableObject { let depositor: PeerID let geohash: String let event: NostrEvent - let queuedAt: Date } static let shared = GatewayService() @@ -179,6 +178,10 @@ final class GatewayService: ObservableObject { // 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 } } @@ -221,7 +224,7 @@ final class GatewayService: ObservableObject { publish(event, geohash: carrier.geohash) accepted = true } else { - accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) + accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event)) } // Only render on our own timeline what we actually accepted for @@ -463,30 +466,3 @@ final class GatewayService: ObservableObject { && geohash.allSatisfy { allowed.contains($0) } } } - -/// Insertion-ordered string set with a fixed capacity; the oldest entry is -/// evicted when full. -private 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/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 index dc4a0abf..8f4a09d1 100644 --- a/bitchat/Services/Groups/GroupProtocol.swift +++ b/bitchat/Services/Groups/GroupProtocol.swift @@ -406,7 +406,6 @@ enum GroupCryptoError: Error, Equatable { case malformedPayload case signingFailed case sealFailed - case wrongEpoch case decryptionFailed case badSenderSignature } 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 e95f536a..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 } @@ -70,6 +81,30 @@ final class LocationNotesManager: ObservableObject { /// 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)) @@ -90,6 +125,8 @@ final class LocationNotesManager: ObservableObject { 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) @@ -123,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 } - guard Geohash.isValidGeohash(norm) else { - SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 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 - ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex - 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) @@ -168,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 @@ -202,10 +261,15 @@ final class LocationNotesManager: ObservableObject { tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) })?[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, geohash: matchedGeohash) + 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() @@ -213,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 { @@ -235,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 @@ -245,7 +348,9 @@ final class LocationNotesManager: ObservableObject { content: trimmed, createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), nickname: nickname, - geohash: geohash + geohash: geohash, + expiresAt: expiresAt, + isUrgent: urgent ) self.noteIDs.insert(event.id) self.notes.insert(echo, at: 0) @@ -288,6 +393,14 @@ final class LocationNotesManager: ObservableObject { } } + /// 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 { @@ -297,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..209fab6c 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 @@ -111,6 +109,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private let teleportedStoreKey = "locationChannel.teleportedSet" private let bookmarksKey = "locationChannel.bookmarks" private let bookmarkNamesKey = "locationChannel.bookmarkNames" + private let bookmarkNamesSchemaVersionKey = "locationChannel.bookmarkNamesSchemaVersion" + private let bookmarkNamesCurrentSchemaVersion = 1 // MARK: - Published State (Channel) @@ -224,6 +224,26 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl let dict = try? JSONDecoder().decode([String: String].self, from: data) { bookmarkNames = dict } + + migrateBookmarkNamesIfNeeded() + } + + /// Drops names cached before low-precision geohashes became country-first. + /// Correctly resolved names written after this migration must survive + /// subsequent launches, so the migration is explicitly versioned. + private func migrateBookmarkNamesIfNeeded() { + guard storage.integer(forKey: bookmarkNamesSchemaVersionKey) < bookmarkNamesCurrentSchemaVersion else { + return + } + + let retainedNames = bookmarkNames.filter { + Self.normalizeGeohash($0.key).count > 2 + } + if retainedNames.count != bookmarkNames.count { + bookmarkNames = retainedNames + persistBookmarkNames() + } + storage.set(bookmarkNamesCurrentSchemaVersion, forKey: bookmarkNamesSchemaVersionKey) } private func initializePermissionState() { @@ -268,7 +288,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 +389,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 +415,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 +425,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 +469,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 } @@ -521,15 +547,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { - var uniqueAdmins: [String] = [] - var seenAdmins = Set() + var uniqueRegions: [String] = [] + var seenRegions = Set() var idx = 0 func step() { if idx >= points.count { let finalName: String? = { - if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] } - return uniqueAdmins.first + if uniqueRegions.count >= 2 { return uniqueRegions[0] + " and " + uniqueRegions[1] } + return uniqueRegions.first }() if let finalName = finalName, !finalName.isEmpty { DispatchQueue.main.async { @@ -545,12 +571,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in guard self != nil else { return } if let pm = placemarks?.first { - if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) { - seenAdmins.insert(admin) - uniqueAdmins.append(admin) - } else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) { - seenAdmins.insert(country) - uniqueAdmins.append(country) + if let country = pm.country, !country.isEmpty { + if !seenRegions.contains(country) { + seenRegions.insert(country) + uniqueRegions.append(country) + } + } else if let admin = pm.administrativeArea, + !admin.isEmpty, + !seenRegions.contains(admin) { + seenRegions.insert(admin) + uniqueRegions.append(admin) } } step() @@ -562,7 +592,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { switch len { case 0...2: - return pm.administrativeArea ?? pm.country + return pm.country ?? pm.administrativeArea case 3...4: return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country case 5: @@ -632,15 +662,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/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 976b8eb8..fbc6d694 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -52,6 +52,60 @@ final class MessageRouter { /// 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 @@ -76,6 +130,9 @@ final class MessageRouter { 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( @@ -114,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. @@ -160,6 +241,9 @@ final class MessageRouter { 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 } @@ -244,6 +328,23 @@ final class MessageRouter { 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], @@ -264,16 +365,26 @@ final class MessageRouter { 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() } + 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 @@ -296,26 +407,58 @@ final class MessageRouter { 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() } - func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + /// 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) { @@ -341,14 +484,31 @@ final class MessageRouter { 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) dropMessage(message.messageID, for: peerID) diff --git a/bitchat/Services/NetworkReachabilityMonitor.swift b/bitchat/Services/NetworkReachabilityMonitor.swift index bc2b77cf..53bb0677 100644 --- a/bitchat/Services/NetworkReachabilityMonitor.swift +++ b/bitchat/Services/NetworkReachabilityMonitor.swift @@ -48,6 +48,15 @@ struct ReachabilityDebounce { /// 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? { @@ -157,7 +166,10 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { } } flushWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + debounce.interval, execute: 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) { diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 9e7e6eae..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 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 7a5c052b..2d1c380b 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -13,12 +13,40 @@ 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 { @@ -33,7 +61,8 @@ final class NostrTransport: Transport, @unchecked Sendable { scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) }, - relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() } + relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }, + ackPacer: NostrTransport.sharedAckPacer ) } } @@ -41,16 +70,64 @@ 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? @@ -64,12 +141,10 @@ final class NostrTransport: Transport, @unchecked Sendable { @MainActor init( - keychain: KeychainManagerProtocol, + keychain _: KeychainManagerProtocol, idBridge: NostrIdentityBridge, dependencies: Dependencies? = nil ) { - self.keychain = keychain - self.idBridge = idBridge self.dependencies = dependencies ?? .live(idBridge: idBridge) setupObservers() @@ -126,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 } @@ -158,7 +230,14 @@ final class NostrTransport: Transport, @unchecked Sendable { // 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] { [:] } @@ -187,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) { @@ -212,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)) } } @@ -232,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) @@ -290,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 091f4289..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,25 +117,30 @@ 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 } + registerCategories() authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in if granted { // Permission granted @@ -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/PrekeyBundleStore.swift b/bitchat/Services/Prekeys/PrekeyBundleStore.swift index fac9227c..83527ef9 100644 --- a/bitchat/Services/Prekeys/PrekeyBundleStore.swift +++ b/bitchat/Services/Prekeys/PrekeyBundleStore.swift @@ -22,6 +22,12 @@ import Foundation /// 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] diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index c979b073..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? @@ -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 10b4f2c6..7dbcf4ab 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -12,7 +12,7 @@ struct RelayController { static func decide(ttl: UInt8, senderIsSelf: Bool, recipientIsSelf: Bool = false, - isEncrypted: Bool, + isEncrypted _: Bool, isDirectedEncrypted: Bool, isFragment: Bool, isDirectedFragment: Bool, @@ -20,6 +20,7 @@ struct RelayController { isAnnounce: Bool, isRequestSync: Bool = false, isUrgentBoardPost: Bool = false, + isVoiceFrame: Bool = false, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) @@ -46,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 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 d6bdef33..c7094bc1 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -72,6 +72,9 @@ enum TransportEvent: @unchecked Sendable { /// 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]) @@ -93,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 @@ -114,6 +116,14 @@ protocol Transport: AnyObject { /// 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] @@ -155,6 +165,14 @@ 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 @@ -201,6 +219,35 @@ protocol Transport: AnyObject { // 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 { @@ -208,6 +255,10 @@ extension Transport { // 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 } @@ -231,6 +282,8 @@ extension Transport { 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. @@ -249,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 { @@ -273,6 +332,8 @@ extension BitchatDelegate { 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 07e11666..f8e4f8c3 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -16,6 +16,27 @@ 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)... @@ -71,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 @@ -81,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 @@ -115,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 @@ -152,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 @@ -188,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 @@ -242,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 @@ -258,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 @@ -313,6 +348,9 @@ enum TransportConfig { // 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, diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index a5d64621..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() } @@ -275,6 +275,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { 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 } @@ -367,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/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 3dee21e0..62f6394e 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -313,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) } @@ -652,6 +652,19 @@ final class GossipSyncManager { } } + /// 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) @@ -703,6 +716,23 @@ 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. diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 3b4b0c82..c2c96a1c 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -54,6 +54,9 @@ struct SyncTypeFlags: OptionSet { // 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 @@ -115,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/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 index a83cb024..6e146fa5 100644 --- a/bitchat/ViewModels/ChatGroupCoordinator.swift +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -336,7 +336,12 @@ final class ChatGroupCoordinator { 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 { - context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group")) + // 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 } @@ -387,7 +392,7 @@ final class ChatGroupCoordinator { /// 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) { + 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 { diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 1241f71b..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) @@ -215,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 { @@ -249,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) + } } } 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/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 8d8b6a9e..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,6 +68,10 @@ 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 @@ -112,6 +122,13 @@ final class ChatOutgoingCoordinator { 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 { @@ -133,6 +150,7 @@ private extension ChatOutgoingCoordinator { messageID: message.id, timestamp: message.timestamp ) + context.bridgeOutgoingPublicMessage(trimmed, senderPeerID: context.myPeerID, timestamp: message.timestamp) } /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index b3c0a199..d1c6accc 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:)`, @@ -350,22 +349,10 @@ final class ChatPeerIdentityCoordinator { return } - if let peer = context.unifiedPeer(for: peerID), - peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { - if !suppressSystemMessages { - 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) @@ -558,7 +545,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 e577e406..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,16 +83,18 @@ 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) // 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. @@ -162,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) } @@ -227,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 } @@ -263,11 +283,11 @@ final class ChatPrivateConversationCoordinator { 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( @@ -287,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 } @@ -357,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 } @@ -408,10 +427,15 @@ 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 } @@ -490,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) } } @@ -579,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) } @@ -798,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 c5f19c44..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 @@ -109,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:)`, @@ -292,6 +288,15 @@ 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. @@ -411,6 +416,24 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { /// 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 } @@ -446,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 @@ -520,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 34b1653c..1ec74850 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -72,6 +72,9 @@ protocol ChatTransportEventContext: AnyObject { 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) @@ -135,6 +138,10 @@ extension ChatViewModel: ChatTransportEventContext { 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) } @@ -397,6 +404,9 @@ private extension ChatTransportEventCoordinator { 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 8f7d484a..3b78797d 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -135,7 +135,6 @@ final class ChatVerificationCoordinator { let noiseKeyHex: String let signKeyHex: String let nonceA: Data - let startedAt: Date var sent: Bool } @@ -258,7 +257,6 @@ final class ChatVerificationCoordinator { noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, - startedAt: Date(), sent: false ) pendingQRVerifications[peerID] = pending diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 0dd2a5fa..11970626 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 @@ -178,6 +177,7 @@ 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) @@ -186,6 +186,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @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 @@ -218,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 @@ -241,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 { @@ -350,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) } @@ -422,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 @@ -720,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 @@ -833,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) @@ -928,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 @@ -955,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] { @@ -1052,14 +1049,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) } - // MARK: - Media Transfers - - private enum MediaSendError: Error { - case encodingFailed - case tooLarge - case copyFailed - } - func currentPublicSender() -> (name: String, peerID: PeerID) { publicConversationCoordinator.currentPublicSender() } @@ -1136,7 +1125,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } @MainActor - @objc func handlePeerStatusUpdate(_ notification: Notification) { + @objc func handlePeerStatusUpdate(_: Notification) { peerIdentityCoordinator.handlePeerStatusUpdate() } @@ -1170,15 +1159,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) @@ -1231,10 +1211,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // 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 @@ -1650,6 +1637,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } } + 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 { @@ -1802,6 +1800,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele /// 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) } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index caca90ec..1f67f3d5 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -41,7 +41,11 @@ struct ChatViewModelServiceBundle { 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() } } @@ -73,6 +77,8 @@ final class ChatViewModelBootstrapper { bindTransferProgress() configureGeoChannels() configureGateway() + configureBridge() + configureBridgeCourier() bindTeleportState() requestNotifications() registerObservers() @@ -174,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 } @@ -193,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) @@ -292,15 +360,31 @@ private extension ChatViewModelBootstrapper { bleService?.setLocalCapability(.gateway, enabled: enabled) } bleService.onNostrCarrierPacket = { payload, from, directedToUs in - GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) + // 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) @@ -311,6 +395,237 @@ private extension ChatViewModelBootstrapper { } } + /// 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/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index a63d6672..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,35 +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 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 939adf6a..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 @@ -54,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) @@ -103,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) @@ -129,62 +173,12 @@ extension ChatViewModel { @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 handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) { - privateConversationCoordinator.handleFavoriteNotification( - content, - from: peerID, - senderNickname: senderNickname - ) - } - @MainActor func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { privateConversationCoordinator.processActionMessage(message) @@ -195,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/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift index a0309447..1196c47f 100644 --- a/bitchat/ViewModels/MessageRateLimiter.swift +++ b/bitchat/ViewModels/MessageRateLimiter.swift @@ -79,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 be50a687..bc5ae744 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -305,7 +305,9 @@ final class NostrInboundPipeline { context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) // Group state travels only over mesh Noise sessions in v1; anything // claiming to be group traffic over Nostr is ignored. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: + // Live voice is mesh-only: latency and relay cost make it + // meaningless over Nostr. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: break } } @@ -359,7 +361,9 @@ final class NostrInboundPipeline { context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) // Group state travels only over mesh Noise sessions in v1; anything // claiming to be group traffic over Nostr is ignored. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: + // Live voice is mesh-only: latency and relay cost make it + // meaningless over Nostr. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: break } } @@ -440,7 +444,9 @@ final class NostrInboundPipeline { context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) // Group state travels only over mesh Noise sessions // in v1; group traffic over Nostr is ignored. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: + // Live voice is mesh-only: latency and relay cost make it + // meaningless over Nostr. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: break } } 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 3f74a354..646d6fde 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -1,31 +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( @@ -58,28 +113,49 @@ 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. - static let items: [(icon: String, text: LocalizedStringKey)] = [ - ("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"), - ("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"), - ("globe", "app_info.legend.nostr"), - ("person", "app_info.legend.offline"), - ("mappin.and.ellipse", "app_info.legend.location_nearby"), - ("face.dashed", "app_info.legend.teleported"), - ("lock.fill", "app_info.legend.encrypted"), - ("lock.slash", "app_info.legend.encryption_failed"), - ("checkmark.seal.fill", "app_info.legend.verified"), - ("star.fill", "app_info.legend.favorite"), - ("envelope.fill", "app_info.legend.unread"), - ("nosign", "app_info.legend.blocked") + /// 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( @@ -110,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) { @@ -137,8 +220,12 @@ struct AppInfoView: View { } .themedSurface(opacity: 0.95) - ScrollView { - infoContent + VStack(spacing: 0) { + panePicker + + ScrollView { + paneContent + } } .themedSheetBackground() } @@ -150,8 +237,12 @@ struct AppInfoView: View { } #else NavigationView { - ScrollView { - infoContent + VStack(spacing: 0) { + panePicker + + ScrollView { + paneContent + } } .themedSheetBackground() .navigationBarTitleDisplayMode(.inline) @@ -169,23 +260,35 @@ struct AppInfoView: View { } #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) @@ -210,17 +313,216 @@ 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) - } - } - .bitchatFont(size: 14) - .foregroundColor(textColor) + Text(verbatim: Strings.HowToUse.paragraph) + .bitchatFont(size: 14) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) } // Network diagnostics @@ -254,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) @@ -261,28 +565,6 @@ struct AppInfoView: View { FeatureRow(info: Strings.Features.mentions) } - // 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(textColor) - .frame(width: 30) - - Text(item.text) - .bitchatFont(size: 13) - .foregroundColor(secondaryTextColor) - .fixedSize(horizontal: false, vertical: true) - - Spacer() - } - .accessibilityElement(children: .combine) - } - } - // Privacy VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Privacy.title) @@ -293,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() } @@ -300,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) @@ -336,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() } } @@ -355,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/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/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 4c96b201..2e1009d7 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -50,6 +50,15 @@ struct TextMessageView: View { .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) @@ -122,9 +131,14 @@ struct TextMessageView: View { } // 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. + // 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 - showDeliveryDetail = false + if showDeliveryDetail { + showDeliveryDetail = false + } } } } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index a3ec8d0f..9913ce03 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -7,6 +7,7 @@ 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 @@ -89,6 +90,10 @@ struct ContentComposerView: View { } HStack(alignment: .center, spacing: 4) { + if showsNearbyOnlyToggle { + nearbyOnlyToggle + } + if conversationUIModel.canSendMediaInCurrentContext { attachmentButton } @@ -108,6 +113,41 @@ 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. @@ -137,16 +177,28 @@ private extension ContentComposerView { 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) { @@ -229,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( @@ -254,7 +324,13 @@ private extension ContentComposerView { .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") diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 9bac1095..96b548fb 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -6,6 +6,7 @@ struct ContentHeaderView: View { @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 @@ -21,19 +22,30 @@ struct ContentHeaderView: View { /// Courier envelopes this device is carrying for offline third parties. @State private var carriedMailCount = 0 - /// Unified notices sheet (board posts + location notes) for the current - /// channel context. - @State private var showNotices = false - /// 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() } @@ -55,6 +67,8 @@ struct ContentHeaderView: View { Text(verbatim: "@") .bitchatFont(size: 14) .foregroundColor(palette.secondary) + // Keep the sigil whole while the field beside it shrinks. + .fixedSize() TextField( "content.input.nickname_placeholder", @@ -91,21 +105,32 @@ 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 { - Image(systemName: "globe") - .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary.opacity(0.8)) - .headerTapTarget() - .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") - ) - .help( - String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator") - ) + // 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 { @@ -144,11 +169,11 @@ struct ContentHeaderView: View { scopes.insert(geoScope) } boardAlertsModel.markSeen(forScopes: scopes) - showNotices = true + appChromeModel.presentNotices() }) { - // Fill marks unseen new pins; the tint says the current - // scope has notices at all. - Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin") + // 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 @@ -249,11 +274,17 @@ struct ContentHeaderView: View { ) ) // Connected-vs-nobody is otherwise encoded only in the icon's - // color; say it. + // color; say it. With a live bridge, also say who's across it. .accessibilityValue( - 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") + 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) @@ -280,7 +311,10 @@ struct ContentHeaderView: View { .environmentObject(locationChannelsModel) .environmentObject(peerListModel) } - .sheet(isPresented: $showNotices) { + .sheet( + isPresented: $appChromeModel.isNoticesSheetPresented, + onDismiss: { appChromeModel.noticesSheetPrefersGeoTab = false } + ) { NoticesView( senderNickname: appChromeModel.nickname, board: appChromeModel.boardManager, @@ -321,8 +355,12 @@ private extension ContentHeaderView { } /// Open the notices sheet on the tab matching the current channel: the - /// geohash channel's notices, or the mesh-local board in mesh chat. + /// 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 } @@ -338,9 +376,12 @@ private extension ContentHeaderView { return locationChannelsModel.currentBuildingGeohash } - /// Whether either tab of the notices sheet currently has content. + /// 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. diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 4d9d5c5b..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 ) } } @@ -142,8 +139,6 @@ private struct ContentPeopleListView: View { @Binding var showSidebar: Bool - let headerHeight: CGFloat - @State private var showVerifySheet = false var body: some View { @@ -179,32 +174,13 @@ private struct ContentPeopleListView: View { } } - 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(palette.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(palette.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,12 +200,10 @@ private struct ContentPeopleListView: View { } ) } else { - GroupChatList( - groups: peerListModel.groupRows, - onTapGroup: { peerID in - peerListModel.startConversation(with: peerID) - showSidebar = true - } + PeopleSectionHeader( + icon: "antenna.radiowaves.left.and.right", + iconColor: palette.accentBlue, + title: "#mesh" ) MeshPeerList( onTapPeer: { peerID in @@ -247,9 +224,24 @@ private struct ContentPeopleListView: View { } } ) + // 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) } } @@ -265,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 @@ -393,6 +367,11 @@ 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() @@ -423,18 +402,19 @@ 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 @@ -542,10 +522,14 @@ private struct ContentPrivateHeaderInfoButton: View { .foregroundColor(.purple) .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) case .offline: - // Absence of a glyph was the only offline signal; say it. - Text("mesh_peers.state.offline") - .bitchatFont(size: 11) + // 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")) } } @@ -580,6 +564,7 @@ private struct ContentPrivateHeaderInfoButton: View { ) ) } + } } .buttonStyle(.plain) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index c4ec23be..36e6779a 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -77,6 +77,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 +152,11 @@ struct ContentView: View { #endif } .sheet(isPresented: $appChromeModel.isAppInfoPresented) { - AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() }) + AppInfoView( + topologyProvider: { appChromeModel.meshTopologyDisplayModel() }, + onPanicWipe: { appChromeModel.panicClearAllData() } + ) + .environmentObject(locationChannelsModel) } .sheet(isPresented: Binding( get: { appChromeModel.showingFingerprintFor != nil && !appChromeModel.showSidebar && selectedPrivatePeerID == nil }, diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index fb4541f5..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" @@ -45,9 +43,6 @@ struct FingerprintView: View { count ) } - static func unknownPeer() -> String { - String(localized: "common.unknown", comment: "Label for an unknown peer") - } } var body: some View { diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift index 8b62d8a2..4dff5c1d 100644 --- a/bitchat/Views/GroupChatList.swift +++ b/bitchat/Views/GroupChatList.swift @@ -21,20 +21,15 @@ struct GroupChatList: View { var body: some View { if !groups.isEmpty { VStack(alignment: .leading, spacing: 0) { - Text(Strings.header) - .bitchatFont(size: 11, weight: .medium) - .foregroundColor(palette.secondary) - .padding(.horizontal) - .padding(.top, 10) - .padding(.bottom, 2) - .accessibilityAddTraits(.isHeader) + // 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) { - Image(systemName: "person.3.fill") - .font(.bitchatSystem(size: 10)) - .foregroundColor(palette.primary) - Text("#\(group.name)") .bitchatFont(size: 14) .foregroundColor(palette.primary) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 0a6e077a..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,9 @@ 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 gatewayTitle: LocalizedStringKey = "location_channels.gateway.title" - static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.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") @@ -220,7 +212,7 @@ struct LocationChannelsSheet: View { } .padding(.vertical, 6) } - } else { + } else if locationChannelsModel.permissionState == .authorized { sectionDivider HStack(spacing: 8) { ProgressView() @@ -229,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 @@ -242,24 +243,6 @@ struct LocationChannelsSheet: View { .padding(.vertical, 8) } - if locationChannelsModel.permissionState == .authorized { - sectionDivider - torToggleSection - .padding(.top, 12) - gatewayToggleSection - .padding(.top, 8) - 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) @@ -401,7 +384,7 @@ struct LocationChannelsSheet: View { title: String, subtitlePrefix: String, subtitleName: String? = nil, - subtitleNameBold: Bool = false, + subtitleNameBold _: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, @@ -484,94 +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(palette.primary) - Text(Strings.torSubtitle) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - } - .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) - } - .padding(12) - .background(palette.secondary.opacity(0.12)) - .cornerRadius(8) - } - - private var gatewayToggleBinding: Binding { - Binding( - get: { locationChannelsModel.gatewayEnabled }, - set: { locationChannelsModel.setGatewayEnabled($0) } - ) - } - - private var gatewayToggleSection: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: gatewayToggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(Strings.gatewayTitle) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(palette.primary) - Text(Strings.gatewaySubtitle) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - } - .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) - } - .padding(12) - .background(palette.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/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 8e5d8bd2..01b5d8cb 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -65,58 +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( - 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) - } - } - } - + // 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") @@ -130,6 +91,7 @@ struct BlockRevealImageView: View { .accessibilityLabel(Strings.cancelSend) } } + .simultaneousGesture(hideSwipe) .onAppear { isBlurred = initiallyBlurred loadImage() @@ -138,7 +100,6 @@ struct BlockRevealImageView: View { isBlurred = initiallyBlurred loadImage() } - .gesture(mainGesture) .contextMenu { if isSending { cancelSendAction @@ -173,6 +134,60 @@ struct BlockRevealImageView: View { } } + @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 { @@ -235,18 +250,19 @@ struct BlockRevealImageView: View { // 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 var mainGesture: some Gesture { - let singleTap = TapGesture().onEnded { - guard !isSending, !loadFailed else { return } - if isBlurred { - withAnimation(.easeOut(duration: 0.2)) { - isBlurred = false - } - } else { - onOpen?() + 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 + } + + 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 @@ -257,7 +273,6 @@ struct BlockRevealImageView: View { } } } - return 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 90fd13c4..2b12ee24 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -33,8 +33,8 @@ 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 // Baseline alignment (via the header text inside the VStack) keeps the @@ -96,6 +96,7 @@ struct MediaMessageView: View { url: url, isSending: state.isSending, sendProgress: state.progress, + isLive: conversationUIModel.isLiveVoiceMessage(message), onCancel: cancelAction ) case .image(let url): @@ -120,13 +121,22 @@ struct MediaMessageView: View { .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. + // 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 - showDeliveryDetail = false + 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 { diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index d2a63e6e..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)) } @@ -69,9 +71,13 @@ struct VoiceNoteView: View { isInteractive: playback.isPlaying ) - Text(playbackLabel) - .bitchatFont(size: 13) - .foregroundColor(palette.secondary) + if isLive { + LiveVoiceBadge() + } else { + Text(playbackLabel) + .bitchatFont(size: 13) + .foregroundColor(palette.secondary) + } if let onCancel = onCancel, isSending { Button(action: onCancel) { diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index b09ef476..1d94af5f 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -44,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.. 0 { + notesHereStrip + } + GeometryReader { geometry in ScrollViewReader { proxy in ScrollView { if messageItems.isEmpty && privatePeer == nil { - publicEmptyState + publicEmptyState(fillHeight: geometry.size.height) } LazyVStack(alignment: .leading, spacing: 0) { ForEach(messageItems) { item in @@ -150,10 +164,23 @@ struct MessageListView: View { } .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 { @@ -246,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" { @@ -270,16 +303,77 @@ private extension MessageListView { 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. - var publicEmptyState: some View { + /// 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: - emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")) - emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet")) - emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) + MeshEmptyStateView(fillHeight: max(0, fillHeight - 24)) case .location(let channel): emptyStateLine( String( @@ -410,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 diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift index 692158f1..51edd184 100644 --- a/bitchat/Views/NoticesView.swift +++ b/bitchat/Views/NoticesView.swift @@ -31,10 +31,27 @@ struct NoticesView: View { @State private var tab: Tab @State private var draft: String = "" @State private var urgent = false - @State private var expiryDays = 7 + /// 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, @@ -46,6 +63,144 @@ struct NoticesView: View { 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 } @@ -56,6 +211,9 @@ struct NoticesView: View { if case .location(let channel) = locationChannelsModel.selectedChannel { return channel.geohash } + guard locationChannelsModel.permissionState == .authorized else { + return nil + } return locationChannelsModel.currentBuildingGeohash } @@ -67,10 +225,11 @@ struct NoticesView: View { } private var activeGeohash: String? { - switch tab { - case .geo: return geoGeohash - case .mesh: return "" - } + Self.composerGeohash( + tab: tab, + notesEnabled: locationNotesEnabled, + geoGeohash: geoGeohash + ) } enum Strings { @@ -90,12 +249,16 @@ struct NoticesView: View { 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" @@ -109,6 +272,16 @@ struct NoticesView: View { ) } + 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"), @@ -132,29 +305,47 @@ struct NoticesView: View { .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) #endif .themedSheetBackground() - .onAppear { beginGeoLocationIfNeeded() } + .onAppear { + reconcileGeoSession() + } .onChange(of: tab) { newTab in if newTab == .geo { - beginGeoLocationIfNeeded() - } else { - locationChannelsModel.endLiveRefresh() + 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 permission granted from the geo tab's enable button. + // Catches both grant and revocation. Revocation must balance the live + // refresh and release a device-derived building REQ immediately. .onChange(of: locationChannelsModel.permissionState) { _ in - beginGeoLocationIfNeeded() + 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 } - .onDisappear { locationChannelsModel.endLiveRefresh() } - } - - /// The geo tab tracks the device's building geohash while on mesh; keep - /// location fresh only in that case (a selected location channel already - /// fixes the scope). - private func beginGeoLocationIfNeeded() { - guard tab == .geo, geoTabNeedsDeviceLocation, - locationChannelsModel.permissionState == .authorized else { return } - locationChannelsModel.enableLocationChannels() - locationChannelsModel.beginLiveRefresh() } private var headerSection: some View { @@ -206,14 +397,56 @@ struct NoticesView: View { notesManager: nil ) case .geo: - if let geohash = geoGeohash { - GeoNoticesList(geohash: geohash, board: board, manager: notesManager) - } else { + 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) { @@ -252,11 +485,10 @@ struct NoticesView: View { .disabled(!sendEnabled) .accessibilityLabel(Strings.send) } - // Urgency and expiry only travel with the mesh copy — the bridged - // Nostr note carries neither, so relay-side readers would never - // see them. Offer the controls only where they fully apply. - if tab == .mesh { - HStack(spacing: 12) { + // 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) @@ -265,19 +497,30 @@ struct NoticesView: View { .toggleStyle(.switch) .fixedSize() .accessibilityLabel(Strings.urgentToggle) - Spacer() - Text(Strings.expiryLabel) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Picker(Strings.expiryLabel, selection: $expiryDays) { - ForEach([1, 3, 7], id: \.self) { days in - Text(Strings.expiryDaysOption(days)).tag(days) - } - } - .pickerStyle(.segmented) - .fixedSize() - .accessibilityLabel(Strings.expiryLabel) } + 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) @@ -293,14 +536,27 @@ struct NoticesView: View { private func send() { guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return } - // Geo posts go to the board and are bridged to Nostr by BoardManager, - // so mesh and internet see the same notice. They always use the - // defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy). + + // ∞ (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: tab == .mesh ? expiryDays : 7, + expiryDays: expiryDays, nickname: senderNickname ) if sent { @@ -310,18 +566,19 @@ struct NoticesView: View { } } -/// The geo tab's list: owns the Nostr notes subscription for the scope -/// geohash and merges it with the board posts for the same geohash. +/// 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 - @StateObject private var notesManager: LocationNotesManager + @ObservedObject var notesManager: LocationNotesManager - init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) { - let gh = geohash.lowercased() - self.geohash = gh + init(geohash: String, board: BoardManager, manager: LocationNotesManager) { + self.geohash = geohash.lowercased() self.board = board - _notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) + self.notesManager = manager } var body: some View { @@ -334,10 +591,6 @@ private struct GeoNoticesList: View { board: board, notesManager: notesManager ) - .onChange(of: geohash) { newValue in - notesManager.setGeohash(newValue) - } - .onDisappear { notesManager.cancel() } } } @@ -394,7 +647,9 @@ private struct NoticesList: View { /// once the sources settled. private var showEmptyState: Bool { guard let notesManager else { return true } - return notesManager.initialLoadComplete && notesManager.state != .loading + return notesManager.initialLoadComplete + && notesManager.state != .loading + && notesManager.state != .connecting } @ViewBuilder @@ -409,6 +664,15 @@ private struct NoticesList: View { 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) @@ -475,6 +739,11 @@ private struct NoticesList: View { 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) @@ -530,19 +799,18 @@ private struct NoticesList: View { 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 { - let rel = relativeFormatter.string(from: date, to: now) ?? "" - return rel.isEmpty ? "" : "\(rel) ago" + // 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: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() f.unitsStyle = .abbreviated - f.collapsesLargestUnit = true return f }() diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 1fba4f2d..94aaf211 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -288,7 +288,6 @@ 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 { palette.secondary.opacity(0.1) } 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 bd83b678..6867d1d4 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -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/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 6a9cdfc1..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() @@ -252,7 +637,7 @@ struct BLEServiceCoreTests { let reachedBudget = await TestHelpers.waitUntil( { outbound.count(ofType: .pong) >= budget }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.longTimeout ) #expect(reachedBudget) // Give any over-budget pong a chance to surface, then confirm the diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 88596f16..1ed083a0 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -106,8 +106,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { routedPrivateMessages.append((content, peerID, recipientNickname)) } - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + var routeReadReceiptResult = true + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { routedReadReceipts.append((receipt.originalMessageID, peerID)) + return routeReadReceiptResult } func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { @@ -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) @@ -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.. FavoritesPersistenceService.FavoriteRelationship? { @@ -226,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)) } @@ -250,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 diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift index 25aa8300..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? diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index 17061343..675aa1f7 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 @@ -323,10 +322,10 @@ struct ChatPeerIdentityCoordinatorContextTests { } @Test @MainActor - func startPrivateChat_suppressed_oneWayFavoriteEmitsNoSystemMessage() async { - // #1064: a one-way favorite (we favorite them, they don't favorite us, - // not connected) trips the mutual-favorite gate. Under suppression the - // reject must emit no "requires favorite" system message and open no chat. + func startPrivateChat_suppressed_oneWayFavoriteOpensChatSilently() async { + // #1064 × #1415: the mutual-favorite gate is gone (store-and-forward + // handles offline non-mutual favorites), so a one-way favorite now + // opens the chat — and under suppression still emits no system message. let context = MockChatPeerIdentityContext() let coordinator = ChatPeerIdentityCoordinator(context: context) let peerID = PeerID(str: "1122334455667788") @@ -342,8 +341,8 @@ struct ChatPeerIdentityCoordinatorContextTests { coordinator.startPrivateChat(with: peerID, suppressSystemMessages: true) #expect(context.systemMessages.isEmpty) - #expect(context.begunChatSessions.isEmpty) - #expect(context.consolidatedPeers.isEmpty) + #expect(context.begunChatSessions == [peerID]) + #expect(context.consolidatedPeers.map(\.peerID) == [peerID]) } @Test @MainActor diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index e9c7549c..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 diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 9e96cabe..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?)] = [] @@ -238,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), @@ -666,7 +654,6 @@ 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 — @@ -692,22 +679,22 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.routedPrivateMessages.map(\.content) == ["hello again"]) #expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent) #expect(context.privateChats[shortID]?.first?.recipientNickname == "bob") - #expect(context.systemMessages.isEmpty) } + /// 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_failsWhenOfflineWithoutMutualFavorite() async { + 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 be76fe82..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 } diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index b0d39782..88d118f3 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -174,6 +174,13 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { 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 diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 6034c890..326ab453 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -268,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 7d68a3dc..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() diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 031247bf..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 @@ -762,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 @@ -780,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" @@ -793,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 diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 5a50f0ac..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() @@ -754,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/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 3da82140..e2d9d561 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -675,10 +675,6 @@ private final class MockCommandContextProvider: CommandContextProvider { 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)] = [] diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index 23829b0e..ea90b6a7 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -66,6 +66,52 @@ struct CourierStoreTests { #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) @@ -174,6 +220,46 @@ struct CourierStoreTests { #expect(second.takeEnvelopes(for: recipientKey) == [envelope]) } + @Test func protectedDataReadFailureDoesNotOverwriteDurableMailAndMergesOnRecovery() { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("courier-protected-data-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("envelopes.json") + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let durableRecipient = Data(repeating: 0xE1, count: 32) + let wakeRecipient = Data(repeating: 0xE2, count: 32) + let durableEnvelope = makeEnvelope(recipientKey: durableRecipient) + let wakeEnvelope = makeEnvelope(recipientKey: wakeRecipient) + let seed = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) + #expect(seed.deposit(durableEnvelope, from: depositorA)) + let durableBytes = try? Data(contentsOf: fileURL) + + var protectedDataUnavailable = true + let restored = CourierStore( + persistsToDisk: true, + fileURL: fileURL, + now: { Self.baseDate }, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + #expect(restored.deposit(wakeEnvelope, from: depositorB)) + + // The locked wake accepted new work in memory but did not replace the + // unreadable file with that partial view. + #expect((try? Data(contentsOf: fileURL)) == durableBytes) + + protectedDataUnavailable = false + restored.retryDeferredPersistence() + + let afterUnlock = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) + #expect(afterUnlock.takeEnvelopes(for: durableRecipient) == [durableEnvelope]) + #expect(afterUnlock.takeEnvelopes(for: wakeRecipient) == [wakeEnvelope]) + } + // MARK: - Tiers (open couriering) @Test func verifiedTierGetsSmallerPerDepositorQuota() { @@ -282,6 +368,29 @@ struct CourierStoreTests { #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)) @@ -300,6 +409,28 @@ struct CourierStoreTests { #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() { diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index 22e56c79..ac68c145 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -96,6 +96,20 @@ struct CourierEndToEndTests { 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 { @@ -141,7 +155,9 @@ struct CourierEndToEndTests { ) #expect(carried) - // 3. Later, Bob announces near Carol → handover fires. + // 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 }, @@ -156,7 +172,10 @@ struct CourierEndToEndTests { timeout: TestConstants.defaultTimeout ) #expect(handedOver) - #expect(carol.courierStore.isEmpty) + // 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) @@ -222,6 +241,7 @@ struct CourierEndToEndTests { ) #expect(carried) + try establishNoiseSession(between: carol, and: bob) bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, @@ -309,7 +329,7 @@ struct CourierEndToEndTests { timeout: TestConstants.defaultTimeout ) #expect(handedOver) - #expect(carol.courierStore.isEmpty) + #expect(!carol.courierStore.isEmpty) } @Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws { @@ -395,7 +415,11 @@ struct CourierEndToEndTests { #expect(!refloodedInCooldown) #expect(!carol.courierStore.isEmpty) - // A later *direct* announce still performs the destructive handover. + // 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( @@ -408,12 +432,12 @@ struct CourierEndToEndTests { ) carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false) - let handedOver = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) == 2 }, - timeout: TestConstants.defaultTimeout + let handedOverWithoutLinkProof = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 1 }, + timeout: TestConstants.shortTimeout ) - #expect(handedOver) - #expect(carol.courierStore.isEmpty) + #expect(!handedOverWithoutLinkProof) + #expect(!carol.courierStore.isEmpty) } @Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws { @@ -556,6 +580,70 @@ struct CourierEndToEndTests { #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", @@ -590,9 +678,6 @@ private final class CourierCaptureTransport: Transport { private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = [] private(set) var directSends: [String] = [] - var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { - Just(snapshots).eraseToAnyPublisher() - } func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots } var myPeerID = PeerID(str: "00000000000000aa") @@ -675,6 +760,50 @@ struct MessageRouterCourierTests { #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() diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift index 456932a7..79bd28b5 100644 --- a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift @@ -190,7 +190,11 @@ struct PrekeyEndToEndTests { #expect(message.content == "burn after reading") // 6. Redelivery tolerance: the same envelope arriving via another - // packet (spray-and-wait) still opens inside the grace window. + // 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(), @@ -203,9 +207,10 @@ struct PrekeyEndToEndTests { bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID) let redelivered = await TestHelpers.waitUntil( { bobDelegate.snapshot().count == 2 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.shortTimeout ) - #expect(redelivered) + #expect(!redelivered) + #expect(bobDelegate.snapshot().count == 1) } @Test func withoutBundleSealingFallsBackToStatic() async throws { 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/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 de1fa209..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 diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index d7b7b7ef..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) { 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/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 1ff1926a..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,9 +86,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { } func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} - - func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} - + func clearAllIdentityData() {} func removeEphemeralSession(peerID: PeerID) {} @@ -139,10 +124,6 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { !(vouchesByVouchee[fingerprint] ?? []).isEmpty } - func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { - socialIdentities[fingerprint]?.trustLevel ?? .unknown - } - func lastVouchBatchSent(to fingerprint: String) -> Date? { vouchBatchSentAt[fingerprint] } 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 5f1243e8..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) @@ -48,11 +45,16 @@ final class MockTransport: Transport { 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] = [:] @@ -90,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] } @@ -110,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 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/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index 5d1a3129..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] 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/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 d496529c..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) { @@ -610,7 +615,6 @@ private final class PerfNostrContext: ChatNostrContext { func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> 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) {} @@ -668,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) {} } @@ -779,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 @@ -790,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/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index e7097121..d295ff4e 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -23,10 +23,6 @@ private final class DefaultTransportProbe: Transport { var myNickname = "Tester" private(set) var sentMessages: [(content: String, mentions: [String])] = [] - var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { - subject.eraseToAnyPublisher() - } - func currentPeerSnapshots() -> [TransportPeerSnapshot] { subject.value } func setNickname(_ nickname: String) { myNickname = nickname } func startServices() {} @@ -107,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/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/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 d53a50b3..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() @@ -334,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) 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 deec44f5..5832b676 100644 --- a/bitchatTests/Services/BLEFanoutSelectorTests.swift +++ b/bitchatTests/Services/BLEFanoutSelectorTests.swift @@ -192,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/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 75550bc5..40532d19 100644 --- a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift +++ b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift @@ -88,21 +88,46 @@ 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 result = BLEIngressLinkRegistry.packetContext( + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( for: packet, claimedSenderID: claimedPeer, boundPeerID: boundPeer, localPeerID: localPeer, directAnnounceTTL: 7 - ) + ))) - #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer))) + #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 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 74a4441d..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 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/BridgeCourierServiceTests.swift b/bitchatTests/Services/BridgeCourierServiceTests.swift new file mode 100644 index 00000000..6464c12b --- /dev/null +++ b/bitchatTests/Services/BridgeCourierServiceTests.swift @@ -0,0 +1,747 @@ +// +// BridgeCourierServiceTests.swift +// bitchat +// +// 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 + +@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 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/LocationStateManagerTests.swift b/bitchatTests/Services/LocationStateManagerTests.swift index c7a950f0..69515f76 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( @@ -167,7 +207,54 @@ final class LocationStateManagerTests: XCTestCase { XCTAssertFalse(reloaded.teleported) } - func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async { + func test_loadPersistedState_migratesLowPrecisionBookmarkNamesOnce() throws { + let storage = makeStorage() + let staleNames = [ + "gc": "England", + "u3": "Île-de-France", + "u4pr": "Paris", + "u4pruy": "Le Marais" + ] + storage.set(try JSONEncoder().encode(staleNames), forKey: "locationChannel.bookmarkNames") + + let migrated = LocationStateManager( + storage: storage, + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: false + ) + + XCTAssertNil(migrated.bookmarkNames["gc"]) + XCTAssertNil(migrated.bookmarkNames["u3"]) + XCTAssertEqual(migrated.bookmarkNames["u4pr"], "Paris") + XCTAssertEqual(migrated.bookmarkNames["u4pruy"], "Le Marais") + XCTAssertEqual(storage.integer(forKey: "locationChannel.bookmarkNamesSchemaVersion"), 1) + + let persistedData = try XCTUnwrap(storage.data(forKey: "locationChannel.bookmarkNames")) + let persistedNames = try JSONDecoder().decode([String: String].self, from: persistedData) + XCTAssertEqual(persistedNames, [ + "u4pr": "Paris", + "u4pruy": "Le Marais" + ]) + + let correctedNames = [ + "gc": "United Kingdom", + "u4pr": "Paris" + ] + storage.set(try JSONEncoder().encode(correctedNames), forKey: "locationChannel.bookmarkNames") + + let reloaded = LocationStateManager( + storage: storage, + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: false + ) + + XCTAssertEqual(reloaded.bookmarkNames["gc"], "United Kingdom") + XCTAssertEqual(reloaded.bookmarkNames["u4pr"], "Paris") + } + + func test_addBookmark_lowPrecisionPrefersCountryOverAdministrativeAreas() async { let geocoder = MockLocationGeocoder() geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")]) @@ -183,12 +270,50 @@ final class LocationStateManagerTests: XCTestCase { manager.addBookmark("9q") - let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" } + let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "United States" } XCTAssertTrue(bookmarkResolved) XCTAssertEqual(geocoder.reverseRequests.count, 5) XCTAssertEqual(manager.bookmarks, ["9q"]) } + func test_addBookmark_lowPrecisionFallsBackToDistinctAdministrativeAreas() async { + let geocoder = MockLocationGeocoder() + geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")]) + geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")]) + geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")]) + geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Arizona")]) + geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")]) + let manager = LocationStateManager( + storage: makeStorage(), + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: geocoder, + shouldInitializeCoreLocation: false + ) + + manager.addBookmark("9q") + + let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" } + XCTAssertTrue(bookmarkResolved) + XCTAssertEqual(geocoder.reverseRequests.count, 5) + } + + func test_addBookmark_higherPrecisionStillPrefersAdministrativeArea() async { + let geocoder = MockLocationGeocoder() + geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) + let manager = LocationStateManager( + storage: makeStorage(), + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: geocoder, + shouldInitializeCoreLocation: false + ) + + manager.addBookmark("9q8") + + let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q8"] == "California" } + XCTAssertTrue(bookmarkResolved) + XCTAssertEqual(geocoder.reverseRequests.count, 1) + } + private func makeStorage() -> UserDefaults { let suiteName = "LocationStateManagerTests-\(UUID().uuidString)" let storage = UserDefaults(suiteName: suiteName)! 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/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift index 218f6cf0..c54b9f99 100644 --- a/bitchatTests/Services/MessageOutboxStoreTests.swift +++ b/bitchatTests/Services/MessageOutboxStoreTests.swift @@ -68,6 +68,145 @@ struct MessageOutboxStoreTests { #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() @@ -89,4 +228,69 @@ struct MessageOutboxStoreTests { 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 78a9caec..6c9d4ea1 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -316,6 +316,108 @@ struct MessageRouterTests { #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") @@ -344,6 +446,34 @@ struct MessageRouterTests { #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") @@ -363,6 +493,40 @@ struct MessageRouterTests { #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 @@ -417,6 +581,295 @@ struct MessageRouterTests { 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 01d8fdca..72d9f6be 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -120,7 +120,6 @@ final class NetworkActivationServiceTests: XCTestCase { return NetworkActivationTestContext( service: service, storage: storage, - permissionSubject: permissionSubject, favoritesSubject: favoritesSubject, torController: torController, relayController: relayController, @@ -148,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 index 420a54db..fee973aa 100644 --- a/bitchatTests/Services/NetworkReachabilityGateTests.swift +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -57,6 +57,39 @@ final class NetworkReachabilityGateTests: XCTestCase { 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() { 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/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 7c04fab6..cb18ec64 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -134,6 +134,46 @@ 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( 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/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index 172bf5b1..a81003ec 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -53,17 +53,22 @@ struct UnifiedPeerServiceTests { let fingerprint = "fp-target" transport.peerFingerprints[peerID] = fingerprint - // Blocking resolves and persists by the peer's 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. + // 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) @@ -214,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) } @@ -266,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() @@ -308,10 +299,6 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { false } - func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { - verified.contains(fingerprint) ? .verified : .unknown - } - func lastVouchBatchSent(to fingerprint: String) -> Date? { nil } diff --git a/bitchatTests/TestUtilities/TestConstants.swift b/bitchatTests/TestUtilities/TestConstants.swift index b64e6378..83f5b46b 100644 --- a/bitchatTests/TestUtilities/TestConstants.swift +++ b/bitchatTests/TestUtilities/TestConstants.swift @@ -25,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 d92f0474..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( @@ -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 debb41e5..6c104e53 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -415,7 +415,7 @@ struct ViewSmokeTests { let board = BoardManager( transport: transport, store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }), - publishToNostr: { _, _, _, _ in nil }, + publishToNostr: { _, _, _, _, _ in nil }, deleteFromNostr: { _, _ in } ) @@ -498,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 @@ -644,7 +646,7 @@ struct ViewSmokeTests { 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: waveformProbeURL)?.count == 16) 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/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/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/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/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 ca29a2b2..db286ae3 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift @@ -7,7 +7,6 @@ // 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, @@ -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/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/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 4ddf5ab1..0ba2730f 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -36,6 +36,10 @@ public enum MessageType: UInt8 { // 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" @@ -53,6 +57,7 @@ public enum MessageType: UInt8 { 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 index 94858d9b..167f9884 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -20,6 +20,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { 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. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift index f22fac18..a9a18973 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift @@ -37,6 +37,10 @@ public struct PeerID: Equatable, Hashable, Sendable { /// `"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 @@ -67,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) }) { @@ -167,6 +177,11 @@ public extension PeerID { 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/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/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/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 d2405b10..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 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/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)") +}