mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733098bb63 | ||
|
|
820c933958 | ||
|
|
b205a55523 | ||
|
|
f8ab0a7dc0 | ||
|
|
304460ee83 | ||
|
|
b7c6f42b3a | ||
|
|
c8479c15e3 | ||
|
|
84d315e62d | ||
|
|
dd6b624cae |
@@ -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
|
||||
|
||||
|
||||
@@ -1 +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:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||
{"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"]}}
|
||||
@@ -10,5 +10,12 @@ 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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||
excluded:
|
||||
- .build
|
||||
- .claude
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.7.0
|
||||
MARKETING_VERSION = 1.7.1
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
+107
-117
@@ -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.*
|
||||
|
||||
Generated
+13
-1
@@ -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 */
|
||||
|
||||
@@ -313,21 +313,29 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
||||
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||
let clearSharedContent = {
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
}
|
||||
|
||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
// A partial or malformed handoff must not linger in the shared
|
||||
// app-group container indefinitely.
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
clearSharedContent()
|
||||
|
||||
switch contentKind {
|
||||
case .url:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,16 +17,52 @@ 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) {
|
||||
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
|
||||
@@ -38,6 +74,13 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
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
|
||||
@@ -51,33 +94,39 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
activeHolders = max(0, activeHolders - 1)
|
||||
guard activeHolders == 0 else { return }
|
||||
channelsCancellable = nil
|
||||
permissionCancellable = nil
|
||||
settingCancellable = nil
|
||||
managerCancellable = nil
|
||||
manager?.cancel()
|
||||
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
|
||||
manager?.cancel()
|
||||
releaseManager(manager)
|
||||
manager = nil
|
||||
noteCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
if let manager {
|
||||
manager.setGeohash(geohash)
|
||||
return
|
||||
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 = LocationNotesManager(geohash: geohash)
|
||||
let fresh = managerFactory(geohash)
|
||||
manager = fresh
|
||||
managerCancellable = fresh.$notes
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//
|
||||
// AudioSessionCoordinator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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<T: Sendable>(_ 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
|
||||
@@ -6,10 +6,142 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
@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`;
|
||||
@@ -17,27 +149,77 @@ import Foundation
|
||||
/// 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 {
|
||||
private let engine = AVAudioEngine()
|
||||
private let node = AVAudioPlayerNode()
|
||||
/// 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 var scheduledCount = 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
|
||||
private var stopped = 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<Void, Never>?
|
||||
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
|
||||
|
||||
init?() {
|
||||
/// 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
|
||||
engine.attach(node)
|
||||
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||
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))
|
||||
@@ -45,6 +227,21 @@ final class PTTBurstPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
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]) {
|
||||
@@ -64,49 +261,119 @@ final class PTTBurstPlayer {
|
||||
/// 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, teardown).
|
||||
/// Immediate stop (cancel, another playback taking over, interruption,
|
||||
/// teardown).
|
||||
func stop() {
|
||||
guard !stopped else { return }
|
||||
stopped = true
|
||||
deadlineTask?.cancel()
|
||||
removeConfigObserver()
|
||||
queuedBuffers = []
|
||||
retireScheduledBuffers()
|
||||
if engineStarted {
|
||||
node.stop()
|
||||
engine.stop()
|
||||
}
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
releaseSessionToken()
|
||||
exclusivity.deactivate(self)
|
||||
onStopped?()
|
||||
}
|
||||
|
||||
private func startIfReady(force: Bool) {
|
||||
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
|
||||
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
|
||||
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||
|
||||
#if os(iOS)
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||
// 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()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
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 {
|
||||
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||
stopped = true
|
||||
return
|
||||
// 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
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
node.play()
|
||||
engine.play()
|
||||
|
||||
let buffered = queuedBuffers
|
||||
queuedBuffers = []
|
||||
@@ -116,21 +383,119 @@ final class PTTBurstPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||
scheduledCount += 1
|
||||
node.scheduleBuffer(buffer) { [weak self] in
|
||||
/// 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
|
||||
guard let self else { return }
|
||||
self.scheduledCount -= 1
|
||||
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, scheduledCount <= 0 else { return }
|
||||
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 {
|
||||
|
||||
@@ -10,12 +10,85 @@ 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.
|
||||
final class PTTCaptureEngine {
|
||||
/// `@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
|
||||
@@ -26,6 +99,9 @@ final class PTTCaptureEngine {
|
||||
/// 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?
|
||||
@@ -35,10 +111,10 @@ final class PTTCaptureEngine {
|
||||
private var encodedFrameCount = 0
|
||||
private var running = false
|
||||
private var captureStart = Date()
|
||||
/// Whether `engine.start()` succeeded for the current capture (main-actor
|
||||
/// callers only; see `stopEngineIfStarted`).
|
||||
private var engineStarted = false
|
||||
|
||||
/// 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)?
|
||||
|
||||
@@ -47,11 +123,41 @@ final class PTTCaptureEngine {
|
||||
case audioSetupFailed
|
||||
}
|
||||
|
||||
func start(outputURL: URL) throws {
|
||||
#if os(iOS)
|
||||
try Self.configureAudioSession()
|
||||
#endif
|
||||
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()
|
||||
@@ -83,13 +189,30 @@ final class PTTCaptureEngine {
|
||||
}
|
||||
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||
self?.queue.async { self?.process(buffer) }
|
||||
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
|
||||
@@ -100,7 +223,9 @@ final class PTTCaptureEngine {
|
||||
|
||||
/// 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
|
||||
@@ -108,34 +233,70 @@ final class PTTCaptureEngine {
|
||||
teardown(deleteFile: false)
|
||||
return (url, frames)
|
||||
}
|
||||
#if os(iOS)
|
||||
Self.deactivateAudioSession()
|
||||
#endif
|
||||
releaseSessionToken()
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancel() {
|
||||
captureGeneration.invalidate()
|
||||
stopEngineIfStarted()
|
||||
queue.sync { teardown(deleteFile: true) }
|
||||
#if os(iOS)
|
||||
Self.deactivateAudioSession()
|
||||
#endif
|
||||
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) {
|
||||
guard running,
|
||||
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
|
||||
guard captureGeneration.isCurrent(generation),
|
||||
running,
|
||||
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
||||
let resampled = resampler?.resample(buffer)
|
||||
else { return }
|
||||
@@ -162,22 +323,4 @@ final class PTTCaptureEngine {
|
||||
}
|
||||
fileURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Audio session (iOS)
|
||||
|
||||
#if os(iOS)
|
||||
private static func configureAudioSession() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
#if targetEnvironment(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)
|
||||
}
|
||||
|
||||
private static func deactivateAudioSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -31,25 +31,45 @@ protocol VoiceCaptureSession: AnyObject {
|
||||
/// 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 VoiceRecorder.shared.requestPermission()
|
||||
await recorder.requestPermission()
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await VoiceRecorder.shared.startRecording()
|
||||
try await recorder.startRecording(owner: owner)
|
||||
}
|
||||
|
||||
func finish() async -> URL? {
|
||||
await VoiceRecorder.shared.stopRecording()
|
||||
await recorder.stopRecording(owner: owner)
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
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_<burstID>.m4a`) so receivers that
|
||||
@@ -60,7 +80,8 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
let burstID: Data
|
||||
|
||||
private let sendPacket: (Data) -> Void
|
||||
private let capture = PTTCaptureEngine()
|
||||
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 {
|
||||
@@ -79,10 +100,17 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
/// - 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) {
|
||||
self.burstID = VoiceBurstPacket.makeBurstID()
|
||||
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.stream = StreamState(burstID: burstID)
|
||||
self.capture = capture ?? PTTCaptureEngine()
|
||||
self.now = now
|
||||
self.stream = StreamState(burstID: self.burstID)
|
||||
}
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
@@ -117,16 +145,31 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
}
|
||||
}
|
||||
do {
|
||||
try capture.start(outputURL: outputURL)
|
||||
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)
|
||||
try capture.start(outputURL: outputURL)
|
||||
// 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 = Date()
|
||||
startDate = now()
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||
}
|
||||
|
||||
@@ -134,11 +177,16 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
guard !completed else { return nil }
|
||||
completed = true
|
||||
|
||||
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
|
||||
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
|
||||
let (url, encodedFrames) = capture.stop()
|
||||
// stop() drained the capture queue, so touching `stream` is safe now.
|
||||
|
||||
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
|
||||
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)
|
||||
@@ -149,17 +197,23 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
for packet in stream.packetizer.flush() {
|
||||
sendPacket(packet)
|
||||
}
|
||||
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
|
||||
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 {
|
||||
guard !completed else { return }
|
||||
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()
|
||||
sendControlPacket(.canceled)
|
||||
if !alreadyCompleted {
|
||||
sendControlPacket(.canceled)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||
|
||||
@@ -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
|
||||
@@ -197,21 +299,59 @@ extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
struct Reservation: Equatable {
|
||||
fileprivate let generation: UInt64
|
||||
}
|
||||
|
||||
private weak var activeController: (any ExclusivePlayback)?
|
||||
private weak var latestReservedController: (any ExclusivePlayback)?
|
||||
private var latestReservation = Reservation(generation: 0)
|
||||
|
||||
private init() {}
|
||||
/// Internal so tests can isolate their own exclusivity slot; the app
|
||||
/// uses `shared`.
|
||||
init() {}
|
||||
|
||||
func activate(_ controller: any ExclusivePlayback) {
|
||||
/// 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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +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 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
|
||||
|
||||
@@ -41,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)
|
||||
}
|
||||
@@ -124,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)
|
||||
}
|
||||
@@ -146,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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -31,6 +31,8 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
@@ -40,9 +42,9 @@
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<string>bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<string>bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
@@ -1,6 +1,186 @@
|
||||
{
|
||||
"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",
|
||||
@@ -31415,6 +31595,186 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.empty.check_notes" : {
|
||||
"comment" : "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "تحقّق من الملاحظات المتروكة هنا"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "এখানে রাখা নোট আছে কি না দেখুন"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nachsehen, ob hier notizen hinterlassen wurden"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "check for notes left here"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "buscar notas dejadas aquí"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "tingnan kung may mga note na naiwan dito"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "vérifier s'il y a des notes laissées ici"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "בדיקה אם הושארו כאן פתקים"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "देखें कि यहाँ नोट छोड़े गए हैं या नहीं"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "periksa catatan yang ditinggalkan di sini"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "controlla se ci sono note lasciate qui"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ここに残されたメモを確認"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "여기 남겨진 쪽지 확인"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "semak nota yang ditinggalkan di sini"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "kijk of hier notities zijn achtergelaten"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "sprawdź, czy zostawiono tutaj notatki"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ver se há notas deixadas aqui"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ver se há notas deixadas aqui"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "проверить, есть ли здесь заметки"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "kolla om anteckningar lämnats här"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "buraya bırakılan notlara bak"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "перевірити, чи залишено тут нотатки"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "kiểm tra ghi chú để lại ở đây"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "查看这里留下的留言"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "查看這裡留下的留言"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.empty.location_intro" : {
|
||||
"comment" : "First line of an empty geohash timeline naming the channel",
|
||||
"extractionState" : "manual",
|
||||
|
||||
@@ -264,22 +264,32 @@ struct NostrProtocol {
|
||||
|
||||
/// 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` carries the original
|
||||
/// mesh message ID so receivers dedup the bridged copy against the radio
|
||||
/// copy by timeline ID.
|
||||
/// 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,
|
||||
meshMessageID: 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 meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
|
||||
tags.append(["m", meshMessageID])
|
||||
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,
|
||||
@@ -325,7 +335,7 @@ struct NostrProtocol {
|
||||
) throws -> NostrEvent {
|
||||
let tags = [
|
||||
["x", recipientTagHex],
|
||||
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
|
||||
["expiration", String(Int(expiresAt.timeIntervalSince1970))]
|
||||
]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
|
||||
@@ -216,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<String>
|
||||
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
|
||||
@@ -312,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
|
||||
@@ -345,6 +357,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||
inboundEventLogCount = 0
|
||||
Self.pendingGiftWrapIDs.removeAll()
|
||||
confirmedSends.removeAll()
|
||||
|
||||
messageQueueLock.lock()
|
||||
messageQueue.removeAll()
|
||||
@@ -419,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<String>) {
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
||||
@@ -923,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)
|
||||
@@ -932,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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -990,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1055,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)
|
||||
@@ -1071,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 {
|
||||
@@ -1084,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,9 +1278,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
<string>3B52.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// MeshMessageIdentity.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,10 @@ enum BLEFanoutSelector {
|
||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||
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<PeerID>()
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,7 +164,11 @@ final class BLELinkStateStore {
|
||||
guard let peerID else { return [] }
|
||||
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
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
|
||||
@@ -221,8 +232,19 @@ final class BLELinkStateStore {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<Fragment>(
|
||||
_ 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -20,22 +20,15 @@ enum BLEOutboundLinkPlanner {
|
||||
excludedLinks: Set<BLEIngressLinkID>,
|
||||
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,
|
||||
|
||||
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] ?? []
|
||||
|
||||
@@ -158,6 +158,16 @@ struct BLEPeerRegistry {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,6 +200,10 @@ final class CommandProcessor {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -365,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
|
||||
|
||||
@@ -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
|
||||
@@ -120,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)
|
||||
@@ -154,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
|
||||
}
|
||||
@@ -207,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*
|
||||
@@ -247,27 +320,33 @@ final class CourierStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelopes to park on relays as bridge courier drops. Non-destructive
|
||||
/// like remote handover — the relay copy is speculative, so the carried
|
||||
/// copy stays until direct handover or expiry. The per-envelope cooldown
|
||||
/// keeps relay churn from republishing the same mail; publishing relays
|
||||
/// dedup identical events by ID anyway.
|
||||
/// 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)
|
||||
var matched: [CourierEnvelope] = []
|
||||
for index in envelopes.indices {
|
||||
if let last = envelopes[index].lastBridgePublishAt,
|
||||
return envelopes.compactMap { stored in
|
||||
if let last = stored.lastBridgePublishAt,
|
||||
date.timeIntervalSince(last) < cooldown {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
envelopes[index].lastBridgePublishAt = date
|
||||
// The relay copy carries no spray budget.
|
||||
matched.append(envelopes[index].envelope.withCopies(1))
|
||||
return stored.envelope.withCopies(1)
|
||||
}
|
||||
if !matched.isEmpty { persistLocked() }
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,25 +357,60 @@ 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
|
||||
@@ -305,6 +419,7 @@ final class CourierStore {
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
envelopes.removeAll()
|
||||
diskLoadDeferred = false
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
@@ -312,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) {
|
||||
@@ -332,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)
|
||||
@@ -344,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 {
|
||||
@@ -355,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,
|
||||
|
||||
@@ -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<String>()
|
||||
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<String>, 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,
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Security
|
||||
#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
|
||||
@@ -49,6 +53,10 @@ final class BridgeCourierService: ObservableObject {
|
||||
/// 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()
|
||||
@@ -57,8 +65,11 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
var bridgeEnabled: (@MainActor () -> Bool)?
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// Publishes a signed drop event to the default (DM) relays.
|
||||
var publishEvent: (@MainActor (NostrEvent) -> Void)?
|
||||
/// 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)?
|
||||
@@ -68,12 +79,21 @@ final class BridgeCourierService: ObservableObject {
|
||||
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 (tag verified inside).
|
||||
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
|
||||
/// 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.
|
||||
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
|
||||
/// 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)?
|
||||
|
||||
@@ -81,39 +101,156 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
private(set) var myTagsHex: Set<String> = []
|
||||
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
|
||||
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
|
||||
/// Message IDs already published as drops (sender-side dedup).
|
||||
private var publishedDropKeys: BoundedIDSet
|
||||
/// Drop event IDs already handled (multi-relay dedup).
|
||||
private var seenDropEventIDs: BoundedIDSet
|
||||
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<String> = []
|
||||
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
|
||||
|
||||
init(now: @escaping () -> Date = Date.init) {
|
||||
private var dedupPersistScheduled = false
|
||||
|
||||
init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) {
|
||||
self.now = now
|
||||
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
||||
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
||||
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. Returns true when a fresh drop
|
||||
/// was sealed (published now or queued for the next relay connection) —
|
||||
/// the router marks the message "carried" so the sender sees progress.
|
||||
@discardableResult
|
||||
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
|
||||
guard bridgeEnabled?() ?? false else { return false }
|
||||
guard !publishedDropKeys.contains(messageID) else { return false }
|
||||
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
|
||||
publishedDropKeys.insert(messageID)
|
||||
publishDrop(envelope)
|
||||
return true
|
||||
/// 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,
|
||||
@@ -121,22 +258,61 @@ final class BridgeCourierService: ObservableObject {
|
||||
func publishHeldEnvelopes() {
|
||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
|
||||
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
|
||||
publishDrop(envelope)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func publishDrop(_ envelope: CourierEnvelope) {
|
||||
/// 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 { return }
|
||||
!envelope.isExpired else {
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
)
|
||||
return
|
||||
}
|
||||
guard relaysConnected?() ?? false else {
|
||||
pendingDrops.append((envelope, nil))
|
||||
if pendingDrops.count > Limits.maxPendingDrops {
|
||||
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
|
||||
// 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 = Self.makeThrowawayIdentity(),
|
||||
guard let identity = try? NostrIdentity.generate(),
|
||||
let event = try? NostrProtocol.createCourierDropEvent(
|
||||
envelope: encoded,
|
||||
recipientTagHex: envelope.recipientTag.hexEncodedString(),
|
||||
@@ -144,10 +320,63 @@ final class BridgeCourierService: ObservableObject {
|
||||
senderIdentity: identity
|
||||
) else {
|
||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
)
|
||||
return
|
||||
}
|
||||
publishEvent?(event)
|
||||
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session)
|
||||
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.
|
||||
@@ -156,7 +385,11 @@ final class BridgeCourierService: ObservableObject {
|
||||
let queued = pendingDrops
|
||||
pendingDrops.removeAll()
|
||||
for item in queued {
|
||||
publishDrop(item.envelope)
|
||||
publishDrop(
|
||||
item.envelope,
|
||||
messageID: item.dedupKey,
|
||||
operationID: item.operationID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +400,15 @@ final class BridgeCourierService: ObservableObject {
|
||||
/// (tags rotate daily); idempotent.
|
||||
func refresh() {
|
||||
armRefreshTimerIfNeeded()
|
||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
|
||||
guard bridgeEnabled?() ?? false else {
|
||||
cancelActivePublishes()
|
||||
if subscriptionOpen {
|
||||
closeSubscription?()
|
||||
subscriptionOpen = false
|
||||
}
|
||||
return
|
||||
}
|
||||
guard relaysConnected?() ?? false else {
|
||||
if subscriptionOpen {
|
||||
closeSubscription?()
|
||||
subscriptionOpen = false
|
||||
@@ -209,11 +450,37 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
/// 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 }
|
||||
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
|
||||
lastAnnounceRefresh = now()
|
||||
refresh()
|
||||
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() {
|
||||
@@ -241,7 +508,10 @@ final class BridgeCourierService: ObservableObject {
|
||||
func handleDropEvent(_ event: NostrEvent) {
|
||||
guard bridgeEnabled?() ?? false else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
|
||||
guard seenDropEventIDs.insert(event.id) 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),
|
||||
@@ -256,29 +526,40 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
if myTagsHex.contains(tagHex) {
|
||||
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
|
||||
openEnvelope?(envelope)
|
||||
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)
|
||||
deliverToPeer?(envelope, match.peerID)
|
||||
if deliverToPeer?(envelope, match.peerID) == true {
|
||||
seenDropEventIDs.insert(event.id, now: now())
|
||||
persistDedup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A fresh random Nostr identity for signing one drop.
|
||||
/// 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? {
|
||||
for _ in 0..<10 {
|
||||
var bytes = Data(count: 32)
|
||||
let status = bytes.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
guard status == errSecSuccess else { return nil }
|
||||
if let identity = try? NostrIdentity(privateKeyData: bytes) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
return nil
|
||||
try? NostrIdentity.generate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//
|
||||
// BridgeDropDedupStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,15 @@ import Foundation
|
||||
/// already holds the radio copy (`isMessageSeenLocally` on the event's
|
||||
/// mesh message ID) — remote islands' traffic is the only thing worth
|
||||
/// airtime.
|
||||
/// Receivers additionally dedup by timeline message ID: a bridged copy
|
||||
/// carries the original mesh message ID in its `m` tag, so the store's
|
||||
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
|
||||
/// 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.
|
||||
@@ -69,11 +75,25 @@ final class BridgeService: ObservableObject {
|
||||
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).
|
||||
@@ -89,7 +109,11 @@ final class BridgeService: ObservableObject {
|
||||
/// 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
|
||||
@@ -114,10 +138,9 @@ final class BridgeService: ObservableObject {
|
||||
/// 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 remote rendezvous participants seen within the freshness
|
||||
/// window. Approximate by design: local participants are subtracted by
|
||||
/// matching their events' mesh message IDs against the local timeline,
|
||||
/// which cannot attribute silent (presence-only) local peers.
|
||||
/// 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] = []
|
||||
@@ -157,6 +180,13 @@ final class BridgeService: ObservableObject {
|
||||
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)?
|
||||
@@ -183,32 +213,56 @@ final class BridgeService: ObservableObject {
|
||||
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<String> = []
|
||||
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, attributed-to-local-island, last known nickname).
|
||||
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
|
||||
/// 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) {
|
||||
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
|
||||
@@ -221,6 +275,8 @@ final class BridgeService: ObservableObject {
|
||||
queuedUplinks.removeAll()
|
||||
pendingDownlinks.removeAll()
|
||||
uplinkDepositTimes.removeAll()
|
||||
inboundEventTimes.removeAll()
|
||||
inboundEventTimesBySigner.removeAll()
|
||||
participants.removeAll()
|
||||
bridgedPeerCount = 0
|
||||
bridgedParticipants = []
|
||||
@@ -292,22 +348,27 @@ final class BridgeService: ObservableObject {
|
||||
/// 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.
|
||||
func bridgeOutgoing(content: String, messageID: String) {
|
||||
/// `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?(),
|
||||
meshMessageID: messageID
|
||||
meshSenderID: senderPeerID.id,
|
||||
meshTimestampMs: timestampMs
|
||||
) else {
|
||||
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
|
||||
return
|
||||
}
|
||||
publishedEventIDs.insert(event.id)
|
||||
injectedMessageIDs.insert(messageID) // our own timeline already has it
|
||||
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),
|
||||
@@ -376,7 +437,6 @@ final class BridgeService: ObservableObject {
|
||||
subscribedCells.contains(cell) else {
|
||||
return
|
||||
}
|
||||
guard let kind = classify(event, cell: 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)
|
||||
@@ -389,18 +449,23 @@ final class BridgeService: ObservableObject {
|
||||
publishedEventIDs.insert(event.id) // never downlink it either
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { 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, isLocal: false, nickname: nil)
|
||||
recordParticipant(event.pubkey, nickname: nil)
|
||||
case .message(let message):
|
||||
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
|
||||
let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false
|
||||
if isLocalRadioCopy {
|
||||
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
|
||||
// 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)
|
||||
}
|
||||
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
|
||||
inject(message)
|
||||
// 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:
|
||||
@@ -419,6 +484,33 @@ final class BridgeService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -455,7 +547,7 @@ final class BridgeService: ObservableObject {
|
||||
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else {
|
||||
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||
return
|
||||
}
|
||||
@@ -511,6 +603,52 @@ final class BridgeService: ObservableObject {
|
||||
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() {
|
||||
@@ -521,9 +659,15 @@ final class BridgeService: ObservableObject {
|
||||
let (event, cell) = pendingDownlinks.removeFirst()
|
||||
guard isFresh(event) else { continue }
|
||||
// Suppression recheck at send time: another gateway may have
|
||||
// broadcast this event during our jitter holdoff.
|
||||
// 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)
|
||||
@@ -572,36 +716,90 @@ final class BridgeService: ObservableObject {
|
||||
guard let event = structurallyValidEvent(from: carrier),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!isOwnRendezvousEvent(event, cell: carrier.geohash),
|
||||
event.isValidSignature() else {
|
||||
allowSignatureVerificationAttempt(),
|
||||
verifyEventSignature(event) else {
|
||||
return
|
||||
}
|
||||
// Mark after verification (a forged copy must not poison the cache),
|
||||
// and use the marking as multi-path dedup.
|
||||
guard meshBroadcastEventIDs.insert(event.id) else { return }
|
||||
// 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
|
||||
}
|
||||
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
|
||||
inject(message)
|
||||
if inject(message) {
|
||||
recordParticipant(event.pubkey, nickname: message.participantNickname)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Injection & participants
|
||||
|
||||
private func inject(_ message: InboundBridgeMessage) {
|
||||
guard injectedMessageIDs.insert(message.messageID) else { return }
|
||||
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
|
||||
@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 recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
|
||||
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]
|
||||
// Local attribution is sticky: one radio-confirmed message marks the
|
||||
// pubkey as an islander for as long as they stay fresh. Presence
|
||||
// events carry no nickname, so a known name is never forgotten.
|
||||
// Presence events carry no nickname, so a known name is never
|
||||
// forgotten. Radio hints deliberately do not mutate this record.
|
||||
participants[pubkey] = (
|
||||
lastSeen: now(),
|
||||
isLocal: (previous?.isLocal ?? false) || isLocal,
|
||||
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
|
||||
)
|
||||
recomputeBridgedCount()
|
||||
@@ -616,7 +814,7 @@ final class BridgeService: ObservableObject {
|
||||
private func recomputeBridgedCount() {
|
||||
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
|
||||
let visible = participants
|
||||
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
|
||||
.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 {
|
||||
@@ -648,11 +846,30 @@ final class BridgeService: ObservableObject {
|
||||
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]
|
||||
let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1]
|
||||
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
|
||||
// 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: messageID,
|
||||
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))",
|
||||
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))
|
||||
|
||||
@@ -15,12 +15,6 @@ struct GeohashChatPreview: Equatable, Sendable {
|
||||
let senderName: String
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
|
||||
init(senderName: String, content: String, timestamp: Date) {
|
||||
self.senderName = senderName
|
||||
self.content = content
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
/// The liveliest nearby conversation, resolved against the user's regional
|
||||
|
||||
@@ -542,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,
|
||||
@@ -551,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
|
||||
|
||||
@@ -173,6 +173,15 @@ final class LocationNotesManager: ObservableObject {
|
||||
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 {
|
||||
let unsubscribe = dependencies.unsubscribe
|
||||
Task { @MainActor in
|
||||
unsubscribe(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops notes whose NIP-40 expiry has passed. Their ids stay in
|
||||
@@ -190,27 +199,10 @@ final class LocationNotesManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
geohash = norm
|
||||
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
// 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,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)
|
||||
|
||||
@@ -222,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() {
|
||||
@@ -367,16 +389,16 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -393,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
|
||||
@@ -402,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)
|
||||
@@ -516,15 +547,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
}
|
||||
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins: [String] = []
|
||||
var seenAdmins = Set<String>()
|
||||
var uniqueRegions: [String] = []
|
||||
var seenRegions = Set<String>()
|
||||
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 {
|
||||
@@ -540,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()
|
||||
@@ -557,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:
|
||||
|
||||
@@ -51,6 +51,14 @@ final class MeshSightingsTracker: ObservableObject {
|
||||
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
|
||||
@@ -76,8 +84,10 @@ final class MeshSightingsTracker: ObservableObject {
|
||||
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 {
|
||||
@@ -92,12 +102,16 @@ final class MeshSightingsTracker: ObservableObject {
|
||||
return digest.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func dayKey(for date: Date) -> String {
|
||||
private static let dayKeyFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar.current
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static func dayKey(for date: Date) -> String {
|
||||
dayKeyFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func randomSalt() -> Data {
|
||||
|
||||
@@ -246,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.
|
||||
|
||||
@@ -56,11 +56,15 @@ final class MessageRouter {
|
||||
/// 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.
|
||||
/// Returns true when a fresh drop was sealed, so the sender's message
|
||||
/// can show the "carried" state instead of sitting on "sending" forever
|
||||
/// (the delivery ack has no radio route back until the peers next share
|
||||
/// a transport).
|
||||
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)?
|
||||
/// 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
|
||||
@@ -77,9 +81,7 @@ final class MessageRouter {
|
||||
}
|
||||
guard !promptlyDeliverable else { continue }
|
||||
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
|
||||
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true {
|
||||
onMessageCarried?(message.messageID, peerID)
|
||||
}
|
||||
requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,12 +94,17 @@ final class MessageRouter {
|
||||
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<Void, Never>?
|
||||
private var bridgeDepositsInFlight = Set<String>()
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
|
||||
@@ -123,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(
|
||||
@@ -161,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.
|
||||
@@ -209,9 +243,7 @@ final class MessageRouter {
|
||||
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.
|
||||
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true {
|
||||
onMessageCarried?(messageID, peerID)
|
||||
}
|
||||
requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey)
|
||||
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
||||
guard remainingSlots > 0 else { return }
|
||||
|
||||
@@ -296,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],
|
||||
@@ -316,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
|
||||
@@ -348,19 +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)
|
||||
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) {
|
||||
@@ -386,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)
|
||||
|
||||
@@ -230,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] { [:] }
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ final class NotificationService {
|
||||
private func registerCategories() {
|
||||
let wave = UNNotificationAction(
|
||||
identifier: Self.waveActionID,
|
||||
title: "wave 👋",
|
||||
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(
|
||||
|
||||
@@ -23,10 +23,11 @@ import Foundation
|
||||
final class PrekeyBundleStore {
|
||||
struct StoredBundle: Codable {
|
||||
// noiseKey is read in loadFromDisk (dictionary keying), but the
|
||||
// Periphery indexer intermittently misses that read and flakes CI
|
||||
// with "assign-only" — its USR is baselined (an in-source ignore
|
||||
// can't work: strict mode flags it as superfluous on the runs where
|
||||
// the indexer gets it right).
|
||||
// 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]
|
||||
|
||||
@@ -256,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,
|
||||
@@ -268,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,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]
|
||||
|
||||
@@ -216,6 +224,9 @@ protocol Transport: AnyObject {
|
||||
// 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
|
||||
@@ -244,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 }
|
||||
@@ -291,6 +306,8 @@ extension Transport {
|
||||
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
|
||||
Task { @MainActor in completion([]) }
|
||||
}
|
||||
|
||||
func purgeArchivedPublicMessages(from peerID: PeerID) {}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
|
||||
@@ -215,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
|
||||
|
||||
@@ -345,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -716,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -248,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ extension ChatViewModel: ChatLiveVoiceContext {
|
||||
}
|
||||
|
||||
/// Where a live voice burst lives: a Noise DM or the public mesh timeline.
|
||||
enum VoiceBurstScope: Equatable {
|
||||
enum VoiceBurstScope: Hashable {
|
||||
case directMessage
|
||||
case publicMesh
|
||||
}
|
||||
@@ -72,6 +72,16 @@ enum VoiceBurstScope: Equatable {
|
||||
/// 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
|
||||
@@ -97,6 +107,8 @@ final class ChatLiveVoiceCoordinator {
|
||||
var idleTimeout: Task<Void, Never>?
|
||||
var gapRedrain: Task<Void, Never>?
|
||||
|
||||
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
|
||||
@@ -123,11 +135,32 @@ final class ChatLiveVoiceCoordinator {
|
||||
private static let finishedBurstsCap = 32
|
||||
|
||||
private unowned let context: any ChatLiveVoiceContext
|
||||
private var assemblies: [Data: Assembly] = [:]
|
||||
private var finishedBursts: [Data: FinishedBurst] = [:]
|
||||
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] = [:]
|
||||
|
||||
init(context: any ChatLiveVoiceContext) {
|
||||
/// `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
|
||||
@@ -160,11 +193,12 @@ final class ChatLiveVoiceCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
if let assembly = assemblies[packet.burstID] {
|
||||
// The sender is authenticated (Noise session or packet
|
||||
// signature); a different peer or scope reusing the same burst
|
||||
// ID is a collision or a replay — drop it.
|
||||
guard assembly.peerID == peerID, assembly.scope == scope else { 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
|
||||
}
|
||||
@@ -178,7 +212,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
return
|
||||
}
|
||||
guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return }
|
||||
assemblies[packet.burstID] = assembly
|
||||
assemblies[key] = assembly
|
||||
updatePublicTalkerIndicator()
|
||||
apply(packet, to: assembly)
|
||||
case .end, .canceled:
|
||||
@@ -203,17 +237,25 @@ final class ChatLiveVoiceCoordinator {
|
||||
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[burstID] {
|
||||
if let assembly = assemblies.first(where: { matches($0.key) })?.value {
|
||||
finalize(assembly)
|
||||
}
|
||||
|
||||
pruneFinishedBursts()
|
||||
guard let finished = finishedBursts[burstID] else { return false }
|
||||
// Bind the note to the burst's authenticated sender and scope.
|
||||
guard message.senderPeerID == nil || message.senderPeerID == finished.peerID else { return false }
|
||||
guard message.isPrivate == (finished.scope == .directMessage) else { return false }
|
||||
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
||||
let finished = entry.value
|
||||
|
||||
let replacement = BitchatMessage(
|
||||
id: finished.messageID,
|
||||
@@ -237,8 +279,8 @@ final class ChatLiveVoiceCoordinator {
|
||||
|
||||
// The complete .m4a replaces the partial live capture.
|
||||
WaveformCache.shared.purge(url: finished.fileURL)
|
||||
try? FileManager.default.removeItem(at: finished.fileURL)
|
||||
finishedBursts.removeValue(forKey: burstID)
|
||||
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)
|
||||
@@ -248,14 +290,19 @@ final class ChatLiveVoiceCoordinator {
|
||||
// MARK: - Assembly lifecycle
|
||||
|
||||
private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? {
|
||||
guard let fileURL = Self.makeIncomingURL(burstID: burstID) else {
|
||||
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
|
||||
}
|
||||
FileManager.default.createFile(atPath: fileURL.path, contents: 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.default.removeItem(at: fileURL)
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -413,30 +460,47 @@ final class ChatLiveVoiceCoordinator {
|
||||
}
|
||||
try? assembly.fileHandle?.close()
|
||||
assembly.fileHandle = nil
|
||||
assemblies.removeValue(forKey: assembly.burstID)
|
||||
assemblies.removeValue(forKey: assembly.key)
|
||||
updatePublicTalkerIndicator()
|
||||
|
||||
guard assembly.deliveredFrames > 0 else {
|
||||
// Nothing audible ever arrived — drop the empty bubble.
|
||||
removeBubble(of: assembly)
|
||||
try? FileManager.default.removeItem(at: assembly.fileURL)
|
||||
try? fileManager.removeItem(at: assembly.fileURL)
|
||||
context.notifyUIChanged()
|
||||
return
|
||||
}
|
||||
|
||||
assembly.player?.finishAfterDrain()
|
||||
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)
|
||||
// Republish so the row re-renders without its LIVE treatment even if
|
||||
// no finalized note ever arrives to swap in.
|
||||
republishBubble(of: assembly)
|
||||
// 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.burstID] = FinishedBurst(
|
||||
finishedBursts[assembly.key] = FinishedBurst(
|
||||
messageID: assembly.messageID,
|
||||
peerID: assembly.peerID,
|
||||
scope: assembly.scope,
|
||||
fileURL: assembly.fileURL,
|
||||
fileURL: fileURL,
|
||||
messageTimestamp: assembly.messageTimestamp,
|
||||
expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds)
|
||||
)
|
||||
@@ -453,12 +517,48 @@ final class ChatLiveVoiceCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private func republishBubble(of assembly: Assembly) {
|
||||
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(assembly.message, in: assembly.peerID)
|
||||
context.upsertPrivateMessage(message, in: assembly.peerID)
|
||||
case .publicMesh:
|
||||
context.upsertPublicMeshMessage(assembly.message)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,11 +568,11 @@ final class ChatLiveVoiceCoordinator {
|
||||
assembly.player?.stop()
|
||||
try? assembly.fileHandle?.close()
|
||||
assembly.fileHandle = nil
|
||||
assemblies.removeValue(forKey: assembly.burstID)
|
||||
assemblies.removeValue(forKey: assembly.key)
|
||||
updatePublicTalkerIndicator()
|
||||
removeBubble(of: assembly)
|
||||
WaveformCache.shared.purge(url: assembly.fileURL)
|
||||
try? FileManager.default.removeItem(at: assembly.fileURL)
|
||||
try? fileManager.removeItem(at: assembly.fileURL)
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
@@ -480,10 +580,10 @@ final class ChatLiveVoiceCoordinator {
|
||||
|
||||
private func rescheduleIdleTimeout(for assembly: Assembly) {
|
||||
assembly.idleTimeout?.cancel()
|
||||
let burstID = assembly.burstID
|
||||
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[burstID] else { return }
|
||||
guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return }
|
||||
// Talker went silent/out of range without an END.
|
||||
self.finalize(assembly)
|
||||
}
|
||||
@@ -491,10 +591,10 @@ final class ChatLiveVoiceCoordinator {
|
||||
|
||||
private func scheduleGapRedrain(for assembly: Assembly) {
|
||||
assembly.gapRedrain?.cancel()
|
||||
let burstID = assembly.burstID
|
||||
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[burstID] else { return }
|
||||
guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return }
|
||||
self.drainInOrder(assembly)
|
||||
self.finalizeIfComplete(assembly)
|
||||
}
|
||||
@@ -521,21 +621,37 @@ final class ChatLiveVoiceCoordinator {
|
||||
return Data(hexString: hex)
|
||||
}
|
||||
|
||||
private static func makeIncomingURL(burstID: Data) -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent("\(MimeType.Category.audio.mediaDir)/incoming", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
return directory.appendingPathComponent("voice_live_\(burstID.hexEncodedString()).aac")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ protocol ChatOutgoingContext: AnyObject {
|
||||
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.
|
||||
func bridgeOutgoingPublicMessage(_ content: String, messageID: String)
|
||||
/// 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
|
||||
@@ -66,8 +69,8 @@ extension ChatViewModel: ChatOutgoingContext {
|
||||
lastPublicActivityAt[key] = Date()
|
||||
}
|
||||
|
||||
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) {
|
||||
BridgeService.shared.bridgeOutgoing(content: content, messageID: messageID)
|
||||
func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) {
|
||||
BridgeService.shared.bridgeOutgoing(content: content, senderPeerID: senderPeerID, timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +150,7 @@ private extension ChatOutgoingCoordinator {
|
||||
messageID: message.id,
|
||||
timestamp: message.timestamp
|
||||
)
|
||||
context.bridgeOutgoingPublicMessage(trimmed, messageID: message.id)
|
||||
context.bridgeOutgoingPublicMessage(trimmed, senderPeerID: context.myPeerID, timestamp: message.timestamp)
|
||||
}
|
||||
|
||||
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
|
||||
|
||||
@@ -83,14 +83,14 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
|
||||
// MARK: Routing & acknowledgements
|
||||
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 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.
|
||||
@@ -161,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)
|
||||
}
|
||||
|
||||
@@ -329,8 +330,12 @@ final class ChatPrivateConversationCoordinator {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -417,9 +417,12 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
/// 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. The mesh wire format carries no stable message ID, so a
|
||||
/// re-synced copy of an already-rendered echo arrives with a fresh UUID —
|
||||
/// this content identity is the only way to recognize it.
|
||||
/// 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<String>()
|
||||
|
||||
func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) {
|
||||
|
||||
@@ -698,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
|
||||
@@ -1173,6 +1191,7 @@ 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()
|
||||
@@ -1754,6 +1773,14 @@ 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 }
|
||||
|
||||
@@ -210,11 +210,19 @@ private extension ChatViewModelBootstrapper {
|
||||
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.
|
||||
// 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 }
|
||||
guard let viewModel, !archived.isEmpty else { return }
|
||||
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.
|
||||
@@ -456,6 +464,12 @@ private extension ChatViewModelBootstrapper {
|
||||
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
|
||||
}
|
||||
@@ -524,11 +538,17 @@ private extension ChatViewModelBootstrapper {
|
||||
let courier = BridgeCourierService.shared
|
||||
|
||||
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
|
||||
courier.relaysConnected = { NostrRelayManager.shared.isConnected }
|
||||
courier.publishEvent = { event in
|
||||
// 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.
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
// 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)
|
||||
@@ -556,20 +576,28 @@ private extension ChatViewModelBootstrapper {
|
||||
bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey)
|
||||
}
|
||||
courier.openEnvelope = { [weak bleService] envelope in
|
||||
bleService?.openBridgedCourierEnvelope(envelope)
|
||||
bleService?.openBridgedCourierEnvelope(envelope) ?? false
|
||||
}
|
||||
courier.deliverToPeer = { [weak bleService] envelope, peerID in
|
||||
bleService?.deliverBridgedEnvelope(envelope, to: peerID)
|
||||
bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false
|
||||
}
|
||||
courier.heldEnvelopes = { cooldown in
|
||||
CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown)
|
||||
}
|
||||
|
||||
viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey in
|
||||
BridgeCourierService.shared.depositDrop(content: content, messageID: messageID, recipientNoiseKey: recipientKey)
|
||||
courier.markHeldEnvelopePublished = { envelope in
|
||||
CourierStore.shared.markBridgePublished(envelope)
|
||||
}
|
||||
// (depositDrop's Bool flows back so a dropped message shows the
|
||||
// "carried" state — a drop's delivery ack can't return over radio.)
|
||||
|
||||
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
|
||||
@@ -580,7 +608,7 @@ private extension ChatViewModelBootstrapper {
|
||||
// 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.$isConnected
|
||||
NostrRelayManager.shared.$isDMRelayConnected
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in BridgeCourierService.shared.refresh() }
|
||||
|
||||
@@ -37,25 +37,47 @@ final class GeoChannelCoordinator {
|
||||
private weak var context: (any GeoChannelContext)?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
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<Bool, Never>
|
||||
private let locationNotesSettingsPublisher: AnyPublisher<Bool, Never>
|
||||
|
||||
init(
|
||||
locationManager: LocationChannelManager? = nil,
|
||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
||||
torManager: TorManager? = nil,
|
||||
notesRevealed: AnyPublisher<Bool, Never>? = nil,
|
||||
locationNotesEnabled: Bool? = nil,
|
||||
locationNotesSettings: AnyPublisher<Bool, Never>? = 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
/// 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))
|
||||
@@ -75,13 +79,18 @@ 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 session.requestPermission()
|
||||
guard state == .requestingPermission, activeSession === session else { return }
|
||||
guard generation == holdGeneration,
|
||||
state == .requestingPermission,
|
||||
activeSession === session
|
||||
else { return }
|
||||
guard granted else {
|
||||
state = .permissionDenied
|
||||
activeSession = nil
|
||||
@@ -90,16 +99,36 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
state = .preparing
|
||||
do {
|
||||
try await session.start()
|
||||
guard state == .preparing, activeSession === session else {
|
||||
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 session.cancel()
|
||||
guard state == .preparing, activeSession === session else { return }
|
||||
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
|
||||
@@ -109,7 +138,10 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
activeSession = fallback
|
||||
do {
|
||||
try await fallback.start()
|
||||
guard state == .preparing, activeSession === fallback else {
|
||||
guard generation == holdGeneration,
|
||||
state == .preparing,
|
||||
activeSession === fallback
|
||||
else {
|
||||
await fallback.cancel()
|
||||
return
|
||||
}
|
||||
@@ -120,7 +152,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
} catch {
|
||||
SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session)
|
||||
await fallback.cancel()
|
||||
guard state == .preparing else { return }
|
||||
guard generation == holdGeneration, state == .preparing else { return }
|
||||
}
|
||||
}
|
||||
activeSession = nil
|
||||
@@ -142,6 +174,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
state = .idle
|
||||
isLiveStreaming = false
|
||||
let session = activeSession
|
||||
let generation = holdGeneration
|
||||
activeSession = nil
|
||||
|
||||
guard case .recording(let startDate) = previousState, let completion, let session else {
|
||||
@@ -159,7 +192,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
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."
|
||||
|
||||
@@ -24,6 +24,7 @@ struct MeshEmptyStateView: View {
|
||||
@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
|
||||
|
||||
@@ -38,6 +39,7 @@ struct MeshEmptyStateView: View {
|
||||
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(
|
||||
@@ -80,6 +82,9 @@ struct MeshEmptyStateView: View {
|
||||
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
|
||||
@@ -89,6 +94,9 @@ struct MeshEmptyStateView: View {
|
||||
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.
|
||||
@@ -100,7 +108,11 @@ struct MeshEmptyStateView: View {
|
||||
}
|
||||
}
|
||||
.frame(minHeight: compact ? 0 : fillHeight, alignment: .top)
|
||||
.onReceive(refreshTimer) { _ in refreshTick += 1 }
|
||||
.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
|
||||
@@ -123,6 +135,31 @@ private extension MeshEmptyStateView {
|
||||
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
|
||||
|
||||
@@ -131,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,14 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,8 +334,10 @@ private extension MessageListView {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// The nearby-notes counter runs whenever the mesh public timeline is
|
||||
/// 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 }
|
||||
|
||||
+212
-42
@@ -34,15 +34,24 @@ struct NoticesView: View {
|
||||
/// 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?
|
||||
/// Live manager owned by the sheet so the composer can post pure Nostr
|
||||
/// 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,
|
||||
@@ -61,20 +70,137 @@ struct NoticesView: View {
|
||||
notesManager ?? liveGeoManager
|
||||
}
|
||||
|
||||
/// Creates (or retargets/revives) the sheet-owned notes manager for the
|
||||
/// current geo scope.
|
||||
private func ensureGeoNotesManager() {
|
||||
guard notesManager == nil, tab == .geo, let geohash = geoGeohash else { return }
|
||||
if let manager = 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() {
|
||||
manager.setGeohash(geohash)
|
||||
} else if manager.state == .idle {
|
||||
// Cancelled on a tab switch; returning re-subscribes.
|
||||
// Pooled managers are shared; never retarget one in place.
|
||||
release(manager)
|
||||
return GeoSessionState(
|
||||
manager: acquire(geohash),
|
||||
ownsLiveRefresh: wantsLiveRefresh
|
||||
)
|
||||
}
|
||||
if manager.state == .idle {
|
||||
manager.refresh()
|
||||
}
|
||||
} else {
|
||||
liveGeoManager = LocationNotesManager(geohash: geohash)
|
||||
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 }
|
||||
@@ -85,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
|
||||
}
|
||||
|
||||
@@ -96,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 {
|
||||
@@ -125,6 +255,8 @@ struct NoticesView: View {
|
||||
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"
|
||||
@@ -174,39 +306,46 @@ struct NoticesView: View {
|
||||
#endif
|
||||
.themedSheetBackground()
|
||||
.onAppear {
|
||||
beginGeoLocationIfNeeded()
|
||||
ensureGeoNotesManager()
|
||||
reconcileGeoSession()
|
||||
}
|
||||
.onChange(of: tab) { newTab in
|
||||
if newTab == .geo {
|
||||
beginGeoLocationIfNeeded()
|
||||
ensureGeoNotesManager()
|
||||
} 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
|
||||
ensureGeoNotesManager()
|
||||
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 {
|
||||
@@ -258,21 +397,56 @@ struct NoticesView: View {
|
||||
notesManager: nil
|
||||
)
|
||||
case .geo:
|
||||
if let geohash = geoGeohash {
|
||||
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 { ensureGeoNotesManager() }
|
||||
.onAppear { reconcileGeoSession() }
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
@@ -417,10 +591,6 @@ private struct GeoNoticesList: View {
|
||||
board: board,
|
||||
notesManager: notesManager
|
||||
)
|
||||
.onChange(of: geohash) { newValue in
|
||||
notesManager.setGeohash(newValue)
|
||||
}
|
||||
.onDisappear { notesManager.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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 }
|
||||
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
|
||||
}
|
||||
break
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
//
|
||||
// AudioSessionCoordinatorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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<Void, Never>] = []
|
||||
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -219,11 +219,54 @@ struct BLEServiceCoreTests {
|
||||
)
|
||||
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).
|
||||
@@ -249,10 +292,230 @@ struct BLEServiceCoreTests {
|
||||
#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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -63,20 +63,57 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
coordinator.handleVoiceFramePayload(from: peerID, payload: packet.encode(), timestamp: Date())
|
||||
}
|
||||
|
||||
private func incomingFileURL(burstID: Data) -> URL? {
|
||||
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("voice_live_\(burstID.hexEncodedString()).aac")
|
||||
.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)
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0xA1)
|
||||
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
|
||||
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)
|
||||
@@ -86,7 +123,7 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
let bubble = try #require(context.handledPrivateMessages.first)
|
||||
#expect(bubble.isPrivate)
|
||||
#expect(bubble.senderPeerID == peer)
|
||||
#expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString()).aac")
|
||||
#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
|
||||
@@ -95,21 +132,26 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
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)
|
||||
|
||||
let url = try #require(incomingFileURL(burstID: burstID))
|
||||
// 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 for re-render.
|
||||
// Burst ended: no longer live, bubble republished pointing at the
|
||||
// promoted file.
|
||||
#expect(!coordinator.isLiveVoiceMessage(bubble))
|
||||
#expect(context.upsertedMessages.contains { $0.message.id == bubble.id })
|
||||
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)
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0xB2)
|
||||
let hex = burstID.hexEncodedString()
|
||||
|
||||
@@ -134,7 +176,8 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
#expect(replacement.message.id == bubble.id)
|
||||
#expect(replacement.message.content == note.content)
|
||||
#expect(replacement.peerID == peer)
|
||||
let url = try #require(incomingFileURL(burstID: burstID))
|
||||
// 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.
|
||||
@@ -143,7 +186,7 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
|
||||
@Test func absorbIgnoresUnrelatedVoiceNotes() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context)
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
|
||||
// A classic voice note (date-stamped name) and a live-capture name
|
||||
// must both pass through untouched.
|
||||
@@ -167,7 +210,7 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
|
||||
@Test func canceledBurstRemovesBubbleAndFile() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context)
|
||||
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)
|
||||
@@ -175,14 +218,14 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
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))
|
||||
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)
|
||||
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)
|
||||
@@ -195,7 +238,7 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
|
||||
@Test func ignoresBlockedPeersAndUnknownControlPackets() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context)
|
||||
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)
|
||||
@@ -210,12 +253,12 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
|
||||
@Test func concurrentAssemblyCapDropsExtraBursts() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context)
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
|
||||
var cleanup: [Data] = []
|
||||
defer {
|
||||
for burstID in cleanup {
|
||||
incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) }
|
||||
incomingFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
|
||||
}
|
||||
}
|
||||
for i in 0..<TransportConfig.pttMaxConcurrentAssemblies {
|
||||
@@ -235,22 +278,25 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
defer { PTTSettings.liveVoiceEnabled = previous }
|
||||
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context)
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0xE8)
|
||||
|
||||
// Off means classic-notes-only: no live bubble, no partial file.
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 5, count: 40)]))), to: coordinator, from: peer)
|
||||
#expect(context.handledPrivateMessages.isEmpty)
|
||||
let url = try #require(incomingFileURL(burstID: burstID))
|
||||
let url = try #require(incomingFileURL(burstID: burstID, peerID: peer))
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
@Test func publicBurstCreatesMeshBubbleAndTracksTalker() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context)
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0x71)
|
||||
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
|
||||
defer {
|
||||
incomingFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
|
||||
fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
|
||||
}
|
||||
|
||||
func sendPublic(_ packet: VoiceBurstPacket) {
|
||||
coordinator.handlePublicVoiceFramePayload(from: peer, nickname: "bob", payload: packet.encode(), timestamp: Date())
|
||||
@@ -286,9 +332,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
|
||||
@Test func absorbEnforcesScopeBinding() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context)
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0x72)
|
||||
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
|
||||
defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } }
|
||||
|
||||
// A DM burst...
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 4, count: 40)]))), to: coordinator, from: peer)
|
||||
@@ -308,6 +354,269 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff (1).m4a") == Data(hexString: "00112233445566ff"))
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_20260708_120000.m4a") == nil)
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff.aac") == nil)
|
||||
// Per-peer, per-scope live capture names must stay unabsorbable too.
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001.aac") == nil)
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001_dm.aac") == nil)
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001_mesh.aac") == nil)
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "other.m4a") == nil)
|
||||
}
|
||||
|
||||
@Test func collidingBurstIDFromAnotherPeerCannotHijackAssembly() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0x73)
|
||||
let attacker = PeerID(str: "ddddeeeeffff0002")
|
||||
defer {
|
||||
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
|
||||
incomingFileURL(burstID: burstID, peerID: attacker).map { try? FileManager.default.removeItem(at: $0) }
|
||||
}
|
||||
|
||||
// The victim starts a burst; the attacker races a START reusing the
|
||||
// observed burst ID from a different peer.
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: attacker)
|
||||
|
||||
// Two distinct bubbles on two distinct files — no capture.
|
||||
#expect(context.handledPrivateMessages.count == 2)
|
||||
let victimBubble = try #require(context.handledPrivateMessages.first)
|
||||
let attackerBubble = try #require(context.handledPrivateMessages.last)
|
||||
#expect(victimBubble.id != attackerBubble.id)
|
||||
#expect(victimBubble.content != attackerBubble.content)
|
||||
|
||||
// The victim's frames still land in the victim's file, untouched by
|
||||
// the attacker's assembly.
|
||||
let victimFrame = Data(repeating: 0x0A, count: 60)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([victimFrame]))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||
let victimURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
||||
#expect(try Data(contentsOf: victimURL) == ADTSFramer.frame(victimFrame))
|
||||
let attackerURL = try #require(incomingFileURL(burstID: burstID, peerID: attacker))
|
||||
#expect((try? Data(contentsOf: attackerURL))?.isEmpty == true)
|
||||
#expect(!coordinator.isLiveVoiceMessage(victimBubble))
|
||||
#expect(coordinator.isLiveVoiceMessage(attackerBubble))
|
||||
}
|
||||
|
||||
@Test func sameBurstIDCoexistsAcrossScopes() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0x74)
|
||||
defer {
|
||||
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
|
||||
fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
|
||||
}
|
||||
|
||||
// A DM burst and a public burst reusing the same burst ID open
|
||||
// independent assemblies instead of colliding.
|
||||
let dmFrame = Data(repeating: 6, count: 50)
|
||||
let publicFrame = Data(repeating: 7, count: 50)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([dmFrame]))), to: coordinator, from: peer)
|
||||
coordinator.handlePublicVoiceFramePayload(
|
||||
from: peer,
|
||||
nickname: "bob",
|
||||
payload: try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([publicFrame]))).encode(),
|
||||
timestamp: Date()
|
||||
)
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.appendedPublicMessages.count == 1)
|
||||
let dmBubble = try #require(context.handledPrivateMessages.first)
|
||||
let publicBubble = try #require(context.appendedPublicMessages.first)
|
||||
|
||||
// Ending the public burst leaves the DM assembly live.
|
||||
coordinator.handlePublicVoiceFramePayload(
|
||||
from: peer,
|
||||
nickname: "bob",
|
||||
payload: try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))).encode(),
|
||||
timestamp: Date()
|
||||
)
|
||||
#expect(!coordinator.isLiveVoiceMessage(publicBubble))
|
||||
#expect(coordinator.isLiveVoiceMessage(dmBubble))
|
||||
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||
#expect(!coordinator.isLiveVoiceMessage(dmBubble))
|
||||
|
||||
// The scope in the file name keeps the two captures on distinct
|
||||
// paths: both survive with intact contents instead of one truncating
|
||||
// or deleting the other (both promoted off their live names by now).
|
||||
let dmURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
||||
let publicURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh))
|
||||
#expect(dmURL != publicURL)
|
||||
#expect(try Data(contentsOf: dmURL) == ADTSFramer.frame(dmFrame))
|
||||
#expect(try Data(contentsOf: publicURL) == ADTSFramer.frame(publicFrame))
|
||||
|
||||
// Absorption still routes each note by its scope.
|
||||
let dmNote = BitchatMessage(
|
||||
sender: "alice", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
|
||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
||||
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
|
||||
}
|
||||
|
||||
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0x75)
|
||||
let hex = burstID.hexEncodedString()
|
||||
let attacker = PeerID(str: "ddddeeeeffff0002")
|
||||
defer {
|
||||
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
|
||||
fallbackFileURL(burstID: burstID, peerID: attacker).map { try? FileManager.default.removeItem(at: $0) }
|
||||
}
|
||||
|
||||
// The attacker's colliding burst finishes FIRST, then the victim's.
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 1, count: 40)]))), to: coordinator, from: attacker)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: attacker)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 2, count: 40)]))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||
#expect(context.handledPrivateMessages.count == 2)
|
||||
let attackerBubble = try #require(context.handledPrivateMessages.first)
|
||||
let victimBubble = try #require(context.handledPrivateMessages.last)
|
||||
|
||||
// The real sender's note absorbs into the real sender's bubble.
|
||||
let note = BitchatMessage(
|
||||
sender: "alice", content: "[voice] voice_\(hex).m4a", timestamp: Date(),
|
||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||
let replacement = try #require(context.upsertedMessages.last)
|
||||
#expect(replacement.message.id == victimBubble.id)
|
||||
#expect(replacement.peerID == peer)
|
||||
|
||||
// The attacker's note can only ever claim the attacker's own bubble.
|
||||
let attackerNote = BitchatMessage(
|
||||
sender: "mallory", content: "[voice] voice_\(hex).m4a", timestamp: Date(),
|
||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: attacker
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
||||
let attackerReplacement = try #require(context.upsertedMessages.last)
|
||||
#expect(attackerReplacement.message.id == attackerBubble.id)
|
||||
#expect(attackerReplacement.peerID == attacker)
|
||||
|
||||
// Both registry entries are consumed — nothing left to hijack.
|
||||
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
||||
}
|
||||
|
||||
@Test func liveBurstEnforcesIncomingMediaQuota() throws {
|
||||
let (store, incoming, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
// Pre-seed enough finalized media that the burst's reservation pushes
|
||||
// usage over the 100 MB quota: the oldest file must be evicted.
|
||||
let oldURL = incoming.appendingPathComponent("voice_old.m4a")
|
||||
let newURL = incoming.appendingPathComponent("voice_new.m4a")
|
||||
try Data(count: 60 * 1024 * 1024).write(to: oldURL)
|
||||
try Data(count: 45 * 1024 * 1024).write(to: newURL)
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -3600), at: oldURL)
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -60), at: newURL)
|
||||
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
|
||||
let burstID = makeBurstID(0x76)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
|
||||
|
||||
// Oldest evicted, newer survivor + reservation fit under the quota,
|
||||
// and the capture file lives under the injected store's base.
|
||||
#expect(!FileManager.default.fileExists(atPath: oldURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: newURL.path))
|
||||
let liveURL = incoming.appendingPathComponent(liveCaptureName(burstID: burstID, peerID: peer))
|
||||
#expect(FileManager.default.fileExists(atPath: liveURL.path))
|
||||
let remaining = try FileManager.default.contentsOfDirectory(at: incoming, includingPropertiesForKeys: [.fileSizeKey])
|
||||
.compactMap { try $0.resourceValues(forKeys: [.fileSizeKey]).fileSize }
|
||||
.reduce(0, +)
|
||||
#expect(remaining + TransportConfig.pttMaxBurstBytes <= 100 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@Test func inFlightLiveCaptureIsNeverEvicted() throws {
|
||||
let (store, incoming, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
|
||||
let burstID = makeBurstID(0x77)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 8, count: 60)]))), to: coordinator, from: peer)
|
||||
let liveURL = incoming.appendingPathComponent(liveCaptureName(burstID: burstID, peerID: peer))
|
||||
#expect(FileManager.default.fileExists(atPath: liveURL.path))
|
||||
|
||||
// Make the streaming partial the LRU-oldest candidate, then blow the
|
||||
// quota and open a second burst to trigger eviction.
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: liveURL)
|
||||
let bigURL = incoming.appendingPathComponent("voice_big.m4a")
|
||||
try Data(count: 105 * 1024 * 1024).write(to: bigURL)
|
||||
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0x78), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
|
||||
|
||||
// Eviction skipped the in-flight partial and deleted the big file.
|
||||
#expect(FileManager.default.fileExists(atPath: liveURL.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: bigURL.path))
|
||||
}
|
||||
|
||||
@Test func startupSweepRemovesStaleLiveCapturesOnly() throws {
|
||||
let (store, incoming, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
// A partial capture orphaned by a previous session; a finalized note
|
||||
// and a promoted fallback capture that must both survive the sweep.
|
||||
let stale = incoming.appendingPathComponent("voice_live_00aa00aa00aa00aa_ddddeeeeffff0002_dm.aac")
|
||||
let finalized = incoming.appendingPathComponent("voice_00112233445566ff.m4a")
|
||||
let promoted = incoming.appendingPathComponent("voice_00bb00bb00bb00bb_ddddeeeeffff0002_dm.aac")
|
||||
try Data([0x01]).write(to: stale)
|
||||
try Data([0x02]).write(to: finalized)
|
||||
try Data([0x03]).write(to: promoted)
|
||||
|
||||
let context = MockChatLiveVoiceContext()
|
||||
_ = ChatLiveVoiceCoordinator(context: context, fileStore: store)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: stale.path))
|
||||
#expect(FileManager.default.fileExists(atPath: finalized.path))
|
||||
#expect(FileManager.default.fileExists(atPath: promoted.path))
|
||||
}
|
||||
|
||||
@Test func finalizedFallbackSurvivesNextStartupSweep() throws {
|
||||
let (store, incoming, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
// A burst finishes without its finalized note (live-only burst or
|
||||
// sender out of range): the capture is the row's only audio.
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
|
||||
let burstID = makeBurstID(0x79)
|
||||
let frame = Data(repeating: 0x0B, count: 60)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame]))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||
|
||||
// The republished row points at the promoted (non-live) name, and
|
||||
// that file holds the burst's audio.
|
||||
let republished = try #require(context.upsertedMessages.last)
|
||||
#expect(republished.message.content == "[voice] \(fallbackName(burstID: burstID, peerID: peer))")
|
||||
let fallbackURL = incoming.appendingPathComponent(fallbackName(burstID: burstID, peerID: peer))
|
||||
#expect(try Data(contentsOf: fallbackURL) == ADTSFramer.frame(frame))
|
||||
|
||||
// A later coordinator startup (same store) sweeps in-flight partials
|
||||
// only: the row's fallback audio must survive and stay playable.
|
||||
_ = ChatLiveVoiceCoordinator(context: MockChatLiveVoiceContext(), fileStore: store)
|
||||
#expect(try Data(contentsOf: fallbackURL) == ADTSFramer.frame(frame))
|
||||
}
|
||||
|
||||
@Test func promotedFallbackIsQuotaEvictable() throws {
|
||||
let (store, incoming, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let context = MockChatLiveVoiceContext()
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
|
||||
let burstID = makeBurstID(0x7A)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 0x0C, count: 60)]))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||
let fallbackURL = incoming.appendingPathComponent(fallbackName(burstID: burstID, peerID: peer))
|
||||
#expect(FileManager.default.fileExists(atPath: fallbackURL.path))
|
||||
|
||||
// Once promoted, the capture is ordinary finalized media: as the
|
||||
// LRU-oldest file it is evicted, not skipped, when quota pressure
|
||||
// arrives.
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: fallbackURL)
|
||||
let bigURL = incoming.appendingPathComponent("voice_big.m4a")
|
||||
try Data(count: 105 * 1024 * 1024).write(to: bigURL)
|
||||
store.enforceQuota(reservingBytes: 0)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: fallbackURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +87,9 @@ private final class MockChatOutgoingContext: ChatOutgoingContext {
|
||||
sentGeohashContexts.append(context)
|
||||
}
|
||||
|
||||
private(set) var bridgedMessages: [(content: String, messageID: String)] = []
|
||||
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) {
|
||||
bridgedMessages.append((content, messageID))
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -181,8 +181,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
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))
|
||||
return routeReadReceiptResult
|
||||
}
|
||||
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
@@ -223,13 +225,8 @@ 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)
|
||||
}
|
||||
@@ -657,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 —
|
||||
@@ -683,7 +679,6 @@ 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
|
||||
@@ -701,6 +696,5 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
|
||||
#expect(context.routedPrivateMessages.map(\.content) == ["hello?"])
|
||||
#expect(context.privateChats[peerID]?.first?.deliveryStatus == .sending)
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -761,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
@@ -691,9 +779,9 @@ struct MessageRouterCourierTests {
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var drops: [(content: String, messageID: String, key: Data)] = []
|
||||
router.bridgeCourierDeposit = { content, messageID, key in
|
||||
router.bridgeCourierDeposit = { content, messageID, key, completion in
|
||||
drops.append((content, messageID, key))
|
||||
return true
|
||||
completion(true)
|
||||
}
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -126,14 +117,11 @@ struct FragmentationTests {
|
||||
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))
|
||||
}
|
||||
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]"))
|
||||
@@ -173,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)
|
||||
@@ -207,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<Void, Never>?
|
||||
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
|
||||
private var expectedPublicMessageCount: Int = 0
|
||||
private var expectedReceivedMessageCount: Int = 0
|
||||
|
||||
private func withLock<T>(_ body: () -> T) -> T {
|
||||
lock.lock()
|
||||
@@ -227,139 +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 {
|
||||
// withCheckedContinuation itself is not cancellable, so hook
|
||||
// group.cancelAll() to resume the parked continuation —
|
||||
// otherwise a timeout leaves the group awaiting this child
|
||||
// forever and the test run hangs instead of failing.
|
||||
await withTaskCancellationHandler {
|
||||
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()
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
let continuation = self.withLock {
|
||||
let parked = self.publicMessageContinuation
|
||||
self.publicMessageContinuation = nil
|
||||
return parked
|
||||
}
|
||||
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 {
|
||||
// withCheckedContinuation itself is not cancellable, so hook
|
||||
// group.cancelAll() to resume the parked continuation —
|
||||
// otherwise a timeout leaves the group awaiting this child
|
||||
// forever and the test run hangs instead of failing.
|
||||
await withTaskCancellationHandler {
|
||||
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()
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
let continuation = self.withLock {
|
||||
let parked = self.receivedMessageContinuation
|
||||
self.receivedMessageContinuation = nil
|
||||
return parked
|
||||
}
|
||||
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) {}
|
||||
|
||||
@@ -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<Bool, Never>(false)
|
||||
let notesEnabled = CurrentValueSubject<Bool, Never>(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<Bool, Never>(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")))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,6 +83,16 @@ 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)
|
||||
}
|
||||
|
||||
@@ -45,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<PeerID> = []
|
||||
var reachablePeers: Set<PeerID> = []
|
||||
/// 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<PeerID>?
|
||||
var peerNicknames: [PeerID: String] = [:]
|
||||
var peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
@@ -87,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]
|
||||
}
|
||||
@@ -107,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
//
|
||||
// PTTBurstPlayerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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..<Int(totalFrames) {
|
||||
channel[i] = sinf(2 * .pi * 440 * Float(i) / Float(PTTAudioFormat.sampleRate)) * 0.5
|
||||
}
|
||||
return encoder.encode(buffer)
|
||||
}
|
||||
|
||||
/// The jitter-buffered start now acquires the session asynchronously
|
||||
/// (its blocking IPC runs off the main actor), so tests await the
|
||||
/// condition instead of asserting right after `enqueue`.
|
||||
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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -786,6 +791,10 @@ 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.conversations = conversations
|
||||
self.viewModel = ChatViewModel(
|
||||
@@ -793,7 +802,8 @@ private final class PerfPipelineFixture {
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport,
|
||||
conversations: conversations
|
||||
conversations: conversations,
|
||||
locationManager: locationManager
|
||||
)
|
||||
self.privateInbox = PrivateInboxModel(conversations: conversations)
|
||||
self.publicChat = PublicChatModel(conversations: conversations)
|
||||
|
||||
@@ -103,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
|
||||
|
||||
@@ -111,6 +111,53 @@ struct BridgeWireFormatTests {
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
@@ -139,7 +186,12 @@ struct CourierStoreBridgePublishTests {
|
||||
// eligible again after the cooldown. `carriedCount` publishes
|
||||
// asynchronously, so it is not asserted here.)
|
||||
|
||||
// Within cooldown: nothing to publish.
|
||||
// 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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user