mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage. Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
This commit is contained in:
@@ -131,10 +131,10 @@ jobs:
|
|||||||
echo "No coverage data found; skipping summary."
|
echo "No coverage data found; skipping summary."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# SPM tests above only compile the macOS slice; this job covers the
|
# SPM tests do not link the shipping app targets. This job covers the
|
||||||
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
# iOS-conditional paths and both universal Release link configurations.
|
||||||
ios-build:
|
ios-build:
|
||||||
name: Build iOS app (simulator)
|
name: Build Release apps (universal)
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
@@ -143,14 +143,29 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Build iOS (simulator, no signing)
|
- 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: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
xcodebuild -project bitchat.xcodeproj \
|
xcodebuild -project bitchat.xcodeproj \
|
||||||
-scheme "bitchat (iOS)" \
|
-scheme "bitchat (iOS)" \
|
||||||
|
-configuration Release \
|
||||||
-sdk iphonesimulator \
|
-sdk iphonesimulator \
|
||||||
-destination 'generic/platform=iOS Simulator' \
|
-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 \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
build
|
build
|
||||||
|
|
||||||
|
|||||||
+107
-117
@@ -1,165 +1,155 @@
|
|||||||
# bitchat Privacy Policy
|
# bitchat Privacy Policy
|
||||||
|
|
||||||
*Last updated: June 2026*
|
*Last updated: July 2026*
|
||||||
|
|
||||||
## Our Commitment
|
## 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
|
## Summary
|
||||||
|
|
||||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays.
|
||||||
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
|
- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK.
|
||||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
- **No sale of data** — the project does not sell user data or build advertising profiles.
|
||||||
- **Open source** - You can verify these claims by reading our code
|
- **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**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||||
- Stored locally in your device's secure storage
|
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
|
||||||
- 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**
|
3. **Private group state**
|
||||||
- The display name you choose (or auto-generated)
|
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||||
- Stored only on your device
|
- 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.
|
||||||
- Shared with peers you communicate with
|
|
||||||
|
|
||||||
3. **Message History** (if enabled)
|
4. **Queued and carried private messages**
|
||||||
- When room owners enable retention, messages are saved locally
|
- 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.
|
||||||
- Stored encrypted on your device
|
- 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.
|
||||||
- You can delete this at any time
|
- A panic wipe deletes both stores.
|
||||||
|
|
||||||
4. **Favorite Peers**
|
5. **Recent public mesh messages and notices**
|
||||||
- Public keys of peers you mark as favorites
|
- 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.
|
||||||
- Stored only on your device
|
- 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.
|
||||||
- Allows you to recognize these peers in future sessions
|
- These items are public to the mesh or board where they are posted; they are not confidential messages.
|
||||||
|
|
||||||
5. **Optional Location Channel State**
|
6. **Media attachments**
|
||||||
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
|
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
|
||||||
- Stored locally on your device so the location-channel UI can restore your choices
|
- 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.
|
||||||
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
|
|
||||||
- Exact latitude and longitude are not persisted by bitchat
|
|
||||||
|
|
||||||
### 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:
|
## Temporary Session Data
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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:
|
### With Nearby Mesh Users
|
||||||
- Your chosen nickname
|
|
||||||
- Your ephemeral public key (changes each session)
|
|
||||||
- Messages you send to public rooms or directly to them
|
|
||||||
- Your approximate Bluetooth signal strength (for connection quality)
|
|
||||||
|
|
||||||
### With Room Members
|
Depending on the feature you use, nearby peers can receive:
|
||||||
|
|
||||||
When you join a password-protected room:
|
- Your chosen nickname and public Noise/signing identity material.
|
||||||
- Your messages are visible to others with the password
|
- 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.
|
||||||
- Your nickname appears in the member list
|
- Public mesh messages, public notices, and group-control packets you intentionally send.
|
||||||
- Room owners can see you've joined
|
- 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:
|
### With Private Group Members
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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**:
|
### With Nostr Relays and Internet Gateways
|
||||||
- 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
|
|
||||||
|
|
||||||
## Encryption
|
Internet-backed features are optional. When enabled or used:
|
||||||
|
|
||||||
All private messages use end-to-end encryption:
|
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
||||||
- **X25519** for key exchange
|
- 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.
|
||||||
- **AES-256-GCM** for message encryption
|
- 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.
|
||||||
- **Ed25519** for digital signatures
|
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||||
- **Argon2id** for password-protected rooms
|
- 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:
|
## Location and Apple Services
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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:
|
- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
|
||||||
- Used only for peer-to-peer communication
|
- A selected geohash can still reveal an approximate area to peers and relays.
|
||||||
- Bluetooth is not used for tracking
|
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
|
||||||
- You can revoke this permission at any time in system settings
|
- 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:
|
- 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.
|
||||||
- Used to compute local geohash channels and display names
|
- Voice-note and live-audio files can remain in Application Support under the media retention rules above.
|
||||||
- Requested as when-in-use permission
|
- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
|
||||||
- Exact coordinates are not shared in messages or stored by bitchat
|
- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
|
||||||
- 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
|
## 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
|
## 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.
|
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.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Changes to This Policy
|
## Changes to This Policy
|
||||||
|
|
||||||
If we update this policy:
|
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.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
bitchat is an open source project. For privacy questions:
|
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
|
- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
|
||||||
|
- Open an issue on GitHub.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*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 */ = {
|
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
ShareViewController.swift,
|
Info.plist,
|
||||||
|
bitchatShareExtension.entitlements,
|
||||||
);
|
);
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||||
};
|
};
|
||||||
@@ -258,9 +259,13 @@
|
|||||||
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
|
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
|
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
|
||||||
|
7E9B64F63F93443FB7BA12DF /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||||
|
);
|
||||||
name = bitchatShareExtension;
|
name = bitchatShareExtension;
|
||||||
productName = bitchatShareExtension;
|
productName = bitchatShareExtension;
|
||||||
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
|
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
|
||||||
@@ -388,6 +393,13 @@
|
|||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
7E9B64F63F93443FB7BA12DF /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
|||||||
@@ -313,21 +313,29 @@ private extension AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkForSharedContent() {
|
func checkForSharedContent() {
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||||
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||||
|
clearSharedContent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||||
|
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
clearSharedContent()
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
|
||||||
|
|
||||||
switch contentKind {
|
switch contentKind {
|
||||||
case .url:
|
case .url:
|
||||||
|
|||||||
@@ -229,15 +229,17 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
// sending < sent < carried < delivered < read. A late `.sent` write
|
// sending < sent < carried < delivered < read. A late `.sent` write
|
||||||
// (e.g. the optimistic stamp after routing) must not clobber the
|
// (e.g. the optimistic stamp after routing) must not clobber the
|
||||||
// `.carried` the router already set when it handed a copy to a
|
// `.carried` the router already set when it handed a copy to a
|
||||||
// courier/bridge, nor a `.delivered`/`.read` ack. Same for the
|
// 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
|
// `.sending` stamp a pre-handshake resend emits asynchronously: it
|
||||||
// can land after the message already reached `.sent`, and "Sent" was
|
// can land after the message already reached `.sent`, and "Sent" was
|
||||||
// already truthful. (`.failed` → `.sending` stays allowed so a real
|
// already truthful. (`.failed` → `.sending` stays allowed so a real
|
||||||
// failure retry is visible.)
|
// failure retry is visible.)
|
||||||
switch (current, new) {
|
switch (current, new) {
|
||||||
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending):
|
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed):
|
||||||
return true
|
return true
|
||||||
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending):
|
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed):
|
||||||
return true
|
return true
|
||||||
case (.carried, .sent), (.carried, .sending):
|
case (.carried, .sent), (.carried, .sending):
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class NearbyNotesCounter: ObservableObject {
|
|||||||
private var manager: LocationNotesManager?
|
private var manager: LocationNotesManager?
|
||||||
private var managerCancellable: AnyCancellable?
|
private var managerCancellable: AnyCancellable?
|
||||||
private var channelsCancellable: AnyCancellable?
|
private var channelsCancellable: AnyCancellable?
|
||||||
|
private var permissionCancellable: AnyCancellable?
|
||||||
private var settingCancellable: AnyCancellable?
|
private var settingCancellable: AnyCancellable?
|
||||||
private var activeHolders = 0
|
private var activeHolders = 0
|
||||||
private let locationManager: LocationChannelManager
|
private let locationManager: LocationChannelManager
|
||||||
@@ -73,6 +74,13 @@ final class NearbyNotesCounter: ObservableObject {
|
|||||||
channelsCancellable = locationManager.$availableChannels
|
channelsCancellable = locationManager.$availableChannels
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in self?.retarget() }
|
.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
|
// The app-info kill switch must take effect immediately, not on the
|
||||||
// next location change or remount.
|
// next location change or remount.
|
||||||
settingCancellable = NotificationCenter.default
|
settingCancellable = NotificationCenter.default
|
||||||
@@ -86,6 +94,7 @@ final class NearbyNotesCounter: ObservableObject {
|
|||||||
activeHolders = max(0, activeHolders - 1)
|
activeHolders = max(0, activeHolders - 1)
|
||||||
guard activeHolders == 0 else { return }
|
guard activeHolders == 0 else { return }
|
||||||
channelsCancellable = nil
|
channelsCancellable = nil
|
||||||
|
permissionCancellable = nil
|
||||||
settingCancellable = nil
|
settingCancellable = nil
|
||||||
managerCancellable = nil
|
managerCancellable = nil
|
||||||
releaseManager(manager)
|
releaseManager(manager)
|
||||||
|
|||||||
@@ -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>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import AVFoundation
|
@preconcurrency import AVFoundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
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.
|
/// Plays one inbound live voice burst with a small jitter buffer.
|
||||||
///
|
///
|
||||||
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
|
/// 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
|
/// buffer arrives, which self-heals timing without explicit silence
|
||||||
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
||||||
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
|
/// 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
|
@MainActor
|
||||||
final class PTTBurstPlayer {
|
final class PTTBurstPlayer {
|
||||||
private let engine = AVAudioEngine()
|
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
|
||||||
private let node = AVAudioPlayerNode()
|
/// 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 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 queuedBuffers: [AVAudioPCMBuffer] = []
|
||||||
private var queuedDuration: TimeInterval = 0
|
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 engineStarted = false
|
||||||
private var finished = 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 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
|
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 }
|
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
||||||
self.decoder = decoder
|
self.decoder = decoder
|
||||||
engine.attach(node)
|
self.coordinator = coordinator ?? .shared
|
||||||
engine.connect(node, to: engine.mainMixerNode, format: format)
|
self.exclusivity = exclusivity ?? .shared
|
||||||
|
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
|
||||||
|
self.makeEngine = factory
|
||||||
|
self.engine = factory()
|
||||||
|
|
||||||
deadlineTask = Task { [weak self] in
|
deadlineTask = Task { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
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
|
/// Decodes and queues frames (in burst order). Starts playback when the
|
||||||
/// jitter buffer fills.
|
/// jitter buffer fills.
|
||||||
func enqueue(_ frames: [Data]) {
|
func enqueue(_ frames: [Data]) {
|
||||||
@@ -64,49 +261,119 @@ final class PTTBurstPlayer {
|
|||||||
/// The burst ended: stop once everything scheduled has played out.
|
/// The burst ended: stop once everything scheduled has played out.
|
||||||
func finishAfterDrain() {
|
func finishAfterDrain() {
|
||||||
finished = true
|
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()
|
stopIfDrained()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Immediate stop (cancel, another playback taking over, teardown).
|
/// Immediate stop (cancel, another playback taking over, interruption,
|
||||||
|
/// teardown).
|
||||||
func stop() {
|
func stop() {
|
||||||
guard !stopped else { return }
|
guard !stopped else { return }
|
||||||
stopped = true
|
stopped = true
|
||||||
deadlineTask?.cancel()
|
deadlineTask?.cancel()
|
||||||
|
removeConfigObserver()
|
||||||
queuedBuffers = []
|
queuedBuffers = []
|
||||||
|
retireScheduledBuffers()
|
||||||
if engineStarted {
|
if engineStarted {
|
||||||
node.stop()
|
|
||||||
engine.stop()
|
engine.stop()
|
||||||
}
|
}
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
releaseSessionToken()
|
||||||
|
exclusivity.deactivate(self)
|
||||||
|
onStopped?()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startIfReady(force: Bool) {
|
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 }
|
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||||
|
|
||||||
#if os(iOS)
|
// Acquiring the session suspends for its blocking IPC (off the main
|
||||||
do {
|
// actor); frames arriving meanwhile keep queueing and are flushed
|
||||||
let session = AVAudioSession.sharedInstance()
|
// onto the engine once it starts.
|
||||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
acquiringSession = true
|
||||||
try session.setActive(true, options: [])
|
playbackReservation = exclusivity.reserve(self)
|
||||||
} catch {
|
Task { [weak self] in
|
||||||
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
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 {
|
do {
|
||||||
try engine.start()
|
try engine.start()
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
// A capture racing this start can reconfigure the session while
|
||||||
stopped = true
|
// the engine spins up (its escalation fan-out no-ops on a player
|
||||||
return
|
// 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
|
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
|
isPlaying = true
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
engine.play()
|
||||||
node.play()
|
|
||||||
|
|
||||||
let buffered = queuedBuffers
|
let buffered = queuedBuffers
|
||||||
queuedBuffers = []
|
queuedBuffers = []
|
||||||
@@ -116,21 +383,119 @@ final class PTTBurstPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
/// The audio session was reconfigured underneath the running engine
|
||||||
scheduledCount += 1
|
/// (category escalation for talk-over, or an engine configuration
|
||||||
node.scheduleBuffer(buffer) { [weak self] in
|
/// 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
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
self?.restartEngine()
|
||||||
self.scheduledCount -= 1
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
self.stopIfDrained()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func retireScheduledBuffers() {
|
||||||
|
engineGeneration += 1
|
||||||
|
for scheduled in scheduledBuffers {
|
||||||
|
_ = scheduled.completionState.retireIfPending()
|
||||||
|
}
|
||||||
|
scheduledBuffers = []
|
||||||
|
}
|
||||||
|
|
||||||
private func stopIfDrained() {
|
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()
|
stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func releaseSessionToken() {
|
||||||
|
sessionToken.map(coordinator.release)
|
||||||
|
sessionToken = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PTTBurstPlayer: ExclusivePlayback {
|
extension PTTBurstPlayer: ExclusivePlayback {
|
||||||
|
|||||||
@@ -10,12 +10,85 @@ import AVFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
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:
|
/// Captures microphone audio for a live push-to-talk burst, producing both:
|
||||||
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
||||||
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
||||||
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
||||||
/// handles delivery to receivers that missed the live stream.
|
/// 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
|
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
|
||||||
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
||||||
private static let maxCaptureDuration: TimeInterval = 120
|
private static let maxCaptureDuration: TimeInterval = 120
|
||||||
@@ -26,6 +99,9 @@ final class PTTCaptureEngine {
|
|||||||
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
||||||
private var engine = AVAudioEngine()
|
private var engine = AVAudioEngine()
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
|
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.
|
// Capture-queue-confined state.
|
||||||
private var resampler: PTTInputResampler?
|
private var resampler: PTTInputResampler?
|
||||||
@@ -35,10 +111,10 @@ final class PTTCaptureEngine {
|
|||||||
private var encodedFrameCount = 0
|
private var encodedFrameCount = 0
|
||||||
private var running = false
|
private var running = false
|
||||||
private var captureStart = Date()
|
private var captureStart = Date()
|
||||||
/// Whether `engine.start()` succeeded for the current capture (main-actor
|
/// Whether `engine.start()` succeeded for the current capture
|
||||||
/// callers only; see `stopEngineIfStarted`).
|
/// (see `stopEngineIfStarted`).
|
||||||
private var engineStarted = false
|
@MainActor private var engineStarted = false
|
||||||
|
@MainActor private var configChangeObserver: NSObjectProtocol?
|
||||||
/// Called on the capture queue with each batch of encoded AAC frames.
|
/// Called on the capture queue with each batch of encoded AAC frames.
|
||||||
var onFrames: (([Data]) -> Void)?
|
var onFrames: (([Data]) -> Void)?
|
||||||
|
|
||||||
@@ -47,11 +123,41 @@ final class PTTCaptureEngine {
|
|||||||
case audioSetupFailed
|
case audioSetupFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
func start(outputURL: URL) throws {
|
init(coordinator: AudioSessionCoordinator = .shared) {
|
||||||
#if os(iOS)
|
self.coordinator = coordinator
|
||||||
try Self.configureAudioSession()
|
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
|
||||||
#endif
|
}
|
||||||
|
|
||||||
|
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
|
// Fresh engine per capture so its input unit binds to the session
|
||||||
// that is active *now* (see `engine` doc comment).
|
// that is active *now* (see `engine` doc comment).
|
||||||
engine = AVAudioEngine()
|
engine = AVAudioEngine()
|
||||||
@@ -83,13 +189,30 @@ final class PTTCaptureEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
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()
|
engine.prepare()
|
||||||
do {
|
do {
|
||||||
try engine.start()
|
try engine.start()
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
|
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)
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
queue.sync { self.teardown(deleteFile: true) }
|
queue.sync { self.teardown(deleteFile: true) }
|
||||||
throw error
|
throw error
|
||||||
@@ -100,7 +223,9 @@ final class PTTCaptureEngine {
|
|||||||
|
|
||||||
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
||||||
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
||||||
|
@MainActor
|
||||||
func stop() -> (url: URL?, encodedFrames: Int) {
|
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||||
|
captureGeneration.invalidate()
|
||||||
stopEngineIfStarted()
|
stopEngineIfStarted()
|
||||||
let result: (URL?, Int) = queue.sync {
|
let result: (URL?, Int) = queue.sync {
|
||||||
let url = fileURL
|
let url = fileURL
|
||||||
@@ -108,34 +233,70 @@ final class PTTCaptureEngine {
|
|||||||
teardown(deleteFile: false)
|
teardown(deleteFile: false)
|
||||||
return (url, frames)
|
return (url, frames)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
releaseSessionToken()
|
||||||
Self.deactivateAudioSession()
|
|
||||||
#endif
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func cancel() {
|
func cancel() {
|
||||||
|
captureGeneration.invalidate()
|
||||||
stopEngineIfStarted()
|
stopEngineIfStarted()
|
||||||
queue.sync { teardown(deleteFile: true) }
|
queue.sync { teardown(deleteFile: true) }
|
||||||
#if os(iOS)
|
releaseSessionToken()
|
||||||
Self.deactivateAudioSession()
|
}
|
||||||
#endif
|
|
||||||
|
/// 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
|
/// Touching `inputNode` on an engine that never started instantiates its
|
||||||
/// input unit against whatever session is active and spams AURemoteIO
|
/// input unit against whatever session is active and spams AURemoteIO
|
||||||
/// errors — a canceled-before-start hold must not touch the engine.
|
/// errors — a canceled-before-start hold must not touch the engine.
|
||||||
|
@MainActor
|
||||||
private func stopEngineIfStarted() {
|
private func stopEngineIfStarted() {
|
||||||
|
if let observer = configChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
configChangeObserver = nil
|
||||||
|
}
|
||||||
guard engineStarted else { return }
|
guard engineStarted else { return }
|
||||||
engineStarted = false
|
engineStarted = false
|
||||||
engine.inputNode.removeTap(onBus: 0)
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
engine.stop()
|
engine.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func releaseSessionToken() {
|
||||||
|
sessionLease.release()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Capture queue
|
// MARK: - Capture queue
|
||||||
|
|
||||||
private func process(_ buffer: AVAudioPCMBuffer) {
|
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
|
||||||
guard running,
|
guard captureGeneration.isCurrent(generation),
|
||||||
|
running,
|
||||||
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
||||||
let resampled = resampler?.resample(buffer)
|
let resampled = resampler?.resample(buffer)
|
||||||
else { return }
|
else { return }
|
||||||
@@ -162,22 +323,4 @@ final class PTTCaptureEngine {
|
|||||||
}
|
}
|
||||||
fileURL = nil
|
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`.
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||||
|
private let recorder: VoiceRecorder
|
||||||
|
private let owner = VoiceRecorder.RecordingOwner()
|
||||||
|
|
||||||
var isLive: Bool { false }
|
var isLive: Bool { false }
|
||||||
|
|
||||||
|
init(recorder: VoiceRecorder = .shared) {
|
||||||
|
self.recorder = recorder
|
||||||
|
}
|
||||||
|
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
await VoiceRecorder.shared.requestPermission()
|
await recorder.requestPermission()
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() async throws {
|
func start() async throws {
|
||||||
try await VoiceRecorder.shared.startRecording()
|
try await recorder.startRecording(owner: owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
func finish() async -> URL? {
|
func finish() async -> URL? {
|
||||||
await VoiceRecorder.shared.stopRecording()
|
await recorder.stopRecording(owner: owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() async {
|
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
|
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
|
||||||
/// recording, then finalizes the same audio as a standard voice note whose
|
/// recording, then finalizes the same audio as a standard voice note whose
|
||||||
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
||||||
@@ -60,7 +80,8 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|||||||
let burstID: Data
|
let burstID: Data
|
||||||
|
|
||||||
private let sendPacket: (Data) -> Void
|
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
|
/// Capture-queue-confined stream state: packetizes frames and lazily
|
||||||
/// emits START so packet order is guaranteed by queue serialization.
|
/// emits START so packet order is guaranteed by queue serialization.
|
||||||
private final class StreamState {
|
private final class StreamState {
|
||||||
@@ -79,10 +100,17 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|||||||
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
||||||
/// target peer; must be safe to call from any queue (BLEService hops to
|
/// target peer; must be safe to call from any queue (BLEService hops to
|
||||||
/// its own message queue internally).
|
/// its own message queue internally).
|
||||||
init(sendPacket: @escaping (Data) -> Void) {
|
init(
|
||||||
self.burstID = VoiceBurstPacket.makeBurstID()
|
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.sendPacket = sendPacket
|
||||||
self.stream = StreamState(burstID: burstID)
|
self.capture = capture ?? PTTCaptureEngine()
|
||||||
|
self.now = now
|
||||||
|
self.stream = StreamState(burstID: self.burstID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
@@ -117,7 +145,15 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
do {
|
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 {
|
} catch {
|
||||||
// The HAL can briefly report a dead input right after the audio
|
// The HAL can briefly report a dead input right after the audio
|
||||||
// session (re)activates while the route settles; one retry after
|
// session (re)activates while the route settles; one retry after
|
||||||
@@ -131,9 +167,9 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|||||||
capture.cancel()
|
capture.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try capture.start(outputURL: outputURL)
|
try await capture.start(outputURL: outputURL)
|
||||||
}
|
}
|
||||||
startDate = Date()
|
startDate = now()
|
||||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,11 +177,16 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|||||||
guard !completed else { return nil }
|
guard !completed else { return nil }
|
||||||
completed = true
|
completed = true
|
||||||
|
|
||||||
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
|
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
|
||||||
let (url, encodedFrames) = capture.stop()
|
let (url, encodedFrames) = capture.stop()
|
||||||
// stop() drained the capture queue, so touching `stream` is safe now.
|
// 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)
|
sendControlPacket(.canceled)
|
||||||
if let url {
|
if let url {
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
@@ -156,7 +197,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|||||||
for packet in stream.packetizer.flush() {
|
for packet in stream.packetizer.flush() {
|
||||||
sendPacket(packet)
|
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))
|
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)
|
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
@Published private(set) var duration: TimeInterval = 0
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
@Published private(set) var progress: Double = 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"
|
/// rounded so 4.9s shows "00:05"
|
||||||
var roundedDuration: Int {
|
var roundedDuration: Int {
|
||||||
guard duration.isFinite else { return 0 }
|
guard duration.isFinite else { return 0 }
|
||||||
@@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
private var player: AVAudioPlayer?
|
private var player: AVAudioPlayer?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var url: URL
|
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.url = url
|
||||||
|
self.sessionCoordinatorOverride = sessionCoordinator
|
||||||
|
self.exclusivity = exclusivity ?? .shared
|
||||||
super.init()
|
super.init()
|
||||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
// 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 {
|
deinit {
|
||||||
timer?.invalidate()
|
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) {
|
func replaceURL(_ url: URL) {
|
||||||
@@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
guard ensurePlayerReady() else { return }
|
guard ensurePlayerReady() else { return }
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
exclusivity.activate(self)
|
||||||
player?.play()
|
isPlaying = true
|
||||||
startTimer()
|
startTimer()
|
||||||
updateProgress()
|
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() {
|
func pause() {
|
||||||
@@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
stopTimer()
|
stopTimer()
|
||||||
updateProgress()
|
updateProgress()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
releaseSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
@@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
stopTimer()
|
stopTimer()
|
||||||
updateProgress()
|
updateProgress()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
releaseSession()
|
||||||
|
exclusivity.deactivate(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(to fraction: Double) {
|
func seek(to fraction: Double) {
|
||||||
@@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
let clamped = max(0, min(1, fraction))
|
let clamped = max(0, min(1, fraction))
|
||||||
if let player = player {
|
if let player = player {
|
||||||
player.currentTime = clamped * player.duration
|
player.currentTime = clamped * player.duration
|
||||||
if isPlaying {
|
// While the session acquire is still in flight, don't start
|
||||||
player.play()
|
// audio pre-activation — the pending acquire's completion starts
|
||||||
|
// playback (from the new position) once the session resolves.
|
||||||
|
if isPlaying, !sessionAcquireInFlight {
|
||||||
|
startPreparedPlayer()
|
||||||
}
|
}
|
||||||
updateProgress()
|
updateProgress()
|
||||||
}
|
}
|
||||||
@@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
self.stopTimer()
|
self.stopTimer()
|
||||||
self.updateProgress()
|
self.updateProgress()
|
||||||
self.isPlaying = false
|
self.isPlaying = false
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
self.releaseSession()
|
||||||
|
self.exclusivity.deactivate(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func preparePlayer(for url: URL) {
|
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 {
|
do {
|
||||||
let player = try AVAudioPlayer(contentsOf: url)
|
let player = try AVAudioPlayer(contentsOf: url)
|
||||||
player.delegate = self
|
player.delegate = self
|
||||||
player.prepareToPlay()
|
|
||||||
self.player = player
|
self.player = player
|
||||||
duration = player.duration
|
duration = player.duration
|
||||||
currentTime = player.currentTime
|
currentTime = player.currentTime
|
||||||
@@ -141,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
if player == nil {
|
if player == nil {
|
||||||
preparePlayer(for: url)
|
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
|
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() {
|
private func startTimer() {
|
||||||
if timer != nil { return }
|
if timer != nil { return }
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
@@ -197,21 +299,59 @@ extension VoiceNotePlaybackController: ExclusivePlayback {
|
|||||||
final class VoiceNotePlaybackCoordinator {
|
final class VoiceNotePlaybackCoordinator {
|
||||||
static let shared = VoiceNotePlaybackCoordinator()
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
|
struct Reservation: Equatable {
|
||||||
|
fileprivate let generation: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
private weak var activeController: (any ExclusivePlayback)?
|
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 {
|
if activeController === controller {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
activeController?.pauseForExclusivity()
|
activeController?.pauseForExclusivity()
|
||||||
activeController = controller
|
activeController = controller
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
|
||||||
|
latestReservation == reservation && latestReservedController === controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate(_ controller: any ExclusivePlayback) {
|
func deactivate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
activeController = nil
|
activeController = nil
|
||||||
}
|
}
|
||||||
|
if latestReservedController === controller {
|
||||||
|
latestReservedController = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,95 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
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.
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
actor VoiceRecorder {
|
actor VoiceRecorder {
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error, Equatable {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recordingInProgress
|
case recordingInProgress
|
||||||
|
case failedToStartRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
static let shared = VoiceRecorder()
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
|
||||||
static let minRecordingDuration: TimeInterval = 1
|
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 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
|
// MARK: - Permissions
|
||||||
|
|
||||||
@@ -41,82 +115,130 @@ actor VoiceRecorder {
|
|||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func startRecording() throws -> URL {
|
func startRecording(owner: RecordingOwner) async throws -> URL {
|
||||||
if recorder?.isRecording == true {
|
if activeOwner != nil {
|
||||||
throw RecorderError.recordingInProgress
|
throw RecorderError.recordingInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
guard permissionGranted() else {
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
guard session.recordPermission == .granted else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
throw RecorderError.microphoneAccessDenied
|
||||||
}
|
}
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// allowBluetoothHFP is not available on iOS Simulator
|
activeOwner = owner
|
||||||
try session.setCategory(
|
startInFlight = true
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
// The acquire suspends while the blocking session IPC runs on the
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
// coordinator's queue (never this actor's thread or main).
|
||||||
)
|
let token: AudioSessionCoordinator.Token
|
||||||
#else
|
do {
|
||||||
try session.setCategory(
|
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
|
||||||
.playAndRecord,
|
Task { await self?.handleSessionInterruption(for: owner) }
|
||||||
mode: .default,
|
}
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
} catch {
|
||||||
)
|
guard activeOwner === owner else {
|
||||||
#endif
|
throw CancellationError()
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
}
|
||||||
#endif
|
startInFlight = false
|
||||||
#if os(macOS)
|
activeOwner = nil
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
throw error
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
// Actor reentrancy: release/cancel may have ended this hold while the
|
||||||
let settings: [String: Any] = [
|
// blocking session activation was still in progress.
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
guard activeOwner === owner, startInFlight else {
|
||||||
AVSampleRateKey: 16_000,
|
sessionCoordinator.release(token)
|
||||||
AVNumberOfChannelsKey: 1,
|
throw CancellationError()
|
||||||
AVEncoderBitRateKey: 16_000
|
}
|
||||||
]
|
startInFlight = false
|
||||||
|
sessionToken = token
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
var outputURL: URL?
|
||||||
audioRecorder.isMeteringEnabled = true
|
do {
|
||||||
audioRecorder.prepareToRecord()
|
let newURL = try makeOutputURL()
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
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
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = newURL
|
||||||
return outputURL
|
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? {
|
func stopRecording(owner: RecordingOwner) async -> URL? {
|
||||||
guard let recorder, recorder.isRecording else {
|
guard activeOwner === owner else { return nil }
|
||||||
return currentURL
|
|
||||||
|
// `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
|
let sessionURL = currentURL
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
if activeRecorder.isRecording, paddingInterval > 0 {
|
||||||
|
if let waitForStopPadding = testingHooks.waitForStopPadding {
|
||||||
recorder.stop()
|
await waitForStopPadding(paddingInterval)
|
||||||
|
} else {
|
||||||
// A new session may have started during the sleep — don't touch its state
|
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||||
if self.recorder === recorder {
|
}
|
||||||
cleanupSession()
|
|
||||||
self.recorder = nil
|
|
||||||
currentURL = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
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 {
|
if let recorder, recorder.isRecording {
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
}
|
}
|
||||||
cleanupSession()
|
releaseSessionToken()
|
||||||
if let currentURL {
|
if let currentURL {
|
||||||
try? FileManager.default.removeItem(at: currentURL)
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
}
|
}
|
||||||
@@ -124,14 +246,45 @@ actor VoiceRecorder {
|
|||||||
currentURL = nil
|
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
|
// 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 {
|
private func makeOutputURL() throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
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)
|
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return baseDirectory.appendingPathComponent(fileName)
|
return baseDirectory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
@@ -146,9 +299,11 @@ actor VoiceRecorder {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupSession() {
|
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
|
||||||
#if os(iOS)
|
/// onto its own queue.
|
||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
private func releaseSessionToken() {
|
||||||
#endif
|
guard let token = sessionToken else { return }
|
||||||
|
sessionToken = nil
|
||||||
|
sessionCoordinator.release(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -31,6 +31,8 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.social-networking</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
@@ -40,9 +42,9 @@
|
|||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<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>
|
<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>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
@@ -268,10 +268,9 @@ struct NostrProtocol {
|
|||||||
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
|
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
|
||||||
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
|
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
|
||||||
/// parsers, which key their dedup on `m[1]` unconditionally and need it
|
/// parsers, which key their dedup on `m[1]` unconditionally and need it
|
||||||
/// per-message-unique; current parsers ignore it and recompute the ID
|
/// per-message-unique. Current parsers key bridge rows by the authenticated
|
||||||
/// from the origin coordinates (elements 2-3) plus the event's own
|
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
|
||||||
/// content to dedup the bridged copy against the radio copy by
|
/// coordinates are public and cannot authenticate the Nostr signer.
|
||||||
/// timeline ID.
|
|
||||||
static func createBridgeMeshEvent(
|
static func createBridgeMeshEvent(
|
||||||
content: String,
|
content: String,
|
||||||
cell: String,
|
cell: String,
|
||||||
|
|||||||
@@ -216,6 +216,15 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
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
|
// Total pending sends dropped at the queue cap; drives the sampled
|
||||||
// overflow warning (first + every Nth drop).
|
// overflow warning (first + every Nth drop).
|
||||||
private var pendingSendDropCount = 0
|
private var pendingSendDropCount = 0
|
||||||
@@ -312,6 +321,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
for (_, tracker) in trackers {
|
for (_, tracker) in trackers {
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
}
|
}
|
||||||
|
let confirmed = confirmedSends.values.map(\.completion)
|
||||||
|
confirmedSends.removeAll()
|
||||||
|
confirmed.forEach { $0(false) }
|
||||||
pendingTorConnectionURLs.removeAll()
|
pendingTorConnectionURLs.removeAll()
|
||||||
awaitingTorForConnections = false
|
awaitingTorForConnections = false
|
||||||
torReadyWaitAttempts = 0
|
torReadyWaitAttempts = 0
|
||||||
@@ -345,6 +357,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||||
inboundEventLogCount = 0
|
inboundEventLogCount = 0
|
||||||
Self.pendingGiftWrapIDs.removeAll()
|
Self.pendingGiftWrapIDs.removeAll()
|
||||||
|
confirmedSends.removeAll()
|
||||||
|
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
messageQueue.removeAll()
|
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>) {
|
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
||||||
@@ -923,6 +1027,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Send initial ping to verify connection
|
// Send initial ping to verify connection
|
||||||
task.sendPing { [weak self] error in
|
task.sendPing { [weak self] error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
guard self?.connections[urlString] === task else { return }
|
||||||
if error == nil {
|
if error == nil {
|
||||||
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
|
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
|
||||||
self?.updateRelayStatus(urlString, isConnected: true)
|
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)
|
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
|
||||||
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
||||||
// Trigger disconnection handler for proper backoff
|
// 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) {
|
Task.detached(priority: .utility) {
|
||||||
guard let parsed = ParsedInbound(message) else { return }
|
guard let parsed = ParsedInbound(message) else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard self.connections[relayUrl] === task else { return }
|
||||||
self.handleParsedMessage(parsed, from: relayUrl)
|
self.handleParsedMessage(parsed, from: relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue receiving
|
// Continue receiving
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard self.connections[relayUrl] === task else { return }
|
||||||
self.receiveMessage(from: task, relayUrl: relayUrl)
|
self.receiveMessage(from: task, relayUrl: relayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
DispatchQueue.main.async {
|
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):
|
case .ok(let eventId, let success, let reason):
|
||||||
|
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
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)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -1084,17 +1201,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
|
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
|
||||||
|
completion?(false)
|
||||||
} else {
|
} else {
|
||||||
// SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session)
|
// SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session)
|
||||||
// Update relay stats
|
// Update relay stats
|
||||||
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
self?.relays[index].messagesSent += 1
|
self?.relays[index].messagesSent += 1
|
||||||
}
|
}
|
||||||
|
completion?(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
||||||
|
completion?(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,9 +1278,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
eoseTrackers[id] = tracker
|
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)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.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)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
settleEOSETrackers(droppingRelay: relayUrl)
|
settleEOSETrackers(droppingRelay: relayUrl)
|
||||||
// If networking is disallowed, do not schedule reconnection
|
// 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>
|
||||||
@@ -14,14 +14,11 @@ import Foundation
|
|||||||
/// The BLE wire carries no message ID for public broadcasts, so every device
|
/// 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,
|
/// recomputes the same stable ID from the signed wire fields (sender ID,
|
||||||
/// millisecond timestamp, content). That gives the mesh bridge a
|
/// millisecond timestamp, content). That gives the mesh bridge a
|
||||||
/// cross-device-consistent dedup/attribution key with zero wire change — and
|
/// cross-device-consistent radio identity with zero wire change. Bridge events
|
||||||
/// because receivers derive the key themselves instead of trusting a claimed
|
/// carry this value only as a hint for detecting a radio copy that is already
|
||||||
/// ID, forging a bridge tag that binds a chosen ID to *different* content is
|
/// present: sender/timestamp/content are public, so a different Nostr signer
|
||||||
/// infeasible. The inputs are cleartext on the radio, though: an attacker in
|
/// can copy them and must never be allowed to reserve the genuine event's
|
||||||
/// radio range can re-sign the identical sender/timestamp/content under
|
/// authenticated dedup slot.
|
||||||
/// their own Nostr key and win first-wins injection on remote islands —
|
|
||||||
/// duplicate-content spoofing the unbridged mesh already permits, so no
|
|
||||||
/// worse than before.
|
|
||||||
enum MeshMessageIdentity {
|
enum MeshMessageIdentity {
|
||||||
/// Matches the wire truncation in `BLEService.sendMessage`.
|
/// Matches the wire truncation in `BLEService.sendMessage`.
|
||||||
static func millisecondTimestamp(_ date: Date) -> UInt64 {
|
static func millisecondTimestamp(_ date: Date) -> UInt64 {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ enum BLEFanoutSelector {
|
|||||||
preferredPeripheralPerPeer: [PeerID: String] = [:],
|
preferredPeripheralPerPeer: [PeerID: String] = [:],
|
||||||
collapseDuplicatePeerLinks: Bool = true,
|
collapseDuplicatePeerLinks: Bool = true,
|
||||||
directedPeerHint: PeerID?,
|
directedPeerHint: PeerID?,
|
||||||
|
requireDirectPeerLink: Bool = false,
|
||||||
packetType: UInt8,
|
packetType: UInt8,
|
||||||
messageID: String
|
messageID: String
|
||||||
) -> BLEFanoutSelection {
|
) -> BLEFanoutSelection {
|
||||||
@@ -38,6 +39,9 @@ enum BLEFanoutSelector {
|
|||||||
) {
|
) {
|
||||||
return directedSelection
|
return directedSelection
|
||||||
}
|
}
|
||||||
|
if directedPeerHint != nil, requireDirectPeerLink {
|
||||||
|
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||||
|
}
|
||||||
if let directedPeerHint,
|
if let directedPeerHint,
|
||||||
hasBoundLink(
|
hasBoundLink(
|
||||||
to: directedPeerHint,
|
to: directedPeerHint,
|
||||||
|
|||||||
@@ -7,6 +7,26 @@ struct BLEOutboundFragmentTransferRequest {
|
|||||||
let maxChunk: Int?
|
let maxChunk: Int?
|
||||||
let directedPeer: PeerID?
|
let directedPeer: PeerID?
|
||||||
let transferId: String?
|
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? {
|
var resolvedTransferId: String? {
|
||||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||||
@@ -22,6 +42,21 @@ struct BLEOutboundFragmentTransferRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
struct BLEOutboundFragmentTransferScheduler {
|
||||||
enum QueuePosition {
|
enum QueuePosition {
|
||||||
case front
|
case front
|
||||||
@@ -31,6 +66,11 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
enum SubmitResult {
|
enum SubmitResult {
|
||||||
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
||||||
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
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
|
/// The same file is already being (or waiting to be) fragmented out
|
||||||
/// to an audience covering this request; sending it again would just
|
/// to an audience covering this request; sending it again would just
|
||||||
/// double the airtime (field-verified: one 41KB voice file went out
|
/// double the airtime (field-verified: one 41KB voice file went out
|
||||||
@@ -113,11 +153,17 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
if request.requireDirectPeerLink {
|
||||||
|
return .rejectedStrict(request: request, transferId: transferId)
|
||||||
|
}
|
||||||
pendingTransfers.append(request)
|
pendingTransfers.append(request)
|
||||||
return .queued(request: request, transferId: transferId, position: .back)
|
return .queued(request: request, transferId: transferId, position: .back)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard activeTransfers[transferId] == nil else {
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
if request.requireDirectPeerLink {
|
||||||
|
return .rejectedStrict(request: request, transferId: transferId)
|
||||||
|
}
|
||||||
pendingTransfers.insert(request, at: 0)
|
pendingTransfers.insert(request, at: 0)
|
||||||
return .queued(request: request, transferId: transferId, position: .front)
|
return .queued(request: request, transferId: transferId, position: .front)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,9 @@ enum BLEOutboundLinkPlanner {
|
|||||||
centralPeerBindings: [String: PeerID] = [:],
|
centralPeerBindings: [String: PeerID] = [:],
|
||||||
preferredPeripheralPerPeer: [PeerID: String] = [:],
|
preferredPeripheralPerPeer: [PeerID: String] = [:],
|
||||||
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
|
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
|
||||||
directedOnlyPeer: PeerID?
|
directedOnlyPeer: PeerID?,
|
||||||
|
requireDirectPeerLink: Bool = false
|
||||||
) -> BLEOutboundLinkPlan {
|
) -> 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)
|
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
|
||||||
// Direct announces bypass the per-peer duplicate-link collapse so
|
// Direct announces bypass the per-peer duplicate-link collapse so
|
||||||
// every live link gets bound (see BLEFanoutSelector.selectLinks).
|
// every live link gets bound (see BLEFanoutSelector.selectLinks).
|
||||||
@@ -51,10 +39,34 @@ enum BLEOutboundLinkPlanner {
|
|||||||
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
|
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
|
||||||
collapseDuplicatePeerLinks: !isDirectAnnounce,
|
collapseDuplicatePeerLinks: !isDirectAnnounce,
|
||||||
directedPeerHint: directedPeerHint,
|
directedPeerHint: directedPeerHint,
|
||||||
|
requireDirectPeerLink: requireDirectPeerLink,
|
||||||
packetType: packet.type,
|
packetType: packet.type,
|
||||||
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
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(
|
return BLEOutboundLinkPlan(
|
||||||
directedPeerHint: directedPeerHint,
|
directedPeerHint: directedPeerHint,
|
||||||
fragmentChunkSize: nil,
|
fragmentChunkSize: nil,
|
||||||
|
|||||||
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
|
|||||||
guard !pending.isEmpty else { return }
|
guard !pending.isEmpty else { return }
|
||||||
notifications.insert(contentsOf: pending, at: 0)
|
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,
|
priority: BLEOutboundWritePriority,
|
||||||
capBytes: Int
|
capBytes: Int
|
||||||
) -> EnqueueResult {
|
) -> 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 {
|
guard data.count <= capBytes else {
|
||||||
return .oversized(bytes: data.count)
|
return (.oversized(bytes: data.count), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var queue = writesByPeripheralID[peripheralID] ?? []
|
var queue = writesByPeripheralID[peripheralID] ?? []
|
||||||
@@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
|
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] {
|
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
|
||||||
@@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer {
|
|||||||
return items
|
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) {
|
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
|
||||||
guard !items.isEmpty else { return }
|
guard !items.isEmpty else { return }
|
||||||
var existing = writesByPeripheralID[peripheralID] ?? []
|
var existing = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ final class BLEService: NSObject {
|
|||||||
// 1. Consolidated BLE link tracking for both central and peripheral roles.
|
// 1. Consolidated BLE link tracking for both central and peripheral roles.
|
||||||
private var linkStateStore = BLELinkStateStore()
|
private var linkStateStore = BLELinkStateStore()
|
||||||
|
|
||||||
|
// A peer ID can retain an established Noise session after its physical
|
||||||
|
// link disappears. Courier handover therefore needs the stronger fact
|
||||||
|
// that the session was established *on this current ingress link*, not
|
||||||
|
// merely that some session exists for the claimed ID. bleQueue-owned.
|
||||||
|
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
|
||||||
|
|
||||||
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
||||||
// store): entries older than the cooldown are pruned on insert.
|
// store): entries older than the cooldown are pruned on insert.
|
||||||
private var lastLinkRebindAt: [String: Date] = [:]
|
private var lastLinkRebindAt: [String: Date] = [:]
|
||||||
@@ -680,6 +686,7 @@ final class BLEService: NSObject {
|
|||||||
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
|
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
|
||||||
bleQueue.sync {
|
bleQueue.sync {
|
||||||
linkStateStore.clearAll()
|
linkStateStore.clearAll()
|
||||||
|
noiseAuthenticatedLinkOwners.removeAll()
|
||||||
connectionScheduler.reset()
|
connectionScheduler.reset()
|
||||||
subscriptionAnnounceLimiter.removeAll()
|
subscriptionAnnounceLimiter.removeAll()
|
||||||
}
|
}
|
||||||
@@ -1188,9 +1195,91 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {
|
/// Synchronously admits a notification to the link-specific retry queue.
|
||||||
|
/// Destructive courier handoff uses this result as its commit point, so a
|
||||||
|
/// full process-local queue must be reported as rejection, not success.
|
||||||
|
private func enqueuePendingNotificationIfAccepted(
|
||||||
|
data: Data,
|
||||||
|
centrals: [CBCentral],
|
||||||
|
context: String
|
||||||
|
) -> Bool {
|
||||||
|
let result = collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingNotifications.enqueue(
|
||||||
|
data: data,
|
||||||
|
targets: centrals,
|
||||||
|
capCount: TransportConfig.blePendingNotificationsCapCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case let .enqueued(count):
|
||||||
|
SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session)
|
||||||
|
return true
|
||||||
|
case let .full(count):
|
||||||
|
SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes the final authenticated-link check with CoreBluetooth's
|
||||||
|
/// notification admission on `bleQueue`, closing the rebind/disconnect
|
||||||
|
/// race between fanout planning and the actual handoff.
|
||||||
|
private func notifyOrEnqueueIfAccepted(
|
||||||
|
data: Data,
|
||||||
|
centrals: [CBCentral],
|
||||||
|
characteristic: CBMutableCharacteristic,
|
||||||
|
context: String,
|
||||||
|
requiredAuthenticatedPeer: PeerID?
|
||||||
|
) -> Bool {
|
||||||
|
let accept = { [self] in
|
||||||
|
let eligible: [CBCentral]
|
||||||
|
if let peerID = requiredAuthenticatedPeer {
|
||||||
|
eligible = centrals.filter { central in
|
||||||
|
let link = BLEIngressLinkID.central(central.identifier.uuidString)
|
||||||
|
return noiseAuthenticatedLinkOwners[link] == peerID
|
||||||
|
&& linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eligible = centrals
|
||||||
|
}
|
||||||
|
guard !eligible.isEmpty else { return false }
|
||||||
|
if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return enqueuePendingNotificationIfAccepted(
|
||||||
|
data: data,
|
||||||
|
centrals: eligible,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||||
|
return accept()
|
||||||
|
}
|
||||||
|
return bleQueue.sync(execute: accept)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true only when the packet was accepted by at least one current
|
||||||
|
/// physical link (including its link-specific backpressure queue). A
|
||||||
|
/// process-local directed spool is deliberately not success: callers
|
||||||
|
/// that own a durable upstream copy must keep it retryable.
|
||||||
|
@discardableResult
|
||||||
|
private func sendOnAllLinks(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
data: Data,
|
||||||
|
pad: Bool,
|
||||||
|
directedOnlyPeer: PeerID?,
|
||||||
|
requireDirectPeerLink: Bool = false,
|
||||||
|
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||||
|
) -> Bool {
|
||||||
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||||||
let excludedPeerLinks = links(to: ingressRecord?.peerID)
|
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
||||||
|
if requireNoiseAuthenticatedPeerLink {
|
||||||
|
guard let directedOnlyPeer else { return false }
|
||||||
|
let boundLinks = links(to: directedOnlyPeer)
|
||||||
|
let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer)
|
||||||
|
guard !authenticatedLinks.isEmpty else { return false }
|
||||||
|
excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
|
||||||
|
}
|
||||||
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
|
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
|
||||||
|
|
||||||
let states = snapshotPeripheralStates()
|
let states = snapshotPeripheralStates()
|
||||||
@@ -1222,12 +1311,22 @@ final class BLEService: NSObject {
|
|||||||
// as a combined snapshot.
|
// as a combined snapshot.
|
||||||
preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings },
|
preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings },
|
||||||
directAnnounceTTL: messageTTL,
|
directAnnounceTTL: messageTTL,
|
||||||
directedOnlyPeer: directedOnlyPeer
|
directedOnlyPeer: directedOnlyPeer,
|
||||||
|
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink
|
||||||
)
|
)
|
||||||
|
|
||||||
if let chunk = plan.fragmentChunkSize {
|
if let chunk = plan.fragmentChunkSize {
|
||||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
|
guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else {
|
||||||
return
|
return false
|
||||||
|
}
|
||||||
|
return sendFragmentedPacket(
|
||||||
|
packet,
|
||||||
|
pad: pad,
|
||||||
|
maxChunk: chunk,
|
||||||
|
directedOnlyPeer: directedOnlyPeer,
|
||||||
|
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
|
||||||
|
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If directed and we currently have no links to forward on, spool for a short window
|
// If directed and we currently have no links to forward on, spool for a short window
|
||||||
@@ -1236,36 +1335,73 @@ final class BLEService: NSObject {
|
|||||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var acceptedByPhysicalLink = false
|
||||||
|
|
||||||
// Writes to selected connected peripherals
|
// Writes to selected connected peripherals
|
||||||
for s in connectedStates {
|
for s in connectedStates {
|
||||||
let pid = s.peripheral.identifier.uuidString
|
let pid = s.peripheral.identifier.uuidString
|
||||||
guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue }
|
guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue }
|
||||||
if let ch = s.characteristic {
|
if let ch = s.characteristic {
|
||||||
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
|
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
|
||||||
|
acceptedByPhysicalLink = writeOrEnqueueIfAccepted(
|
||||||
|
data,
|
||||||
|
to: s.peripheral,
|
||||||
|
characteristic: ch,
|
||||||
|
priority: outboundPriority,
|
||||||
|
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
|
||||||
|
) || acceptedByPhysicalLink
|
||||||
|
} else {
|
||||||
|
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Notify selected subscribed centrals
|
// Notify selected subscribed centrals
|
||||||
if let ch = characteristic {
|
if let ch = characteristic {
|
||||||
let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) }
|
let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) }
|
||||||
if !targets.isEmpty {
|
if !targets.isEmpty {
|
||||||
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
|
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
|
||||||
if !success {
|
acceptedByPhysicalLink = notifyOrEnqueueIfAccepted(
|
||||||
// Notification queue full - queue for retry to prevent silent packet loss
|
data: data,
|
||||||
// This is critical for fragment delivery reliability
|
centrals: targets,
|
||||||
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
|
characteristic: ch,
|
||||||
enqueuePendingNotification(data: data, centrals: targets, context: context)
|
context: "directed",
|
||||||
|
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
|
||||||
|
) || acceptedByPhysicalLink
|
||||||
|
} else {
|
||||||
|
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
|
||||||
|
if !success {
|
||||||
|
// Notification queue full - queue for retry to prevent silent packet loss
|
||||||
|
// This is critical for fragment delivery reliability
|
||||||
|
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
|
||||||
|
enqueuePendingNotification(data: data, centrals: targets, context: context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink }
|
||||||
|
return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||||
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
@discardableResult
|
||||||
|
private func sendPacketDirected(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
requireDirectPeerLink: Bool = false,
|
||||||
|
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||||
|
) -> Bool {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
_test_onOutboundPacket?(packet)
|
_test_onOutboundPacket?(packet)
|
||||||
#endif
|
#endif
|
||||||
guard let data = packet.toBinaryData(padding: false) else { return }
|
guard let data = packet.toBinaryData(padding: false) else { return false }
|
||||||
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
return sendOnAllLinks(
|
||||||
|
packet: packet,
|
||||||
|
data: data,
|
||||||
|
pad: false,
|
||||||
|
directedOnlyPeer: peerID,
|
||||||
|
requireDirectPeerLink: requireDirectPeerLink,
|
||||||
|
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Directed store-and-forward
|
// MARK: - Directed store-and-forward
|
||||||
@@ -1938,6 +2074,10 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Clean up references and peer mappings
|
// Clean up references and peer mappings
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
|
}
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
_ = linkStateStore.removePeripheral(peripheralID)
|
_ = linkStateStore.removePeripheral(peripheralID)
|
||||||
// A duplicate link can drop while the peer stays live on another
|
// A duplicate link can drop while the peer stays live on another
|
||||||
// (the dual-role central link, or a second bound link after a
|
// (the dual-role central link, or a second bound link after a
|
||||||
@@ -1988,6 +2128,10 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
|
|
||||||
// Clean up the references
|
// Clean up the references
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
|
}
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
_ = linkStateStore.removePeripheral(peripheralID)
|
_ = linkStateStore.removePeripheral(peripheralID)
|
||||||
|
|
||||||
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
||||||
@@ -2088,6 +2232,10 @@ extension BLEService {
|
|||||||
|
|
||||||
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
|
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
|
||||||
central.cancelPeripheralConnection(peripheral)
|
central.cancelPeripheralConnection(peripheral)
|
||||||
|
self.collectionsQueue.sync(flags: .barrier) {
|
||||||
|
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
|
}
|
||||||
|
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||||
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
||||||
self.tryConnectFromQueue()
|
self.tryConnectFromQueue()
|
||||||
@@ -2134,6 +2282,23 @@ extension BLEService {
|
|||||||
handleReceivedPacket(packet, from: fromPeerID)
|
handleReceivedPacket(packet, from: fromPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Waits until fragment ingress already submitted by a test has finished
|
||||||
|
/// reassembly/reinjection and any resulting transport event has crossed
|
||||||
|
/// the MainActor delivery hop. This is a deterministic pipeline fence,
|
||||||
|
/// avoiding wall-clock sleeps that become flaky under a parallel suite.
|
||||||
|
func _test_drainFragmentPipeline() async {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
messageQueue.async(flags: .barrier) {
|
||||||
|
// Reassembled packets are reinjected synchronously on
|
||||||
|
// `messageQueue`; their UI delivery task is therefore already
|
||||||
|
// enqueued before this later MainActor marker.
|
||||||
|
Task { @MainActor in
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
|
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
|
||||||
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
|
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
|
||||||
}
|
}
|
||||||
@@ -2164,6 +2329,13 @@ extension BLEService {
|
|||||||
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
|
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||||
|
bleQueue.sync {
|
||||||
|
guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return }
|
||||||
|
noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
peerRegistry.upsert(BLEPeerInfo(
|
peerRegistry.upsert(BLEPeerInfo(
|
||||||
@@ -2578,7 +2750,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
||||||
SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString.prefix(8))…", category: .session)
|
let centralID = central.identifier.uuidString
|
||||||
|
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session)
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
||||||
|
}
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
|
||||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||||
|
|
||||||
// Ensure we're still advertising for other devices to find us
|
// Ensure we're still advertising for other devices to find us
|
||||||
@@ -3118,6 +3295,45 @@ extension BLEService {
|
|||||||
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||||
readLinkState { $0.links(to: peerID) }
|
readLinkState { $0.links(to: peerID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? {
|
||||||
|
switch link {
|
||||||
|
case .peripheral(let peripheralUUID):
|
||||||
|
store.peerID(forPeripheralID: peripheralUUID)
|
||||||
|
case .central(let centralUUID):
|
||||||
|
store.peerID(forCentralUUID: centralUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the exact physical ingress link that completed a fresh Noise
|
||||||
|
/// handshake. An old session keyed only by peer ID is insufficient: a
|
||||||
|
/// replayed announce can rebind an attacker's link to that ID.
|
||||||
|
private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) {
|
||||||
|
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return }
|
||||||
|
readLinkState { store in
|
||||||
|
guard boundPeerID(for: link, in: store) == peerID else { return }
|
||||||
|
noiseAuthenticatedLinkOwners[link] = peerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool {
|
||||||
|
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false }
|
||||||
|
return readLinkState { store in
|
||||||
|
noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool {
|
||||||
|
!currentNoiseAuthenticatedLinks(to: peerID).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set<BLEIngressLinkID> {
|
||||||
|
readLinkState { store in
|
||||||
|
Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
||||||
|
owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
@@ -3299,22 +3515,32 @@ extension BLEService {
|
|||||||
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
|
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
openCourierEnvelope(envelope)
|
return openCourierEnvelope(envelope)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hands a bridge-fetched envelope directly to the matching local peer
|
/// Hands a bridge-fetched envelope directly to the matching local peer
|
||||||
/// as a directed courier packet. Delivery-only by design: the recipient's
|
/// as a directed courier packet. Delivery-only by design: the recipient's
|
||||||
/// tag matched, so this never lands in a stranger's carry quota.
|
/// tag matched, so this never lands in a stranger's carry quota.
|
||||||
|
/// Returns true only if a current Noise-authenticated physical link
|
||||||
|
/// accepted the packet; a stale peer-level session, reachability record,
|
||||||
|
/// replay-rebound link, or process-local spool is not delivery.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
|
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
|
||||||
guard isPeerConnected(peerID) || isPeerReachable(peerID) else { return false }
|
guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false }
|
||||||
guard let payload = envelope.encode() else { return false }
|
guard let payload = envelope.encode() else { return false }
|
||||||
let packet = makeCourierPacket(payload, to: peerID)
|
let packet = makeCourierPacket(payload, to: peerID)
|
||||||
messageQueue.async { [weak self] in
|
let send = { [weak self] in
|
||||||
self?.sendPacketDirected(packet, to: peerID)
|
self?.sendPacketDirected(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
requireDirectPeerLink: true,
|
||||||
|
requireNoiseAuthenticatedPeerLink: true
|
||||||
|
) ?? false
|
||||||
}
|
}
|
||||||
return true
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
return send()
|
||||||
|
}
|
||||||
|
return messageQueue.sync(execute: send)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Our own Noise static public key (for computing our courier tags).
|
/// Our own Noise static public key (for computing our courier tags).
|
||||||
@@ -3385,7 +3611,8 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
@discardableResult
|
||||||
|
private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
|
||||||
do {
|
do {
|
||||||
let typedPayload: Data
|
let typedPayload: Data
|
||||||
let senderStaticKey: Data
|
let senderStaticKey: Data
|
||||||
@@ -3410,12 +3637,12 @@ extension BLEService {
|
|||||||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||||
payloadType == .privateMessage else {
|
payloadType == .privateMessage else {
|
||||||
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
|
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
|
||||||
return
|
return true // decrypted but deterministically unsupported
|
||||||
}
|
}
|
||||||
let payload = Data(typedPayload.dropFirst())
|
let payload = Data(typedPayload.dropFirst())
|
||||||
guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else {
|
guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else {
|
||||||
SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session)
|
SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session)
|
||||||
return
|
return true // decrypted but deterministically malformed
|
||||||
}
|
}
|
||||||
// Redundant copies of one message arrive as distinct envelopes
|
// Redundant copies of one message arrive as distinct envelopes
|
||||||
// (fresh seal each: mesh couriers, bridge drops across relays),
|
// (fresh seal each: mesh couriers, bridge drops across relays),
|
||||||
@@ -3427,14 +3654,14 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
guard firstOpen else {
|
guard firstOpen else {
|
||||||
SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))…", category: .session)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Couriered mail arrives while the sender is absent, so the UI's
|
// Couriered mail arrives while the sender is absent, so the UI's
|
||||||
// block check can't resolve their fingerprint from a live session.
|
// block check can't resolve their fingerprint from a live session.
|
||||||
// Gate here, where the full static key is in hand.
|
// Gate here, where the full static key is in hand.
|
||||||
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
|
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
|
||||||
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// A present sender resolves to their live mesh thread via the
|
// A present sender resolves to their live mesh thread via the
|
||||||
// derived short ID. An absent sender — the usual courier case —
|
// derived short ID. An absent sender — the usual courier case —
|
||||||
@@ -3454,9 +3681,11 @@ extension BLEService {
|
|||||||
timestamp: Date()
|
timestamp: Date()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// Tag collision or stale key: not addressed to us after all.
|
// Tag collision or stale key: not addressed to us after all.
|
||||||
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
|
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3498,13 +3727,23 @@ extension BLEService {
|
|||||||
|
|
||||||
/// Hand over any carried envelopes addressed to a peer we just heard from.
|
/// Hand over any carried envelopes addressed to a peer we just heard from.
|
||||||
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
|
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
|
||||||
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
|
let metrics = sfMetrics
|
||||||
guard !envelopes.isEmpty else { return }
|
let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in
|
||||||
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
|
guard let self,
|
||||||
for envelope in envelopes {
|
let payload = envelope.encode(),
|
||||||
guard let payload = envelope.encode() else { continue }
|
self.sendPacketDirected(
|
||||||
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
|
self.makeCourierPacket(payload, to: peerID),
|
||||||
sfMetrics?.record(.courierHandedOver)
|
to: peerID,
|
||||||
|
requireDirectPeerLink: true,
|
||||||
|
requireNoiseAuthenticatedPeerLink: true
|
||||||
|
) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
metrics?.record(.courierHandedOver)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if accepted > 0 {
|
||||||
|
SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3534,13 +3773,23 @@ extension BLEService {
|
|||||||
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
|
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
|
||||||
let store = courierStore
|
let store = courierStore
|
||||||
let metrics = sfMetrics
|
let metrics = sfMetrics
|
||||||
let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in
|
let sendSpray: () -> Void = { [weak self] in
|
||||||
guard let self, !envelopes.isEmpty else { return }
|
guard let self else { return }
|
||||||
SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
|
let accepted = store.transferSprayCopies(to: noiseKey) { envelope in
|
||||||
for envelope in envelopes {
|
guard let payload = envelope.encode(),
|
||||||
guard let payload = envelope.encode() else { continue }
|
self.sendPacketDirected(
|
||||||
self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID)
|
self.makeCourierPacket(payload, to: peerID),
|
||||||
|
to: peerID,
|
||||||
|
requireDirectPeerLink: true,
|
||||||
|
requireNoiseAuthenticatedPeerLink: true
|
||||||
|
) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
metrics?.record(.courierSprayed)
|
metrics?.record(.courierSprayed)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if accepted > 0 {
|
||||||
|
SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let policy = courierDepositPolicy
|
let policy = courierDepositPolicy
|
||||||
@@ -3548,7 +3797,7 @@ extension BLEService {
|
|||||||
// Same trust gate as deposits: don't hand mail to a peer who
|
// Same trust gate as deposits: don't hand mail to a peer who
|
||||||
// would reject it from us.
|
// would reject it from us.
|
||||||
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
||||||
sendSpray(store.takeSprayCopies(for: noiseKey))
|
sendSpray()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3829,6 +4078,62 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes immediately or synchronously admits the packet to this
|
||||||
|
/// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return
|
||||||
|
/// value distinguishes a retained queue item from one rejected or trimmed
|
||||||
|
/// immediately, which lets durable courier state commit truthfully.
|
||||||
|
private func writeOrEnqueueIfAccepted(
|
||||||
|
_ data: Data,
|
||||||
|
to peripheral: CBPeripheral,
|
||||||
|
characteristic: CBCharacteristic,
|
||||||
|
priority: BLEOutboundWritePriority,
|
||||||
|
requiredAuthenticatedPeer: PeerID?
|
||||||
|
) -> Bool {
|
||||||
|
let accept = { [self] in
|
||||||
|
let uuid = peripheral.identifier.uuidString
|
||||||
|
guard let state = linkStateStore.state(forPeripheralID: uuid),
|
||||||
|
state.isConnected,
|
||||||
|
state.characteristic?.uuid == characteristic.uuid else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let peerID = requiredAuthenticatedPeer {
|
||||||
|
let link = BLEIngressLinkID.peripheral(uuid)
|
||||||
|
guard state.peerID == peerID,
|
||||||
|
noiseAuthenticatedLinkOwners[link] == peerID else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if peripheral.canSendWriteWithoutResponse {
|
||||||
|
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt = collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingPeripheralWrites.enqueueReportingAcceptance(
|
||||||
|
data: data,
|
||||||
|
for: uuid,
|
||||||
|
priority: priority,
|
||||||
|
capBytes: TransportConfig.blePendingWriteBufferCapBytes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch attempt.result {
|
||||||
|
case .oversized(let bytes):
|
||||||
|
SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
|
||||||
|
case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
|
||||||
|
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
|
||||||
|
case .enqueued:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return attempt.accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||||
|
return accept()
|
||||||
|
}
|
||||||
|
return bleQueue.sync(execute: accept)
|
||||||
|
}
|
||||||
|
|
||||||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
||||||
let uuid = peripheral.identifier.uuidString
|
let uuid = peripheral.identifier.uuidString
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
@@ -3976,6 +4281,10 @@ extension BLEService {
|
|||||||
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
|
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
|
||||||
let peripheralID = state.peripheral.identifier.uuidString
|
let peripheralID = state.peripheral.identifier.uuidString
|
||||||
central.cancelPeripheralConnection(state.peripheral)
|
central.cancelPeripheralConnection(state.peripheral)
|
||||||
|
self.collectionsQueue.sync(flags: .barrier) {
|
||||||
|
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
|
}
|
||||||
|
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||||
cancelled += 1
|
cancelled += 1
|
||||||
}
|
}
|
||||||
@@ -4119,25 +4428,37 @@ extension BLEService {
|
|||||||
|
|
||||||
// MARK: Fragmentation (Required for messages > BLE MTU)
|
// MARK: Fragmentation (Required for messages > BLE MTU)
|
||||||
|
|
||||||
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) {
|
@discardableResult
|
||||||
|
private func sendFragmentedPacket(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
pad: Bool,
|
||||||
|
maxChunk: Int? = nil,
|
||||||
|
directedOnlyPeer: PeerID? = nil,
|
||||||
|
transferId: String? = nil,
|
||||||
|
requireDirectPeerLink: Bool = false,
|
||||||
|
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||||
|
) -> Bool {
|
||||||
let request = BLEOutboundFragmentTransferRequest(
|
let request = BLEOutboundFragmentTransferRequest(
|
||||||
packet: packet,
|
packet: packet,
|
||||||
pad: pad,
|
pad: pad,
|
||||||
maxChunk: maxChunk,
|
maxChunk: maxChunk,
|
||||||
directedPeer: directedOnlyPeer,
|
directedPeer: directedOnlyPeer,
|
||||||
transferId: transferId
|
transferId: transferId,
|
||||||
|
requireDirectPeerLink: requireDirectPeerLink,
|
||||||
|
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
|
||||||
)
|
)
|
||||||
|
|
||||||
let result = collectionsQueue.sync(flags: .barrier) {
|
let result = collectionsQueue.sync(flags: .barrier) {
|
||||||
outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
|
outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
|
||||||
}
|
}
|
||||||
handleFragmentTransferSubmitResult(result)
|
return handleFragmentTransferSubmitResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) {
|
@discardableResult
|
||||||
|
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool {
|
||||||
switch result {
|
switch result {
|
||||||
case let .start(request, reservedTransferId):
|
case let .start(request, reservedTransferId):
|
||||||
startFragmentedPacket(request, reservedTransferId: reservedTransferId)
|
return startFragmentedPacket(request, reservedTransferId: reservedTransferId)
|
||||||
|
|
||||||
case let .queued(_, transferId, _):
|
case let .queued(_, transferId, _):
|
||||||
if let transferId {
|
if let transferId {
|
||||||
@@ -4145,16 +4466,29 @@ extension BLEService {
|
|||||||
} else {
|
} else {
|
||||||
SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session)
|
SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
|
||||||
|
case let .rejectedStrict(_, transferId):
|
||||||
|
SecureLogger.debug(
|
||||||
|
"🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
|
||||||
case let .droppedDuplicate(_, activeTransferId):
|
case let .droppedDuplicate(_, activeTransferId):
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")…",
|
"🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")…",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startFragmentedPacket(_ request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) {
|
@discardableResult
|
||||||
|
private func startFragmentedPacket(
|
||||||
|
_ request: BLEOutboundFragmentTransferRequest,
|
||||||
|
reservedTransferId: String?
|
||||||
|
) -> Bool {
|
||||||
let releaseReservedSlot: (String) -> Void = { [weak self] id in
|
let releaseReservedSlot: (String) -> Void = { [weak self] id in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
TransferProgressManager.shared.cancel(id: id)
|
TransferProgressManager.shared.cancel(id: id)
|
||||||
@@ -4174,7 +4508,7 @@ extension BLEService {
|
|||||||
if let id = reservedTransferId {
|
if let id = reservedTransferId {
|
||||||
releaseReservedSlot(id)
|
releaseReservedSlot(id)
|
||||||
}
|
}
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightweight pacing to reduce floods and allow BLE buffers to drain
|
// Lightweight pacing to reduce floods and allow BLE buffers to drain
|
||||||
@@ -4200,6 +4534,42 @@ extension BLEService {
|
|||||||
return id
|
return id
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
|
||||||
|
guard let self else { return false }
|
||||||
|
if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
|
||||||
|
return self.sendPacketDirected(
|
||||||
|
fragmentPacket,
|
||||||
|
to: directedPeer,
|
||||||
|
requireDirectPeerLink: true,
|
||||||
|
requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.broadcastPacket(fragmentPacket)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict courier handoff is transactional at the fragment-admission
|
||||||
|
// boundary: every fragment must enter the intended authenticated
|
||||||
|
// link or its bounded retry queue before the durable owner may commit.
|
||||||
|
// A partial train is harmlessly abandoned and the envelope stays
|
||||||
|
// retryable with a fresh fragment ID on the next encounter.
|
||||||
|
if request.requireDirectPeerLink {
|
||||||
|
let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in
|
||||||
|
guard sendFragment(fragmentPacket) else { return false }
|
||||||
|
if let transferId = transferIdentifier {
|
||||||
|
markFragmentSent(transferId: transferId)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard admitted else {
|
||||||
|
if let id = reservedTransferId {
|
||||||
|
releaseReservedSlot(id)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
|
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
|
||||||
|
|
||||||
for (index, fragmentPacket) in plan.fragmentPackets.enumerated() {
|
for (index, fragmentPacket) in plan.fragmentPackets.enumerated() {
|
||||||
@@ -4212,7 +4582,7 @@ extension BLEService {
|
|||||||
if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true {
|
if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true {
|
||||||
self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)
|
self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)
|
||||||
}
|
}
|
||||||
self.broadcastPacket(fragmentPacket)
|
_ = sendFragment(fragmentPacket)
|
||||||
if let transferId = transferIdentifier {
|
if let transferId = transferIdentifier {
|
||||||
self.markFragmentSent(transferId: transferId)
|
self.markFragmentSent(transferId: transferId)
|
||||||
}
|
}
|
||||||
@@ -4232,6 +4602,7 @@ extension BLEService {
|
|||||||
let delayMs = index * plan.spacingMs
|
let delayMs = index * plan.spacingMs
|
||||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
|
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
||||||
@@ -4503,15 +4874,32 @@ extension BLEService {
|
|||||||
let result,
|
let result,
|
||||||
result.isVerified else { return }
|
result.isVerified else { return }
|
||||||
let noiseKey = result.announcement.noisePublicKey
|
let noiseKey = result.announcement.noisePublicKey
|
||||||
if result.isDirectAnnounce {
|
let authenticatedIngress = result.isDirectAnnounce
|
||||||
// Established link: destructive handover is safe, and the peer is
|
&& canDeliverSecurely(to: result.peerID)
|
||||||
// close enough to become a courier for other carried mail.
|
&& isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID)
|
||||||
|
if authenticatedIngress {
|
||||||
|
// The session was established on this still-bound ingress link.
|
||||||
|
// A peer-level Noise session alone is not enough: it can outlive
|
||||||
|
// its physical link while a replay rebinds an attacker's link to
|
||||||
|
// the victim's ID.
|
||||||
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
|
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
|
||||||
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
|
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
|
||||||
} else {
|
} else {
|
||||||
// Relayed announce: recipient is multi-hop away. Push a copy
|
// Relayed announce, or a direct-looking announce that has not yet
|
||||||
// toward them speculatively; the carried copy stays put.
|
// proved link ownership with Noise: push a speculative copy while
|
||||||
|
// retaining the durable carried original.
|
||||||
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||||||
|
if result.isDirectAnnounce,
|
||||||
|
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
|
||||||
|
if noiseService.hasEstablishedSession(with: result.peerID) {
|
||||||
|
// A session with no surviving authenticated link is stale;
|
||||||
|
// force the current link to prove possession again.
|
||||||
|
noiseService.clearSession(for: result.peerID)
|
||||||
|
}
|
||||||
|
if !noiseService.hasSession(with: result.peerID) {
|
||||||
|
initiateNoiseHandshake(with: result.peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4559,6 +4947,10 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
self.lastLinkRebindAt[linkUUID] = now
|
self.lastLinkRebindAt[linkUUID] = now
|
||||||
|
|
||||||
|
// A Noise proof belongs to the old physical binding. Never carry
|
||||||
|
// it across an announce-driven rebind, whose direct TTL is
|
||||||
|
// replayable; the new owner must complete a fresh handshake.
|
||||||
|
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||||
switch link {
|
switch link {
|
||||||
case .peripheral(let peripheralUUID):
|
case .peripheral(let peripheralUUID):
|
||||||
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
||||||
@@ -4652,6 +5044,10 @@ extension BLEService {
|
|||||||
)
|
)
|
||||||
for uuid in retiring {
|
for uuid in retiring {
|
||||||
guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue }
|
guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue }
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingPeripheralWrites.discardAll(for: uuid)
|
||||||
|
}
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
|
||||||
_ = linkStateStore.removePeripheral(uuid)
|
_ = linkStateStore.removePeripheral(uuid)
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
|
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
|
||||||
@@ -5029,7 +5425,11 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||||
|
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
|
||||||
|
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Trust level of a courier deposit, decided by the caller's policy.
|
/// 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
|
/// 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 queue = DispatchQueue(label: "chat.bitchat.courier.store")
|
||||||
private let fileURL: URL?
|
private let fileURL: URL?
|
||||||
private let now: () -> Date
|
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
|
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||||
/// when `persistsToDisk` is false.
|
/// 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.now = now
|
||||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||||
|
self.readData = readData
|
||||||
loadFromDisk()
|
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)
|
// MARK: - Depositing (courier side)
|
||||||
@@ -154,10 +189,16 @@ final class CourierStore {
|
|||||||
return queue.sync {
|
return queue.sync {
|
||||||
pruneExpiredLocked(at: date)
|
pruneExpiredLocked(at: date)
|
||||||
|
|
||||||
// Identical ciphertext is the same envelope; accept idempotently,
|
// Identical ciphertext is the same envelope. Before any spray,
|
||||||
// keeping the larger spray budget (bounded by maxCopies either way).
|
// a carry-only copy may legitimately arrive ahead of the original
|
||||||
|
// higher-budget copy, so keep the larger initial budget. Once a
|
||||||
|
// branch has sprayed, however, replaying the depositor's original
|
||||||
|
// packet must never replenish spent copies: that would defeat
|
||||||
|
// spray-and-wait and let `sprayedTo` grow without bound.
|
||||||
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
|
if 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()
|
persistLocked()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -207,20 +248,52 @@ final class CourierStore {
|
|||||||
// MARK: - Handover (on encountering a peer)
|
// MARK: - Handover (on encountering a peer)
|
||||||
|
|
||||||
/// Remove and return all envelopes addressed to the given peer, matching
|
/// Remove and return all envelopes addressed to the given peer, matching
|
||||||
/// the rotating recipient tag across adjacent days. Envelopes are removed
|
/// the rotating recipient tag across adjacent days. This compatibility
|
||||||
/// optimistically: handover happens over a live link, and the depositor's
|
/// helper accepts every offer; transport callers should use
|
||||||
/// outbox still retains the original for direct delivery.
|
/// `handoverEnvelopes(for:accepting:)` so failed sends remain durable.
|
||||||
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
|
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 date = now()
|
||||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
||||||
return queue.sync {
|
let offered = queue.sync {
|
||||||
pruneExpiredLocked(at: date)
|
pruneExpiredLocked(at: date)
|
||||||
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
|
return envelopes
|
||||||
guard !matched.isEmpty else { return [] }
|
.filter { candidates.contains($0.recipientTag) }
|
||||||
envelopes.removeAll { stored in matched.contains(stored) }
|
.map(\.envelope)
|
||||||
persistLocked()
|
|
||||||
return matched.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*
|
/// 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
|
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||||
/// like remote handover — the relay copy is speculative, so the carried
|
/// offering one does not start its cooldown: the caller commits that only
|
||||||
/// copy stays until direct handover or expiry. The per-envelope cooldown
|
/// after a relay explicitly accepts the event via NIP-20 OK.
|
||||||
/// keeps relay churn from republishing the same mail; publishing relays
|
|
||||||
/// dedup identical events by ID anyway.
|
|
||||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||||
let date = now()
|
let date = now()
|
||||||
return queue.sync {
|
return queue.sync {
|
||||||
pruneExpiredLocked(at: date)
|
pruneExpiredLocked(at: date)
|
||||||
var matched: [CourierEnvelope] = []
|
return envelopes.compactMap { stored in
|
||||||
for index in envelopes.indices {
|
if let last = stored.lastBridgePublishAt,
|
||||||
if let last = envelopes[index].lastBridgePublishAt,
|
|
||||||
date.timeIntervalSince(last) < cooldown {
|
date.timeIntervalSince(last) < cooldown {
|
||||||
continue
|
return nil
|
||||||
}
|
}
|
||||||
envelopes[index].lastBridgePublishAt = date
|
|
||||||
// The relay copy carries no spray budget.
|
// 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),
|
/// deposited, envelopes addressed to them (those ride the handover path),
|
||||||
/// carry-only envelopes, and couriers already sprayed.
|
/// carry-only envelopes, and couriers already sprayed.
|
||||||
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
|
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 date = now()
|
||||||
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
|
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
|
||||||
return queue.sync {
|
let offered = queue.sync {
|
||||||
pruneExpiredLocked(at: date)
|
pruneExpiredLocked(at: date)
|
||||||
var sprayed: [CourierEnvelope] = []
|
return envelopes.compactMap { stored -> CourierEnvelope? in
|
||||||
for index in envelopes.indices {
|
|
||||||
let stored = envelopes[index]
|
|
||||||
guard stored.copies > 1,
|
guard stored.copies > 1,
|
||||||
stored.depositorNoiseKey != courierNoiseKey,
|
stored.depositorNoiseKey != courierNoiseKey,
|
||||||
!stored.sprayedTo.contains(courierNoiseKey),
|
!stored.sprayedTo.contains(courierNoiseKey),
|
||||||
!courierTags.contains(stored.recipientTag) else { continue }
|
!courierTags.contains(stored.recipientTag) else { return nil }
|
||||||
let given = stored.copies / 2
|
return stored.envelope.withCopies(stored.copies / 2)
|
||||||
envelopes[index].copies = stored.copies - given
|
|
||||||
envelopes[index].sprayedTo.insert(courierNoiseKey)
|
|
||||||
sprayed.append(stored.envelope.withCopies(given))
|
|
||||||
}
|
}
|
||||||
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
|
// MARK: - Maintenance
|
||||||
@@ -305,6 +419,7 @@ final class CourierStore {
|
|||||||
func wipe() {
|
func wipe() {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
envelopes.removeAll()
|
envelopes.removeAll()
|
||||||
|
diskLoadDeferred = false
|
||||||
if let fileURL {
|
if let fileURL {
|
||||||
try? FileManager.default.removeItem(at: 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`)
|
// MARK: - Internals (call only on `queue`)
|
||||||
|
|
||||||
private func pruneExpiredLocked(at date: Date) {
|
private func pruneExpiredLocked(at date: Date) {
|
||||||
@@ -332,6 +457,12 @@ final class CourierStore {
|
|||||||
private func persistLocked() {
|
private func persistLocked() {
|
||||||
publishCountLocked()
|
publishCountLocked()
|
||||||
guard let fileURL else { return }
|
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 {
|
do {
|
||||||
if envelopes.isEmpty {
|
if envelopes.isEmpty {
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
@@ -344,7 +475,7 @@ final class CourierStore {
|
|||||||
let data = try JSONEncoder().encode(envelopes)
|
let data = try JSONEncoder().encode(envelopes)
|
||||||
var options: Data.WritingOptions = [.atomic]
|
var options: Data.WritingOptions = [.atomic]
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
options.insert(.completeFileProtection)
|
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||||
#endif
|
#endif
|
||||||
try data.write(to: fileURL, options: options)
|
try data.write(to: fileURL, options: options)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -355,16 +486,135 @@ final class CourierStore {
|
|||||||
private func loadFromDisk() {
|
private func loadFromDisk() {
|
||||||
guard let fileURL else { return }
|
guard let fileURL else { return }
|
||||||
queue.sync {
|
queue.sync {
|
||||||
guard let data = try? Data(contentsOf: fileURL),
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
|
diskLoadDeferred = false
|
||||||
return
|
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())
|
pruneExpiredLocked(at: now())
|
||||||
publishCountLocked()
|
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? {
|
private static func defaultFileURL() -> URL? {
|
||||||
guard let base = try? FileManager.default.url(
|
guard let base = try? FileManager.default.url(
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import BitLogger
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Disk persistence for the MessageRouter outbox, so private messages queued
|
/// Disk persistence for the MessageRouter outbox, so private messages queued
|
||||||
/// for an offline peer survive an app kill instead of silently evaporating.
|
/// 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 keychainService = "chat.bitchat.outbox"
|
||||||
private static let keychainKey = "outbox-encryption-key"
|
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 fileURL: URL?
|
||||||
private let keychain: KeychainManagerProtocol
|
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.keychain = keychain
|
||||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
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)
|
// MARK: - API (call from the router's actor; IO is small and atomic)
|
||||||
|
|
||||||
func load() -> [PeerID: [QueuedMessage]] {
|
func load() -> Snapshot {
|
||||||
guard let fileURL,
|
lock.lock()
|
||||||
let sealed = try? Data(contentsOf: fileURL),
|
defer { lock.unlock() }
|
||||||
let key = encryptionKey(createIfMissing: false),
|
|
||||||
let box = try? ChaChaPoly.SealedBox(combined: sealed),
|
if case .loaded = diskState {
|
||||||
let plaintext = try? ChaChaPoly.open(box, using: key),
|
return cachedSnapshot
|
||||||
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
|
|
||||||
return [:]
|
|
||||||
}
|
}
|
||||||
var outbox: [PeerID: [QueuedMessage]] = [:]
|
// Once a deferred recovery has classified the durable baseline,
|
||||||
for (peerID, queue) in decoded where !queue.isEmpty {
|
// `cachedSnapshot` is the only safe synchronous view. Re-reading via
|
||||||
outbox[PeerID(str: peerID)] = queue
|
// 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]]) {
|
func save(_ outbox: Snapshot) {
|
||||||
guard let fileURL else { return }
|
var recovered: RecoveredSnapshot?
|
||||||
let flattened = outbox.filter { !$0.value.isEmpty }
|
|
||||||
guard !flattened.isEmpty else {
|
lock.lock()
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
let recoveryWasPending = recoveryDeliveryPending
|
||||||
return
|
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 {
|
switch diskState {
|
||||||
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
|
case .loaded:
|
||||||
return
|
cachedSnapshot = applyingPendingRemovalsLocked(
|
||||||
}
|
recoveryStateWasPending
|
||||||
do {
|
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
|
||||||
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
|
: flattened
|
||||||
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 !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||||
#if os(iOS)
|
// Retain the latest complete router snapshot. Because its
|
||||||
options.insert(.completeFileProtection)
|
// durable baseline was already loaded, it replaces the older
|
||||||
#endif
|
// disk file after protected data returns (preserving removals).
|
||||||
try sealed.write(to: fileURL, options: options)
|
pendingSnapshot = cachedSnapshot
|
||||||
} catch {
|
pendingSnapshotIsAuthoritative = true
|
||||||
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
|
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.
|
/// Panic wipe: drop the queued mail and the key that could ever read it.
|
||||||
func wipe() {
|
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 {
|
if let fileURL {
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
}
|
}
|
||||||
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
|
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
|
||||||
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
|
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
|
||||||
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
|
switch readEncryptionKey() {
|
||||||
return SymmetricKey(data: data)
|
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 }
|
guard createIfMissing else { return nil }
|
||||||
|
|
||||||
let key = SymmetricKey(size: .bits256)
|
let key = SymmetricKey(size: .bits256)
|
||||||
let data = key.withUnsafeBytes { Data($0) }
|
let data = key.withUnsafeBytes { Data($0) }
|
||||||
// After-first-unlock so queued mail can flush from background BLE wakes.
|
// After-first-unlock so queued mail can flush from background BLE wakes.
|
||||||
@@ -139,9 +616,222 @@ final class MessageOutboxStore {
|
|||||||
service: Self.keychainService,
|
service: Self.keychainService,
|
||||||
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
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
|
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? {
|
private static func defaultFileURL() -> URL? {
|
||||||
guard let base = try? FileManager.default.url(
|
guard let base = try? FileManager.default.url(
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
var bridgeEnabled: (@MainActor () -> Bool)?
|
var bridgeEnabled: (@MainActor () -> Bool)?
|
||||||
var relaysConnected: (@MainActor () -> Bool)?
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
/// Publishes a signed drop event to the default (DM) relays.
|
/// Publishes a signed drop event directly to connected default (DM)
|
||||||
var publishEvent: (@MainActor (NostrEvent) -> Void)?
|
/// 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.
|
/// (Re)opens the drop subscription for the given hex tags.
|
||||||
var openSubscription: (@MainActor ([String]) -> Void)?
|
var openSubscription: (@MainActor ([String]) -> Void)?
|
||||||
var closeSubscription: (@MainActor () -> Void)?
|
var closeSubscription: (@MainActor () -> Void)?
|
||||||
@@ -76,14 +79,21 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
|
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
|
||||||
/// Seals content into a carry-only envelope for a recipient key.
|
/// Seals content into a carry-only envelope for a recipient key.
|
||||||
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
|
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
|
||||||
/// Opens a drop addressed to us (tag verified inside).
|
/// Opens a drop addressed to us. True means the inner envelope was
|
||||||
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
|
/// 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.
|
/// Hands a drop to a matching local peer as a directed courier packet.
|
||||||
/// Returns false when the handoff could not even be attempted (peer no
|
/// Returns true only when the transport accepted the packet onto a live
|
||||||
/// longer reachable), so the drop event stays retryable.
|
/// 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)?
|
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Bool)?
|
||||||
/// Held envelopes eligible for (re)publish, honoring the cooldown.
|
/// Held envelopes eligible for (re)publish, honoring the cooldown.
|
||||||
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
|
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`.
|
/// Timer injection for tests; nil arms a real `Task`.
|
||||||
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||||
|
|
||||||
@@ -91,7 +101,11 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
private(set) var myTagsHex: Set<String> = []
|
private(set) var myTagsHex: Set<String> = []
|
||||||
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
|
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
|
||||||
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
|
private(set) var pendingDrops: [(
|
||||||
|
envelope: CourierEnvelope,
|
||||||
|
dedupKey: String?,
|
||||||
|
operationID: UUID?
|
||||||
|
)] = []
|
||||||
/// Message IDs already published as drops (sender-side dedup) and drop
|
/// Message IDs already published as drops (sender-side dedup) and drop
|
||||||
/// event IDs already handled (multi-relay dedup). Both persist across
|
/// event IDs already handled (multi-relay dedup). Both persist across
|
||||||
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
|
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
|
||||||
@@ -104,7 +118,27 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
private var subscriptionOpen = false
|
private var subscriptionOpen = false
|
||||||
private var lastSubscribedTags: Set<String> = []
|
private var lastSubscribedTags: Set<String> = []
|
||||||
private var refreshTimerArmed = false
|
private var refreshTimerArmed = false
|
||||||
|
private var announceRefreshTimerArmed = false
|
||||||
private var lastAnnounceRefresh = Date.distantPast
|
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 now: () -> Date
|
||||||
private let dedupStore: BridgeDropDedupStore
|
private let dedupStore: BridgeDropDedupStore
|
||||||
@@ -156,23 +190,23 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the dedup record now. Sender keys still sitting in the
|
/// Writes the dedup record now. `publishedDropKeys` contains only drops
|
||||||
/// in-memory `pendingDrops` queue are excluded: their drop is not durable
|
/// a relay explicitly accepted; queued and in-flight keys
|
||||||
/// until it actually reaches a relay, and persisting the key early would
|
/// live in `activeDropOperations` and are intentionally process-local.
|
||||||
/// turn "app killed before relays connected" into a silent 24h blackhole
|
|
||||||
/// (the relaunch loses the queued drop but the persisted key blocks every
|
|
||||||
/// re-deposit). `flushPendingDrops` re-persists once they publish.
|
|
||||||
func flushDedupSnapshot() {
|
func flushDedupSnapshot() {
|
||||||
let pendingKeys = Set(pendingDrops.compactMap(\.dedupKey))
|
|
||||||
dedupStore.save(BridgeDropDedupStore.Snapshot(
|
dedupStore.save(BridgeDropDedupStore.Snapshot(
|
||||||
publishedDropKeys: publishedDropKeys.entries.filter { !pendingKeys.contains($0.key) },
|
publishedDropKeys: publishedDropKeys.entries,
|
||||||
seenDropEventIDs: seenDropEventIDs.entries
|
seenDropEventIDs: seenDropEventIDs.entries
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic wipe: forget queued drops and the persisted dedup record.
|
/// Panic wipe: forget queued drops and the persisted dedup record.
|
||||||
func wipe() {
|
func wipe() {
|
||||||
pendingDrops.removeAll()
|
cancelActivePublishes()
|
||||||
|
rejectedDropKeys = ExpiringIDSet(
|
||||||
|
capacity: Limits.maxTrackedIDs,
|
||||||
|
lifetime: CourierEnvelope.maxLifetimeSeconds
|
||||||
|
)
|
||||||
publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
|
publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
|
||||||
seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
|
seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
|
||||||
dedupStore.wipe()
|
dedupStore.wipe()
|
||||||
@@ -182,32 +216,41 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
/// Parallel-deposit a sealed copy of an outbound private message as a
|
/// Parallel-deposit a sealed copy of an outbound private message as a
|
||||||
/// relay drop. Called by the message router alongside physical courier
|
/// relay drop. Called by the message router alongside physical courier
|
||||||
/// deposits; idempotent per message ID. Returns true when a fresh drop
|
/// deposits; idempotent per message ID. Completion becomes true only
|
||||||
/// was sealed (published now or queued for the next relay connection) —
|
/// after a real relay acceptance arrives, which is when the router may
|
||||||
/// the router marks the message "carried" so the sender sees progress.
|
/// show "carried".
|
||||||
@discardableResult
|
func depositDrop(
|
||||||
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
|
content: String,
|
||||||
guard bridgeEnabled?() ?? false else { return false }
|
messageID: String,
|
||||||
guard !publishedDropKeys.contains(messageID, now: now()) else { return false }
|
recipientNoiseKey: Data,
|
||||||
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
|
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
|
// 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
|
// same way on every attempt (size is a function of the content, not
|
||||||
// of the sealing); consume the dedup slot so the retry sweep stops
|
// of the sealing); suppress it in-memory so the retry sweep does not
|
||||||
// re-running Noise sealing on a drop that can never ship.
|
// churn, but never persist it as a published drop.
|
||||||
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
||||||
publishedDropKeys.insert(messageID, now: now())
|
rejectedDropKeys.insert(messageID, now: now())
|
||||||
persistDedup()
|
completion(false)
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
// Only consume the sender-side dedup slot once the drop is durably
|
let operationID = UUID()
|
||||||
// accepted (published, or safely queued for the next relay
|
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
|
||||||
// connection). If the compose fails, leave the slot open so the
|
publishDrop(envelope, messageID: messageID, operationID: operationID)
|
||||||
// router's retry sweep can attempt a fresh deposit rather than
|
|
||||||
// marking the message "carried" and blocking retries forever.
|
|
||||||
guard publishDrop(envelope, messageID: messageID) else { return false }
|
|
||||||
publishedDropKeys.insert(messageID, now: now())
|
|
||||||
persistDedup()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes held envelopes (mail we carry for others) as drops,
|
/// Publishes held envelopes (mail we carry for others) as drops,
|
||||||
@@ -215,32 +258,59 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
func publishHeldEnvelopes() {
|
func publishHeldEnvelopes() {
|
||||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
|
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
|
||||||
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
/// 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);
|
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
||||||
/// it rides the pending queue so an evicted drop can release its slot.
|
/// it rides the pending queue so an evicted or failed drop can release its
|
||||||
/// Returns false only when the drop could not be made durable (bad
|
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
||||||
/// encode/expired/compose failure) so callers can keep it retryable.
|
private func publishDrop(
|
||||||
@discardableResult
|
_ envelope: CourierEnvelope,
|
||||||
private func publishDrop(_ envelope: CourierEnvelope, messageID: String? = nil) -> Bool {
|
messageID: String? = nil,
|
||||||
|
operationID: UUID? = nil,
|
||||||
|
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||||
|
) {
|
||||||
guard let encoded = envelope.encode(),
|
guard let encoded = envelope.encode(),
|
||||||
encoded.count <= Limits.maxDropEnvelopeBytes,
|
encoded.count <= Limits.maxDropEnvelopeBytes,
|
||||||
!envelope.isExpired else { return false }
|
!envelope.isExpired else {
|
||||||
|
finishPublish(
|
||||||
|
messageID: messageID,
|
||||||
|
operationID: operationID,
|
||||||
|
succeeded: false,
|
||||||
|
untrackedCompletion: untrackedCompletion
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard relaysConnected?() ?? false else {
|
guard relaysConnected?() ?? false else {
|
||||||
pendingDrops.append((envelope, messageID))
|
// 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 {
|
while pendingDrops.count > Limits.maxPendingDrops {
|
||||||
let evicted = pendingDrops.removeFirst()
|
let evicted = pendingDrops.removeFirst()
|
||||||
// The oldest queued drop is being dropped before it ever
|
finishPublish(
|
||||||
// published; release its dedup slot so it stays retryable.
|
messageID: evicted.dedupKey,
|
||||||
if let key = evicted.dedupKey {
|
operationID: evicted.operationID,
|
||||||
publishedDropKeys.remove(key)
|
succeeded: false
|
||||||
persistDedup()
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
guard let identity = try? NostrIdentity.generate(),
|
guard let identity = try? NostrIdentity.generate(),
|
||||||
let event = try? NostrProtocol.createCourierDropEvent(
|
let event = try? NostrProtocol.createCourierDropEvent(
|
||||||
@@ -250,10 +320,62 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
senderIdentity: identity
|
senderIdentity: identity
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||||
return false
|
finishPublish(
|
||||||
|
messageID: messageID,
|
||||||
|
operationID: operationID,
|
||||||
|
succeeded: false,
|
||||||
|
untrackedCompletion: untrackedCompletion
|
||||||
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
publishEvent?(event)
|
guard let publishEvent else {
|
||||||
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,16 +384,13 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
|
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
|
||||||
let queued = pendingDrops
|
let queued = pendingDrops
|
||||||
pendingDrops.removeAll()
|
pendingDrops.removeAll()
|
||||||
for item in queued where !publishDrop(item.envelope, messageID: item.dedupKey) {
|
for item in queued {
|
||||||
// Compose failed with relays up: release the slot so the router's
|
publishDrop(
|
||||||
// retry sweep can attempt a fresh deposit.
|
item.envelope,
|
||||||
if let key = item.dedupKey {
|
messageID: item.dedupKey,
|
||||||
publishedDropKeys.remove(key)
|
operationID: item.operationID
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
// Flushed keys just became durable (published, so no longer excluded
|
|
||||||
// as pending) or were released above; either way the record changed.
|
|
||||||
persistDedup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subscription (recipient + gateway watch)
|
// MARK: - Subscription (recipient + gateway watch)
|
||||||
@@ -281,7 +400,15 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
/// (tags rotate daily); idempotent.
|
/// (tags rotate daily); idempotent.
|
||||||
func refresh() {
|
func refresh() {
|
||||||
armRefreshTimerIfNeeded()
|
armRefreshTimerIfNeeded()
|
||||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
|
guard bridgeEnabled?() ?? false else {
|
||||||
|
cancelActivePublishes()
|
||||||
|
if subscriptionOpen {
|
||||||
|
closeSubscription?()
|
||||||
|
subscriptionOpen = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard relaysConnected?() ?? false else {
|
||||||
if subscriptionOpen {
|
if subscriptionOpen {
|
||||||
closeSubscription?()
|
closeSubscription?()
|
||||||
subscriptionOpen = false
|
subscriptionOpen = false
|
||||||
@@ -323,11 +450,37 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
/// Announce-driven refresh, debounced — a newly verified peer should be
|
/// Announce-driven refresh, debounced — a newly verified peer should be
|
||||||
/// watched promptly, but announce storms must not thrash subscriptions.
|
/// 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() {
|
func refreshAfterVerifiedAnnounce() {
|
||||||
guard bridgeEnabled?() ?? false else { return }
|
guard bridgeEnabled?() ?? false else { return }
|
||||||
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
|
let date = now()
|
||||||
lastAnnounceRefresh = now()
|
let elapsed = date.timeIntervalSince(lastAnnounceRefresh)
|
||||||
refresh()
|
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() {
|
private func armRefreshTimerIfNeeded() {
|
||||||
@@ -355,8 +508,10 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
func handleDropEvent(_ event: NostrEvent) {
|
func handleDropEvent(_ event: NostrEvent) {
|
||||||
guard bridgeEnabled?() ?? false else { return }
|
guard bridgeEnabled?() ?? false else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
|
||||||
guard seenDropEventIDs.insert(event.id, now: now()) else { return }
|
// A resubscribe can still deliver an event from the old watch set.
|
||||||
persistDedup()
|
// 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),
|
guard let data = Data(base64Encoded: event.content),
|
||||||
data.count <= Limits.maxDropEnvelopeBytes,
|
data.count <= Limits.maxDropEnvelopeBytes,
|
||||||
let envelope = CourierEnvelope.decode(data),
|
let envelope = CourierEnvelope.decode(data),
|
||||||
@@ -371,17 +526,16 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
if myTagsHex.contains(tagHex) {
|
if myTagsHex.contains(tagHex) {
|
||||||
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
|
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
|
return
|
||||||
}
|
}
|
||||||
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
|
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)
|
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))…", category: .session)
|
||||||
if deliverToPeer?(envelope, match.peerID) != true {
|
if deliverToPeer?(envelope, match.peerID) == true {
|
||||||
// The best-effort handoff never left this device (the peer
|
seenDropEventIDs.insert(event.id, now: now())
|
||||||
// walked away between the relay fetch and the mesh send).
|
|
||||||
// Release the seen slot so a relaunch or backlog redelivery
|
|
||||||
// retries — a single-gateway island has no other carrier.
|
|
||||||
seenDropEventIDs.remove(event.id)
|
|
||||||
persistDedup()
|
persistDedup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,6 +543,19 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Cancels queued and in-flight sender operations after bridge disable or
|
||||||
|
/// panic wipe. Invalidate first, then resolve false so callback re-entry
|
||||||
|
/// cannot be mistaken for an active operation; late relay callbacks no-op.
|
||||||
|
private func cancelActivePublishes() {
|
||||||
|
let invalidated = activeDropOperations.values.map(\.completion)
|
||||||
|
pendingDrops.removeAll()
|
||||||
|
activeDropOperations.removeAll()
|
||||||
|
// Untracked held publishes are invalidated too. Their late relay
|
||||||
|
// callbacks compare operation IDs and no-op after this reset.
|
||||||
|
heldDropOperations.removeAll()
|
||||||
|
invalidated.forEach { $0(false) }
|
||||||
|
}
|
||||||
|
|
||||||
/// A fresh random Nostr identity for signing one drop. Delegates to the
|
/// A fresh random Nostr identity for signing one drop. Delegates to the
|
||||||
/// canonical generator (Schnorr key that can't fail validity) instead of
|
/// canonical generator (Schnorr key that can't fail validity) instead of
|
||||||
/// hand-rolling SecRandom + retry.
|
/// hand-rolling SecRandom + retry.
|
||||||
|
|||||||
@@ -47,11 +47,15 @@ import Foundation
|
|||||||
/// already holds the radio copy (`isMessageSeenLocally` on the event's
|
/// already holds the radio copy (`isMessageSeenLocally` on the event's
|
||||||
/// mesh message ID) — remote islands' traffic is the only thing worth
|
/// mesh message ID) — remote islands' traffic is the only thing worth
|
||||||
/// airtime.
|
/// airtime.
|
||||||
/// Receivers additionally dedup by timeline message ID: the wire carries no
|
/// Receivers key bridge rows by the signed Nostr event ID. The event's `m` tag
|
||||||
/// public message ID, so every device derives the same content-stable one
|
/// is only a radio-copy hint: its mesh sender/timestamp fields are public and
|
||||||
/// (`MeshMessageIdentity`) from the origin coordinates in the event's `m`
|
/// cannot authenticate the event signer, so letting them own the timeline ID
|
||||||
/// tag — recomputed locally, never trusted — and the store's insert-by-ID
|
/// would allow a different signer to front-run the genuine event's dedup slot.
|
||||||
/// absorbs radio/bridge duplicates in either arrival order.
|
/// 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
|
/// All dependencies are closure-injected (repo convention) so the policy
|
||||||
/// layer is unit-testable without relays, radios, or CoreLocation.
|
/// layer is unit-testable without relays, radios, or CoreLocation.
|
||||||
@@ -71,11 +75,25 @@ final class BridgeService: ObservableObject {
|
|||||||
static let maxEventAgeSeconds: TimeInterval = 15 * 60
|
static let maxEventAgeSeconds: TimeInterval = 15 * 60
|
||||||
/// Bounded loop-prevention ID caches (oldest evicted).
|
/// Bounded loop-prevention ID caches (oldest evicted).
|
||||||
static let maxTrackedEventIDs = 512
|
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.
|
/// Presence heartbeat cadence while the bridge is active.
|
||||||
static let presenceIntervalSeconds: TimeInterval = 4 * 60
|
static let presenceIntervalSeconds: TimeInterval = 4 * 60
|
||||||
/// A rendezvous participant counts toward "via bridge" for this long
|
/// A rendezvous participant counts toward "via bridge" for this long
|
||||||
/// after their last event.
|
/// after their last event.
|
||||||
static let participantFreshnessSeconds: TimeInterval = 10 * 60
|
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.
|
/// Content cap, matching the public-message pipeline's own limit.
|
||||||
static let maxContentBytes = 16_000
|
static let maxContentBytes = 16_000
|
||||||
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
|
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
|
||||||
@@ -91,7 +109,11 @@ final class BridgeService: ObservableObject {
|
|||||||
/// A validated rendezvous message ready for the timeline.
|
/// A validated rendezvous message ready for the timeline.
|
||||||
struct InboundBridgeMessage {
|
struct InboundBridgeMessage {
|
||||||
let messageID: String
|
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 senderNickname: String
|
||||||
|
let participantNickname: String?
|
||||||
let senderPubkey: String
|
let senderPubkey: String
|
||||||
let content: String
|
let content: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
@@ -116,10 +138,9 @@ final class BridgeService: ObservableObject {
|
|||||||
/// The user toggle. While true this device publishes its own public mesh
|
/// The user toggle. While true this device publishes its own public mesh
|
||||||
/// messages to the rendezvous and subscribes to it when online.
|
/// messages to the rendezvous and subscribes to it when online.
|
||||||
@Published private(set) var isEnabled: Bool
|
@Published private(set) var isEnabled: Bool
|
||||||
/// Distinct remote rendezvous participants seen within the freshness
|
/// Distinct rendezvous participants seen within the freshness window.
|
||||||
/// window. Approximate by design: local participants are subtracted by
|
/// Radio-copy hints never alter signer locality: their public coordinates
|
||||||
/// matching their events' mesh message IDs against the local timeline,
|
/// can suppress a duplicate row but cannot authenticate a Nostr signer.
|
||||||
/// which cannot attribute silent (presence-only) local peers.
|
|
||||||
@Published private(set) var bridgedPeerCount: Int = 0
|
@Published private(set) var bridgedPeerCount: Int = 0
|
||||||
/// The people behind the count, newest activity first.
|
/// The people behind the count, newest activity first.
|
||||||
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
|
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
|
||||||
@@ -159,6 +180,13 @@ final class BridgeService: ObservableObject {
|
|||||||
var broadcastToMesh: (@MainActor (Data) -> Void)?
|
var broadcastToMesh: (@MainActor (Data) -> Void)?
|
||||||
/// Delivers a validated inbound bridge message to the mesh timeline.
|
/// Delivers a validated inbound bridge message to the mesh timeline.
|
||||||
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
|
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
|
/// True when the mesh timeline already holds this message ID (the radio
|
||||||
/// copy) — used to skip pointless downlink airtime.
|
/// copy) — used to skip pointless downlink airtime.
|
||||||
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
|
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
|
||||||
@@ -185,32 +213,56 @@ final class BridgeService: ObservableObject {
|
|||||||
private var rebroadcastEventIDs: BoundedIDSet
|
private var rebroadcastEventIDs: BoundedIDSet
|
||||||
/// Timeline message IDs already injected (either arrival path).
|
/// Timeline message IDs already injected (either arrival path).
|
||||||
private var injectedMessageIDs: BoundedIDSet
|
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).
|
/// Cells the rendezvous subscription covers (own + neighbor ring).
|
||||||
private(set) var subscribedCells: Set<String> = []
|
private(set) var subscribedCells: Set<String> = []
|
||||||
private(set) var queuedUplinks: [QueuedUplink] = []
|
private(set) var queuedUplinks: [QueuedUplink] = []
|
||||||
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
|
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
|
||||||
private var downlinkSendTimes: [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 pendingDownlinks: [(event: NostrEvent, cell: String)] = []
|
||||||
private var downlinkDrainScheduled = false
|
private var downlinkDrainScheduled = false
|
||||||
private var presenceTimerArmed = false
|
private var presenceTimerArmed = false
|
||||||
private var lastPresenceAt = Date.distantPast
|
private var lastPresenceAt = Date.distantPast
|
||||||
|
|
||||||
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
|
/// pubkey -> (lastSeen, last known nickname).
|
||||||
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
|
private var participants: [String: (lastSeen: Date, nickname: String?)] = [:]
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
|
private let verifyEventSignature: (NostrEvent) -> Bool
|
||||||
private static let enabledKey = "bridge.userEnabled"
|
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.defaults = defaults
|
||||||
self.now = now
|
self.now = now
|
||||||
|
self.verifyEventSignature = verifyEventSignature
|
||||||
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
|
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
|
||||||
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
self.injectedMessageIDs = 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
|
// MARK: - Toggle & lifecycle
|
||||||
@@ -223,6 +275,8 @@ final class BridgeService: ObservableObject {
|
|||||||
queuedUplinks.removeAll()
|
queuedUplinks.removeAll()
|
||||||
pendingDownlinks.removeAll()
|
pendingDownlinks.removeAll()
|
||||||
uplinkDepositTimes.removeAll()
|
uplinkDepositTimes.removeAll()
|
||||||
|
inboundEventTimes.removeAll()
|
||||||
|
inboundEventTimesBySigner.removeAll()
|
||||||
participants.removeAll()
|
participants.removeAll()
|
||||||
bridgedPeerCount = 0
|
bridgedPeerCount = 0
|
||||||
bridgedParticipants = []
|
bridgedParticipants = []
|
||||||
@@ -301,11 +355,6 @@ final class BridgeService: ObservableObject {
|
|||||||
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
|
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
|
||||||
guard content.utf8.count <= Limits.maxContentBytes else { return }
|
guard content.utf8.count <= Limits.maxContentBytes else { return }
|
||||||
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
|
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
|
||||||
let stableID = MeshMessageIdentity.stableID(
|
|
||||||
senderIDHex: senderPeerID.id,
|
|
||||||
timestampMs: timestampMs,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
guard let identity = try? deriveIdentity?(cell),
|
guard let identity = try? deriveIdentity?(cell),
|
||||||
let event = try? NostrProtocol.createBridgeMeshEvent(
|
let event = try? NostrProtocol.createBridgeMeshEvent(
|
||||||
content: content,
|
content: content,
|
||||||
@@ -319,7 +368,7 @@ final class BridgeService: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
publishedEventIDs.insert(event.id)
|
publishedEventIDs.insert(event.id)
|
||||||
injectedMessageIDs.insert(stableID) // our own timeline already has it
|
injectedMessageIDs.insert(event.id) // our own timeline already has it
|
||||||
if relaysConnected?() ?? false {
|
if relaysConnected?() ?? false {
|
||||||
publishToRelays?(event, cell)
|
publishToRelays?(event, cell)
|
||||||
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
|
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
|
||||||
@@ -388,7 +437,6 @@ final class BridgeService: ObservableObject {
|
|||||||
subscribedCells.contains(cell) else {
|
subscribedCells.contains(cell) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let kind = classify(event, cell: cell) else { return }
|
|
||||||
// Events we published come back from our own subscription; they are
|
// Events we published come back from our own subscription; they are
|
||||||
// presence-neutral (we never count ourselves) and never re-injected
|
// presence-neutral (we never count ourselves) and never re-injected
|
||||||
// or rebroadcast. Two layers: the published-ID cache (this session)
|
// or rebroadcast. Two layers: the published-ID cache (this session)
|
||||||
@@ -401,18 +449,23 @@ final class BridgeService: ObservableObject {
|
|||||||
publishedEventIDs.insert(event.id) // never downlink it either
|
publishedEventIDs.insert(event.id) // never downlink it either
|
||||||
return
|
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 {
|
switch kind {
|
||||||
case .presence:
|
case .presence:
|
||||||
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
|
recordParticipant(event.pubkey, nickname: nil)
|
||||||
case .message(let message):
|
case .message(let message):
|
||||||
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
|
let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false
|
||||||
if isLocalRadioCopy {
|
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
|
// Serving duty: carry remote islands' messages onto the radio for
|
||||||
// mesh-only peers. Local-origin events are skipped — the island
|
// mesh-only peers. Local-origin events are skipped — the island
|
||||||
// already heard them (loop rule 3). The drain is jitter-delayed:
|
// already heard them (loop rule 3). The drain is jitter-delayed:
|
||||||
@@ -431,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)
|
// MARK: - Mesh carrier ingress (both roles)
|
||||||
|
|
||||||
/// Entry point for received `nostrCarrier` packets with bridge
|
/// Entry point for received `nostrCarrier` packets with bridge
|
||||||
@@ -467,7 +547,7 @@ final class BridgeService: ObservableObject {
|
|||||||
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session)
|
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard event.isValidSignature() else {
|
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else {
|
||||||
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -523,6 +603,52 @@ final class BridgeService: ObservableObject {
|
|||||||
return true
|
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)
|
// MARK: - Downlink (gateway role: internet -> mesh)
|
||||||
|
|
||||||
private func drainPendingDownlinks() {
|
private func drainPendingDownlinks() {
|
||||||
@@ -533,9 +659,15 @@ final class BridgeService: ObservableObject {
|
|||||||
let (event, cell) = pendingDownlinks.removeFirst()
|
let (event, cell) = pendingDownlinks.removeFirst()
|
||||||
guard isFresh(event) else { continue }
|
guard isFresh(event) else { continue }
|
||||||
// Suppression recheck at send time: another gateway may have
|
// 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),
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
!rebroadcastEventIDs.contains(event.id) else { continue }
|
!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),
|
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
|
||||||
let payload = carrier.encode() else { continue }
|
let payload = carrier.encode() else { continue }
|
||||||
broadcastToMesh?(payload)
|
broadcastToMesh?(payload)
|
||||||
@@ -584,36 +716,90 @@ final class BridgeService: ObservableObject {
|
|||||||
guard let event = structurallyValidEvent(from: carrier),
|
guard let event = structurallyValidEvent(from: carrier),
|
||||||
!publishedEventIDs.contains(event.id),
|
!publishedEventIDs.contains(event.id),
|
||||||
!isOwnRendezvousEvent(event, cell: carrier.geohash),
|
!isOwnRendezvousEvent(event, cell: carrier.geohash),
|
||||||
event.isValidSignature() else {
|
allowSignatureVerificationAttempt(),
|
||||||
|
verifyEventSignature(event) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Mark after verification (a forged copy must not poison the cache),
|
// Mark after verification (a forged copy must not poison the cache),
|
||||||
// and use the marking as multi-path dedup.
|
// even when the relay copy won the injection race: pending gateway
|
||||||
guard meshBroadcastEventIDs.insert(event.id) else { return }
|
// 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 {
|
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
|
if inject(message) {
|
||||||
inject(message)
|
recordParticipant(event.pubkey, nickname: message.participantNickname)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Injection & participants
|
// MARK: - Injection & participants
|
||||||
|
|
||||||
private func inject(_ message: InboundBridgeMessage) {
|
@discardableResult
|
||||||
guard injectedMessageIDs.insert(message.messageID) else { return }
|
private func inject(_ message: InboundBridgeMessage) -> Bool {
|
||||||
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
|
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)
|
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
|
||||||
injectInbound?(message)
|
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]
|
let previous = participants[pubkey]
|
||||||
// Local attribution is sticky: one radio-confirmed message marks the
|
// Presence events carry no nickname, so a known name is never
|
||||||
// pubkey as an islander for as long as they stay fresh. Presence
|
// forgotten. Radio hints deliberately do not mutate this record.
|
||||||
// events carry no nickname, so a known name is never forgotten.
|
|
||||||
participants[pubkey] = (
|
participants[pubkey] = (
|
||||||
lastSeen: now(),
|
lastSeen: now(),
|
||||||
isLocal: (previous?.isLocal ?? false) || isLocal,
|
|
||||||
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
|
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
|
||||||
)
|
)
|
||||||
recomputeBridgedCount()
|
recomputeBridgedCount()
|
||||||
@@ -628,7 +814,7 @@ final class BridgeService: ObservableObject {
|
|||||||
private func recomputeBridgedCount() {
|
private func recomputeBridgedCount() {
|
||||||
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
|
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
|
||||||
let visible = participants
|
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) }
|
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
|
||||||
.sorted { $0.lastSeen > $1.lastSeen }
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
if visible.count != bridgedPeerCount {
|
if visible.count != bridgedPeerCount {
|
||||||
@@ -660,28 +846,30 @@ final class BridgeService: ObservableObject {
|
|||||||
let content = event.content
|
let content = event.content
|
||||||
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
|
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 nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
|
||||||
// Recompute — never trust — the mesh message ID: the `m` tag is
|
// The `m` tag is `[stable ID, sender ID, wire timestamp ms]` for
|
||||||
// `[stable ID, sender ID, wire timestamp ms]`. Element 1 exists
|
// radio/bridge duplicate detection. Those coordinates are public
|
||||||
// for v1.7.0 parsers; we ignore it and derive the ID from the
|
// and not cryptographically bound to this Nostr signer, so they
|
||||||
// origin coordinates (elements 2-3) plus the event's own content,
|
// are never allowed to own bridge dedup. A copied tag can at most
|
||||||
// so a forged tag cannot bind a chosen ID to different content
|
// make the attacker's own event look like a radio duplicate; it
|
||||||
// (see `MeshMessageIdentity` for the exact property and its
|
// cannot reserve the genuine signed event's timeline ID.
|
||||||
// limits).
|
|
||||||
let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })
|
let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })
|
||||||
let messageID: String
|
let radioMessageIDHint: String?
|
||||||
if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit),
|
if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit),
|
||||||
let timestampMs = UInt64(m[3]) {
|
let timestampMs = UInt64(m[3]) {
|
||||||
messageID = MeshMessageIdentity.stableID(
|
radioMessageIDHint = MeshMessageIdentity.stableID(
|
||||||
senderIDHex: m[2],
|
senderIDHex: m[2],
|
||||||
timestampMs: timestampMs,
|
timestampMs: timestampMs,
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
messageID = event.id // old-format or absent tag
|
radioMessageIDHint = nil
|
||||||
}
|
}
|
||||||
|
let baseNickname = nickname?.trimmedOrNilIfEmpty ?? "anon"
|
||||||
return .message(InboundBridgeMessage(
|
return .message(InboundBridgeMessage(
|
||||||
messageID: messageID,
|
messageID: event.id,
|
||||||
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.suffix(4))",
|
radioMessageIDHint: radioMessageIDHint,
|
||||||
|
senderNickname: baseNickname + "#" + String(event.pubkey.suffix(4)),
|
||||||
|
participantNickname: nickname?.trimmedOrNilIfEmpty,
|
||||||
senderPubkey: event.pubkey,
|
senderPubkey: event.pubkey,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
|||||||
@@ -542,6 +542,15 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Load data from a custom service
|
/// Load data from a custom service
|
||||||
func load(key: String, service customService: String) -> Data? {
|
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] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
@@ -551,9 +560,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
return classifyReadStatus(status, data: result as? Data)
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
return result as? Data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete data from a custom service
|
/// Delete data from a custom service
|
||||||
|
|||||||
@@ -367,16 +367,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||||
updatePermissionState(from: status)
|
let newState = updatePermissionState(from: status)
|
||||||
if case .authorized = permissionState {
|
if newState == .authorized {
|
||||||
requestOneShotLocation()
|
requestOneShotLocation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 14.0, macOS 11.0, *)
|
@available(iOS 14.0, macOS 11.0, *)
|
||||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
updatePermissionState(from: manager.authorizationStatus)
|
let newState = updatePermissionState(from: manager.authorizationStatus)
|
||||||
if case .authorized = permissionState {
|
if newState == .authorized {
|
||||||
requestOneShotLocation()
|
requestOneShotLocation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
// MARK: - Private Helpers (Permission)
|
// MARK: - Private Helpers (Permission)
|
||||||
|
|
||||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
@discardableResult
|
||||||
|
private func updatePermissionState(from status: CLAuthorizationStatus) -> PermissionState {
|
||||||
let newState: PermissionState
|
let newState: PermissionState
|
||||||
switch status {
|
switch status {
|
||||||
case .notDetermined: newState = .notDetermined
|
case .notDetermined: newState = .notDetermined
|
||||||
@@ -402,7 +403,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||||
@unknown default: newState = .restricted
|
@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 }
|
Task { @MainActor in self.permissionState = newState }
|
||||||
|
return newState
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers (Channel Computation)
|
// MARK: - Private Helpers (Channel Computation)
|
||||||
|
|||||||
@@ -246,6 +246,16 @@ final class MessageDeduplicationService {
|
|||||||
ContentNormalizer.normalizedKey(content)
|
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
|
// MARK: - Nostr Event Deduplication
|
||||||
|
|
||||||
/// Checks if a Nostr event has already been processed.
|
/// 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
|
/// relays as a courier drop, so delivery stops requiring a physical
|
||||||
/// courier encounter. No-op unless the bridge is enabled. Runs alongside
|
/// courier encounter. No-op unless the bridge is enabled. Runs alongside
|
||||||
/// (not instead of) mesh couriers; receivers dedup by message ID.
|
/// (not instead of) mesh couriers; receivers dedup by message ID.
|
||||||
/// Returns true when a fresh drop was sealed, so the sender's message
|
/// Completion is true only after at least one default relay explicitly
|
||||||
/// can show the "carried" state instead of sitting on "sending" forever
|
/// accepts the event, so a socket write followed by rejection cannot
|
||||||
/// (the delivery ack has no radio route back until the peers next share
|
/// falsely show the sender's message as "carried".
|
||||||
/// a transport).
|
var bridgeCourierDeposit: ((
|
||||||
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)?
|
_ content: String,
|
||||||
|
_ messageID: String,
|
||||||
|
_ recipientNoiseKey: Data,
|
||||||
|
_ completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) -> Void)?
|
||||||
|
|
||||||
/// Re-attempts bridge drops for retained messages whose recipient no
|
/// Re-attempts bridge drops for retained messages whose recipient no
|
||||||
/// transport can promptly reach anymore. Covers sends that raced the BLE
|
/// transport can promptly reach anymore. Covers sends that raced the BLE
|
||||||
@@ -77,9 +81,7 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
guard !promptlyDeliverable else { continue }
|
guard !promptlyDeliverable else { continue }
|
||||||
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
|
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
|
||||||
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true {
|
requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey)
|
||||||
onMessageCarried?(message.messageID, peerID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var bridgeSweepTask: Task<Void, Never>?
|
private var bridgeSweepTask: Task<Void, Never>?
|
||||||
|
private var bridgeDepositsInFlight = Set<String>()
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||||
|
|
||||||
@@ -127,6 +130,9 @@ final class MessageRouter {
|
|||||||
self.outboxStore = outboxStore
|
self.outboxStore = outboxStore
|
||||||
self.metrics = metrics
|
self.metrics = metrics
|
||||||
self.outbox = outboxStore?.load() ?? [:]
|
self.outbox = outboxStore?.load() ?? [:]
|
||||||
|
outboxStore?.setRecoveryHandler { [weak self] recovered in
|
||||||
|
self?.mergeRecoveredOutbox(recovered)
|
||||||
|
}
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -237,9 +243,7 @@ final class MessageRouter {
|
|||||||
let entry = queuedMessage(messageID, for: peerID) else { return }
|
let entry = queuedMessage(messageID, for: peerID) else { return }
|
||||||
// The bridge drop needs no connected courier — only the recipient
|
// The bridge drop needs no connected courier — only the recipient
|
||||||
// key — so it runs before the courier-slot bookkeeping.
|
// key — so it runs before the courier-slot bookkeeping.
|
||||||
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true {
|
requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey)
|
||||||
onMessageCarried?(messageID, peerID)
|
|
||||||
}
|
|
||||||
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
||||||
guard remainingSlots > 0 else { return }
|
guard remainingSlots > 0 else { return }
|
||||||
|
|
||||||
@@ -324,6 +328,23 @@ final class MessageRouter {
|
|||||||
outbox[peerID]?.first { $0.messageID == messageID }
|
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]) {
|
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
|
||||||
metrics?.record(.courierDeposited)
|
metrics?.record(.courierDeposited)
|
||||||
guard var queue = outbox[peerID],
|
guard var queue = outbox[peerID],
|
||||||
@@ -344,10 +365,14 @@ final class MessageRouter {
|
|||||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||||
cleared = true
|
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 {
|
if cleared {
|
||||||
metrics?.record(.outboxDelivered)
|
metrics?.record(.outboxDelivered)
|
||||||
persistOutbox()
|
|
||||||
}
|
}
|
||||||
|
persistOutbox()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
||||||
@@ -382,6 +407,38 @@ final class MessageRouter {
|
|||||||
outboxStore?.save(outbox)
|
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.
|
/// Panic wipe: forget queued mail on disk and in memory.
|
||||||
func wipeOutbox() {
|
func wipeOutbox() {
|
||||||
outbox.removeAll()
|
outbox.removeAll()
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ enum TransportConfig {
|
|||||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||||
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
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.
|
// After this long, a relay marked permanently failed gets another chance.
|
||||||
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
|
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
private var fileManager: FileManager { fileStore.fileManager }
|
private var fileManager: FileManager { fileStore.fileManager }
|
||||||
private var assemblies: [AssemblyKey: Assembly] = [:]
|
private var assemblies: [AssemblyKey: Assembly] = [:]
|
||||||
private var finishedBursts: [AssemblyKey: FinishedBurst] = [:]
|
private var finishedBursts: [AssemblyKey: FinishedBurst] = [:]
|
||||||
|
/// Players still draining after their assembly — the sole strong owner —
|
||||||
|
/// was discarded on burst END. Without this hold the player deallocates
|
||||||
|
/// mid-tail (or mid session-acquire, killing the whole burst's audio)
|
||||||
|
/// and its registered session token would only be reclaimed by the
|
||||||
|
/// deinit backstop. Entries remove themselves via `onStopped`.
|
||||||
|
private var drainingPlayers: [ObjectIdentifier: PTTBurstPlayer] = [:]
|
||||||
|
|
||||||
/// `sweepsOnInit` exists for tests whose coordinator shares the real
|
/// `sweepsOnInit` exists for tests whose coordinator shares the real
|
||||||
/// application-support directory: they pass `false` so parallel test
|
/// application-support directory: they pass `false` so parallel test
|
||||||
@@ -465,7 +471,18 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
return
|
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.
|
// The bubble's waveform may have been computed from a partial file.
|
||||||
WaveformCache.shared.purge(url: assembly.fileURL)
|
WaveformCache.shared.purge(url: assembly.fileURL)
|
||||||
// The capture is the bubble's replayable audio from here on (unless a
|
// The capture is the bubble's replayable audio from here on (unless a
|
||||||
|
|||||||
@@ -698,12 +698,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false
|
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
|
/// Removes a message by ID from whichever public conversation contains
|
||||||
/// it. Returns the removed message, if any.
|
/// it. Returns the removed message, if any.
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
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
|
/// Removes every message matching `predicate` from a geohash
|
||||||
@@ -1755,6 +1773,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
/// Handle incoming public message
|
/// Handle incoming public message
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePublicMessage(_ message: BitchatMessage) {
|
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
|
// A finalized voice note whose burst already streamed in live swaps
|
||||||
// into the existing bubble instead of appearing twice.
|
// into the existing bubble instead of appearing twice.
|
||||||
if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return }
|
if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return }
|
||||||
|
|||||||
@@ -464,6 +464,12 @@ private extension ChatViewModelBootstrapper {
|
|||||||
isBridged: true
|
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
|
bridge.isMessageSeenLocally = { [weak viewModel] messageID in
|
||||||
viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false
|
viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false
|
||||||
}
|
}
|
||||||
@@ -532,11 +538,17 @@ private extension ChatViewModelBootstrapper {
|
|||||||
let courier = BridgeCourierService.shared
|
let courier = BridgeCourierService.shared
|
||||||
|
|
||||||
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
|
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
|
||||||
courier.relaysConnected = { NostrRelayManager.shared.isConnected }
|
// A geo/custom relay does not make a global courier drop durable.
|
||||||
courier.publishEvent = { event in
|
// 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,
|
// Default (DM) relays: drops need the standing global relay set,
|
||||||
// not geo relays — sender and recipient share no cell.
|
// 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
|
courier.openSubscription = { tagsHex in
|
||||||
NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
|
NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
|
||||||
@@ -564,7 +576,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey)
|
bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey)
|
||||||
}
|
}
|
||||||
courier.openEnvelope = { [weak bleService] envelope in
|
courier.openEnvelope = { [weak bleService] envelope in
|
||||||
bleService?.openBridgedCourierEnvelope(envelope)
|
bleService?.openBridgedCourierEnvelope(envelope) ?? false
|
||||||
}
|
}
|
||||||
courier.deliverToPeer = { [weak bleService] envelope, peerID in
|
courier.deliverToPeer = { [weak bleService] envelope, peerID in
|
||||||
bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false
|
bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false
|
||||||
@@ -572,12 +584,20 @@ private extension ChatViewModelBootstrapper {
|
|||||||
courier.heldEnvelopes = { cooldown in
|
courier.heldEnvelopes = { cooldown in
|
||||||
CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown)
|
CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown)
|
||||||
}
|
}
|
||||||
|
courier.markHeldEnvelopePublished = { envelope in
|
||||||
viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey in
|
CourierStore.shared.markBridgePublished(envelope)
|
||||||
BridgeCourierService.shared.depositDrop(content: content, messageID: messageID, recipientNoiseKey: recipientKey)
|
|
||||||
}
|
}
|
||||||
// (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()
|
viewModel.messageRouter.startBridgeDepositSweep()
|
||||||
bleService.onVerifiedPeerAnnounce = { _ in
|
bleService.onVerifiedPeerAnnounce = { _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -588,7 +608,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
// Relay connectivity gates everything; refresh (re)opens or closes.
|
// Relay connectivity gates everything; refresh (re)opens or closes.
|
||||||
// Deduped: refresh() resubscribes, and the raw publisher re-emits on
|
// Deduped: refresh() resubscribes, and the raw publisher re-emits on
|
||||||
// every relay state recompute (6x in 300ms in field logs).
|
// every relay state recompute (6x in 300ms in field logs).
|
||||||
NostrRelayManager.shared.$isConnected
|
NostrRelayManager.shared.$isDMRelayConnected
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { _ in BridgeCourierService.shared.refresh() }
|
.sink { _ in BridgeCourierService.shared.refresh() }
|
||||||
|
|||||||
@@ -39,25 +39,38 @@ final class GeoChannelCoordinator {
|
|||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var regionalChannels: [GeohashChannel] = []
|
private var regionalChannels: [GeohashChannel] = []
|
||||||
private var bookmarkedGeohashes: [String] = []
|
private var bookmarkedGeohashes: [String] = []
|
||||||
|
private var permissionState: LocationChannelManager.PermissionState
|
||||||
|
private var locationNotesEnabled: Bool
|
||||||
/// Mirrors `NearbyNotesCounter.revealed` (injectable for tests): the
|
/// Mirrors `NearbyNotesCounter.revealed` (injectable for tests): the
|
||||||
/// session's one explicit notes act. Until it happens, background
|
/// session's one explicit notes act. Until it happens, background
|
||||||
/// sampling must not include the building-precision cell — see
|
/// sampling must not include the building-precision cell — see
|
||||||
/// `sampledRegionalGeohashes`.
|
/// `sampledRegionalGeohashes`.
|
||||||
private var notesRevealed = false
|
private var notesRevealed = false
|
||||||
private let notesRevealedPublisher: AnyPublisher<Bool, Never>
|
private let notesRevealedPublisher: AnyPublisher<Bool, Never>
|
||||||
|
private let locationNotesSettingsPublisher: AnyPublisher<Bool, Never>
|
||||||
|
|
||||||
init(
|
init(
|
||||||
locationManager: LocationChannelManager? = nil,
|
locationManager: LocationChannelManager? = nil,
|
||||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
bookmarksStore: GeohashBookmarksStore? = nil,
|
||||||
torManager: TorManager? = nil,
|
torManager: TorManager? = nil,
|
||||||
notesRevealed: AnyPublisher<Bool, Never>? = nil,
|
notesRevealed: AnyPublisher<Bool, Never>? = nil,
|
||||||
|
locationNotesEnabled: Bool? = nil,
|
||||||
|
locationNotesSettings: AnyPublisher<Bool, Never>? = nil,
|
||||||
context: any GeoChannelContext
|
context: any GeoChannelContext
|
||||||
) {
|
) {
|
||||||
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
let resolvedLocationManager = locationManager ?? Self.defaultLocationManager()
|
||||||
|
self.locationManager = resolvedLocationManager
|
||||||
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
||||||
self.torManager = torManager ?? Self.defaultTorManager()
|
self.torManager = torManager ?? Self.defaultTorManager()
|
||||||
|
self.permissionState = resolvedLocationManager.permissionState
|
||||||
|
self.locationNotesEnabled = locationNotesEnabled ?? LocationNotesSettings.enabled
|
||||||
self.notesRevealedPublisher = notesRevealed
|
self.notesRevealedPublisher = notesRevealed
|
||||||
?? NearbyNotesCounter.shared.$revealed.eraseToAnyPublisher()
|
?? NearbyNotesCounter.shared.$revealed.eraseToAnyPublisher()
|
||||||
|
self.locationNotesSettingsPublisher = locationNotesSettings
|
||||||
|
?? NotificationCenter.default
|
||||||
|
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||||
|
.map { _ in LocationNotesSettings.enabled }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
start()
|
start()
|
||||||
@@ -97,6 +110,18 @@ final class GeoChannelCoordinator {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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)
|
||||||
|
|
||||||
bookmarksStore.$bookmarks
|
bookmarksStore.$bookmarks
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] bookmarks in
|
.sink { [weak self] bookmarks in
|
||||||
@@ -109,10 +134,15 @@ final class GeoChannelCoordinator {
|
|||||||
locationManager.$permissionState
|
locationManager.$permissionState
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] state in
|
.sink { [weak self] state in
|
||||||
guard let self, state == .authorized else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor [weak self] in
|
self.permissionState = state
|
||||||
self?.locationManager.refreshChannels()
|
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)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
@@ -130,23 +160,22 @@ final class GeoChannelCoordinator {
|
|||||||
/// the nearby-conversation hint and channel participant counts working.
|
/// the nearby-conversation hint and channel participant counts working.
|
||||||
/// Bookmarks are exempt: bookmarking a geohash is itself explicit.
|
/// Bookmarks are exempt: bookmarking a geohash is itself explicit.
|
||||||
private var sampledRegionalGeohashes: [String] {
|
private var sampledRegionalGeohashes: [String] {
|
||||||
regionalChannels
|
guard permissionState == .authorized else { return [] }
|
||||||
.filter { notesRevealed || $0.level != .building }
|
return regionalChannels
|
||||||
|
.filter { (notesRevealed && locationNotesEnabled) || $0.level != .building }
|
||||||
.map { $0.geohash }
|
.map { $0.geohash }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSampling() {
|
private func updateSampling() {
|
||||||
let union = Array(Set(sampledRegionalGeohashes).union(bookmarkedGeohashes))
|
let union = Array(Set(sampledRegionalGeohashes).union(bookmarkedGeohashes))
|
||||||
Task { @MainActor in
|
guard !union.isEmpty else {
|
||||||
guard !union.isEmpty else {
|
context?.endGeohashSampling()
|
||||||
context?.endGeohashSampling()
|
return
|
||||||
return
|
}
|
||||||
}
|
if torManager.isForeground() {
|
||||||
if torManager.isForeground() {
|
context?.beginGeohashSampling(for: union)
|
||||||
context?.beginGeohashSampling(for: union)
|
} else {
|
||||||
} else {
|
context?.endGeohashSampling()
|
||||||
context?.endGeohashSampling()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ final class PublicMessagePipeline {
|
|||||||
scheduleFlush()
|
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() {
|
func flushIfNeeded() {
|
||||||
flushBuffer()
|
flushBuffer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
/// live push-to-talk session when the current DM peer can hear it now.
|
/// live push-to-talk session when the current DM peer can hear it now.
|
||||||
var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() }
|
var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() }
|
||||||
private var activeSession: VoiceCaptureSession?
|
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 {
|
func formattedDuration(for date: Date) -> String {
|
||||||
let clamped = max(0, state.duration(for: date))
|
let clamped = max(0, state.duration(for: date))
|
||||||
@@ -75,13 +79,18 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
|
|
||||||
func start(shouldShow: Bool) {
|
func start(shouldShow: Bool) {
|
||||||
guard shouldShow, state == .idle else { return }
|
guard shouldShow, state == .idle else { return }
|
||||||
|
holdGeneration &+= 1
|
||||||
|
let generation = holdGeneration
|
||||||
let session = sessionProvider()
|
let session = sessionProvider()
|
||||||
SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session)
|
SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session)
|
||||||
activeSession = session
|
activeSession = session
|
||||||
state = .requestingPermission
|
state = .requestingPermission
|
||||||
Task {
|
Task {
|
||||||
let granted = await session.requestPermission()
|
let granted = await session.requestPermission()
|
||||||
guard state == .requestingPermission, activeSession === session else { return }
|
guard generation == holdGeneration,
|
||||||
|
state == .requestingPermission,
|
||||||
|
activeSession === session
|
||||||
|
else { return }
|
||||||
guard granted else {
|
guard granted else {
|
||||||
state = .permissionDenied
|
state = .permissionDenied
|
||||||
activeSession = nil
|
activeSession = nil
|
||||||
@@ -90,16 +99,36 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
state = .preparing
|
state = .preparing
|
||||||
do {
|
do {
|
||||||
try await session.start()
|
try await session.start()
|
||||||
guard state == .preparing, activeSession === session else {
|
guard generation == holdGeneration,
|
||||||
|
state == .preparing,
|
||||||
|
activeSession === session
|
||||||
|
else {
|
||||||
await session.cancel()
|
await session.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state = .recording(startDate: Date())
|
state = .recording(startDate: Date())
|
||||||
isLiveStreaming = session.isLive
|
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 {
|
} catch {
|
||||||
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
||||||
await session.cancel()
|
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
|
// The live engine and the classic recorder are separate
|
||||||
// capture stacks: when the live one hits an audio-route
|
// capture stacks: when the live one hits an audio-route
|
||||||
// glitch, fall back within the same hold so the user still
|
// glitch, fall back within the same hold so the user still
|
||||||
@@ -109,7 +138,10 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
activeSession = fallback
|
activeSession = fallback
|
||||||
do {
|
do {
|
||||||
try await fallback.start()
|
try await fallback.start()
|
||||||
guard state == .preparing, activeSession === fallback else {
|
guard generation == holdGeneration,
|
||||||
|
state == .preparing,
|
||||||
|
activeSession === fallback
|
||||||
|
else {
|
||||||
await fallback.cancel()
|
await fallback.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,7 +152,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session)
|
SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session)
|
||||||
await fallback.cancel()
|
await fallback.cancel()
|
||||||
guard state == .preparing else { return }
|
guard generation == holdGeneration, state == .preparing else { return }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeSession = nil
|
activeSession = nil
|
||||||
@@ -142,6 +174,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
state = .idle
|
state = .idle
|
||||||
isLiveStreaming = false
|
isLiveStreaming = false
|
||||||
let session = activeSession
|
let session = activeSession
|
||||||
|
let generation = holdGeneration
|
||||||
activeSession = nil
|
activeSession = nil
|
||||||
|
|
||||||
guard case .recording(let startDate) = previousState, let completion, let session else {
|
guard case .recording(let startDate) = previousState, let completion, let session else {
|
||||||
@@ -159,7 +192,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
isValidRecording(at: url, duration: finalDuration) {
|
isValidRecording(at: url, duration: finalDuration) {
|
||||||
completion(url)
|
completion(url)
|
||||||
} else {
|
} else {
|
||||||
guard state == .idle else { return }
|
guard generation == holdGeneration, state == .idle else { return }
|
||||||
state = .error(
|
state = .error(
|
||||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||||
? "Recording is too short."
|
? "Recording is too short."
|
||||||
|
|||||||
+193
-49
@@ -34,6 +34,9 @@ struct NoticesView: View {
|
|||||||
/// Days until the notice fades; `permanentExpiry` (geo default) means no
|
/// Days until the notice fades; `permanentExpiry` (geo default) means no
|
||||||
/// NIP-40 tag — the note stays until its relay drops it.
|
/// NIP-40 tag — the note stays until its relay drops it.
|
||||||
@State private var expiryDays: Int
|
@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).
|
/// Sentinel picker tag for the ∞ option (geo tab only).
|
||||||
private static let permanentExpiry = 0
|
private static let permanentExpiry = 0
|
||||||
@@ -45,6 +48,10 @@ struct NoticesView: View {
|
|||||||
/// Acquired from `LocationNotesPool` (shared with the nearby-notes
|
/// Acquired from `LocationNotesPool` (shared with the nearby-notes
|
||||||
/// counter, one REQ per geohash) and released on dismissal.
|
/// counter, one REQ per geohash) and released on dismissal.
|
||||||
@State private var liveGeoManager: LocationNotesManager?
|
@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(
|
init(
|
||||||
senderNickname: String,
|
senderNickname: String,
|
||||||
@@ -73,24 +80,127 @@ struct NoticesView: View {
|
|||||||
tab == .geo && geoGeohash != nil
|
tab == .geo && geoGeohash != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acquires (or retargets/revives) the pooled notes manager for the
|
struct GeoSessionState {
|
||||||
/// current geo scope.
|
let manager: LocationNotesManager?
|
||||||
private func ensureGeoNotesManager() {
|
let ownsLiveRefresh: Bool
|
||||||
guard tab == .geo else { return }
|
}
|
||||||
guard notesManager == nil, let geohash = geoGeohash else { return }
|
|
||||||
if let manager = liveGeoManager {
|
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() {
|
if manager.geohash != geohash.lowercased() {
|
||||||
// Pooled managers are shared; never retarget one in place —
|
// Pooled managers are shared; never retarget one in place.
|
||||||
// release the old cell and acquire the new one.
|
release(manager)
|
||||||
LocationNotesPool.shared.release(manager)
|
return GeoSessionState(
|
||||||
liveGeoManager = LocationNotesPool.shared.acquire(geohash)
|
manager: acquire(geohash),
|
||||||
} else if manager.state == .idle {
|
ownsLiveRefresh: wantsLiveRefresh
|
||||||
// Revived after a cancel elsewhere; returning re-subscribes.
|
)
|
||||||
|
}
|
||||||
|
if manager.state == .idle {
|
||||||
manager.refresh()
|
manager.refresh()
|
||||||
}
|
}
|
||||||
} else {
|
return GeoSessionState(manager: manager, ownsLiveRefresh: wantsLiveRefresh)
|
||||||
liveGeoManager = LocationNotesPool.shared.acquire(geohash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }
|
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||||
@@ -101,6 +211,9 @@ struct NoticesView: View {
|
|||||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
return channel.geohash
|
return channel.geohash
|
||||||
}
|
}
|
||||||
|
guard locationChannelsModel.permissionState == .authorized else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return locationChannelsModel.currentBuildingGeohash
|
return locationChannelsModel.currentBuildingGeohash
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +225,11 @@ struct NoticesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var activeGeohash: String? {
|
private var activeGeohash: String? {
|
||||||
switch tab {
|
Self.composerGeohash(
|
||||||
case .geo: return geoGeohash
|
tab: tab,
|
||||||
case .mesh: return ""
|
notesEnabled: locationNotesEnabled,
|
||||||
}
|
geoGeohash: geoGeohash
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Strings {
|
enum Strings {
|
||||||
@@ -141,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 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 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 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 loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||||
static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays"
|
static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays"
|
||||||
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||||
@@ -190,55 +306,48 @@ struct NoticesView: View {
|
|||||||
#endif
|
#endif
|
||||||
.themedSheetBackground()
|
.themedSheetBackground()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
beginGeoLocationIfNeeded()
|
reconcileGeoSession()
|
||||||
ensureGeoNotesManager()
|
|
||||||
}
|
}
|
||||||
.onChange(of: tab) { newTab in
|
.onChange(of: tab) { newTab in
|
||||||
if newTab == .geo {
|
if newTab == .geo {
|
||||||
beginGeoLocationIfNeeded()
|
|
||||||
if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) {
|
if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) {
|
||||||
NearbyNotesCounter.shared.reveal()
|
NearbyNotesCounter.shared.reveal()
|
||||||
}
|
}
|
||||||
ensureGeoNotesManager()
|
|
||||||
} else {
|
|
||||||
locationChannelsModel.endLiveRefresh()
|
|
||||||
// Leaving the geo tab must take its REQ down with it, not
|
|
||||||
// leave the geohash subscription streaming behind the mesh
|
|
||||||
// board. The pool makes the return trip cheap (re-acquire
|
|
||||||
// revives the manager), and the dismissal release below is
|
|
||||||
// safe: `liveGeoManager` is nil until re-acquired.
|
|
||||||
LocationNotesPool.shared.release(liveGeoManager)
|
|
||||||
liveGeoManager = nil
|
|
||||||
}
|
}
|
||||||
|
reconcileGeoSession()
|
||||||
// Each tab keeps its natural default: geo notes stay until
|
// Each tab keeps its natural default: geo notes stay until
|
||||||
// deleted (∞), mesh board posts fade within a week.
|
// deleted (∞), mesh board posts fade within a week.
|
||||||
expiryDays = newTab == .geo ? Self.permanentExpiry : 7
|
expiryDays = newTab == .geo ? Self.permanentExpiry : 7
|
||||||
urgent = false
|
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
|
.onChange(of: locationChannelsModel.permissionState) { _ in
|
||||||
beginGeoLocationIfNeeded()
|
reconcileGeoSession()
|
||||||
}
|
}
|
||||||
.onChange(of: geoGeohash) { _ in
|
.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 {
|
.onDisappear {
|
||||||
locationChannelsModel.endLiveRefresh()
|
if ownsLiveGeoRefresh {
|
||||||
LocationNotesPool.shared.release(liveGeoManager)
|
locationChannelsModel.endLiveRefresh()
|
||||||
|
ownsLiveGeoRefresh = false
|
||||||
|
}
|
||||||
|
if notesManager == nil {
|
||||||
|
LocationNotesPool.shared.release(liveGeoManager)
|
||||||
|
}
|
||||||
liveGeoManager = nil
|
liveGeoManager = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
private var headerSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -288,21 +397,56 @@ struct NoticesView: View {
|
|||||||
notesManager: nil
|
notesManager: nil
|
||||||
)
|
)
|
||||||
case .geo:
|
case .geo:
|
||||||
if let geohash = geoGeohash {
|
switch Self.geoPresentationState(
|
||||||
|
notesEnabled: locationNotesEnabled,
|
||||||
|
geohash: geoGeohash
|
||||||
|
) {
|
||||||
|
case .disabled:
|
||||||
|
locationNotesDisabledSection
|
||||||
|
case .available(let geohash):
|
||||||
if let manager = activeNotesManager {
|
if let manager = activeNotesManager {
|
||||||
GeoNoticesList(geohash: geohash, board: board, manager: manager)
|
GeoNoticesList(geohash: geohash, board: board, manager: manager)
|
||||||
} else {
|
} else {
|
||||||
// Manager is created on appear; visible for one frame.
|
// Manager is created on appear; visible for one frame.
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.onAppear { ensureGeoNotesManager() }
|
.onAppear { reconcileGeoSession() }
|
||||||
}
|
}
|
||||||
} else {
|
case .locationUnavailable:
|
||||||
locationUnavailableSection
|
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 {
|
private var locationUnavailableSection: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
|||||||
@@ -28,8 +28,6 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionActivationRule</key>
|
<key>NSExtensionActivationRule</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>NSExtensionActivationSupportsText</key>
|
<key>NSExtensionActivationSupportsText</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
<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) {
|
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
|
||||||
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
|
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
|
||||||
let grp = DispatchGroup()
|
for provider in providers {
|
||||||
var found: URL?
|
guard let identifier = identifiers.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else {
|
||||||
|
continue
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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) {
|
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
|
||||||
let id = UTType.plainText.identifier
|
let identifier = UTType.plainText.identifier
|
||||||
let grp = DispatchGroup()
|
guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(identifier) }) else {
|
||||||
var text: String?
|
DispatchQueue.main.async { completion(nil) }
|
||||||
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
|
return
|
||||||
grp.enter()
|
}
|
||||||
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
provider.loadItem(forTypeIdentifier: identifier, options: nil) { item, _ in
|
||||||
defer { grp.leave() }
|
let result: String?
|
||||||
if let s = item as? String { text = s }
|
if let string = item as? String {
|
||||||
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
|
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
|
// MARK: - Save + Finish
|
||||||
@@ -170,10 +178,13 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func finishWithMessage(_ msg: String) {
|
private func finishWithMessage(_ msg: String) {
|
||||||
statusLabel.text = msg
|
DispatchQueue.main.async { [weak self] in
|
||||||
// Complete shortly after showing status
|
guard let self else { return }
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
self.statusLabel.text = msg
|
||||||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
// 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 payload = try #require(announcement.encode(), "Failed to encode announcement")
|
||||||
let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
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_seedConnectedPeer(victimPeerID, nickname: "victim")
|
||||||
ble._test_bindCentral(victimLink, to: victimPeerID)
|
ble._test_bindCentral(victimLink, to: victimPeerID)
|
||||||
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
|
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
|
||||||
ble._test_bindCentral(attackerLink, to: attackerPeerID)
|
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
|
// The victim's fresh signed announce replayed on the attacker's bound
|
||||||
// link with its direct TTL restored (TTL is excluded from signing, so
|
// link with its direct TTL restored (TTL is excluded from signing, so
|
||||||
// the signature still verifies).
|
// the signature still verifies).
|
||||||
@@ -249,6 +292,16 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(!stolen)
|
#expect(!stolen)
|
||||||
#expect(ble._test_centralBinding(attackerLink) == attackerPeerID)
|
#expect(ble._test_centralBinding(attackerLink) == attackerPeerID)
|
||||||
#expect(ble._test_centralBinding(victimLink) == victimPeerID)
|
#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.
|
// And the replay must not retire the link's real bound peer.
|
||||||
#expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID))
|
#expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID))
|
||||||
}
|
}
|
||||||
@@ -315,6 +368,78 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
#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
|
/// A legitimate rotation announce necessarily arrives on a link still
|
||||||
/// bound to the OLD ID, so its registry upsert stores the new peer
|
/// bound to the OLD ID, so its registry upsert stores the new peer
|
||||||
/// disconnected. The successful rebind must promote it: a healed
|
/// disconnected. The successful rebind must promote it: a healed
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
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 {
|
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ struct ChatVerificationCoordinatorContextTests {
|
|||||||
context.verifiedFingerprints = ["fp-verified"]
|
context.verifiedFingerprints = ["fp-verified"]
|
||||||
|
|
||||||
coordinator.setupNoiseCallbacks()
|
coordinator.setupNoiseCallbacks()
|
||||||
let callbacks = try? #require(context.installedCallbacks)
|
let callbacks = context.installedCallbacks
|
||||||
|
|
||||||
// Authenticated with a verified fingerprint -> verified status and a
|
// Authenticated with a verified fingerprint -> verified status and a
|
||||||
// cached stable peer ID derived from the session key.
|
// cached stable peer ID derived from the session key.
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
|
|
||||||
#expect(Conversation.shouldSkipStatusUpdate(current: .delivered(to: "Peer", at: Date()), new: .sending))
|
#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: .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
|
// A late async `.sending` (pre-handshake resend) must not visibly
|
||||||
// downgrade a truthful "Sent" either...
|
// downgrade a truthful "Sent" either...
|
||||||
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending))
|
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending))
|
||||||
|
|||||||
@@ -760,6 +760,40 @@ struct ChatViewModelRateLimitingTests {
|
|||||||
|
|
||||||
struct ChatViewModelPublicConversationTests {
|
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
|
@Test @MainActor
|
||||||
func addPublicSystemMessage_persistsAcrossTimelineRefresh() async {
|
func addPublicSystemMessage_persistsAcrossTimelineRefresh() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -66,6 +66,52 @@ struct CourierStoreTests {
|
|||||||
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
|
#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() {
|
@Test func duplicateDepositIsIdempotent() {
|
||||||
let store = makeStore()
|
let store = makeStore()
|
||||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
@@ -174,6 +220,46 @@ struct CourierStoreTests {
|
|||||||
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
#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)
|
// MARK: - Tiers (open couriering)
|
||||||
|
|
||||||
@Test func verifiedTierGetsSmallerPerDepositorQuota() {
|
@Test func verifiedTierGetsSmallerPerDepositorQuota() {
|
||||||
@@ -282,6 +368,29 @@ struct CourierStoreTests {
|
|||||||
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
#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() {
|
@Test func carryOnlyEnvelopesAreNeverSprayed() {
|
||||||
let store = makeStore()
|
let store = makeStore()
|
||||||
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
||||||
@@ -300,6 +409,28 @@ struct CourierStoreTests {
|
|||||||
#expect(sprayed.first?.copies == 2)
|
#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)
|
// MARK: - Remote handover (relayed announces)
|
||||||
|
|
||||||
@Test func remoteHandoverIsNonDestructiveAndCooledDown() {
|
@Test func remoteHandoverIsNonDestructiveAndCooledDown() {
|
||||||
|
|||||||
@@ -96,6 +96,20 @@ struct CourierEndToEndTests {
|
|||||||
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
|
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
|
// MARK: - Tests
|
||||||
|
|
||||||
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
|
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
|
||||||
@@ -141,7 +155,9 @@ struct CourierEndToEndTests {
|
|||||||
)
|
)
|
||||||
#expect(carried)
|
#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()
|
bob.sendBroadcastAnnounce()
|
||||||
let announced = await TestHelpers.waitUntil(
|
let announced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.first(ofType: .announce) != nil },
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
@@ -156,7 +172,10 @@ struct CourierEndToEndTests {
|
|||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#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))
|
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||||
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
|
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
|
||||||
|
|
||||||
@@ -222,6 +241,7 @@ struct CourierEndToEndTests {
|
|||||||
)
|
)
|
||||||
#expect(carried)
|
#expect(carried)
|
||||||
|
|
||||||
|
try establishNoiseSession(between: carol, and: bob)
|
||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let announced = await TestHelpers.waitUntil(
|
let announced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.first(ofType: .announce) != nil },
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
@@ -309,7 +329,7 @@ struct CourierEndToEndTests {
|
|||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#expect(handedOver)
|
||||||
#expect(carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
|
@Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
|
||||||
@@ -395,7 +415,11 @@ struct CourierEndToEndTests {
|
|||||||
#expect(!refloodedInCooldown)
|
#expect(!refloodedInCooldown)
|
||||||
#expect(!carol.courierStore.isEmpty)
|
#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)
|
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let announcedAgain = await TestHelpers.waitUntil(
|
let announcedAgain = await TestHelpers.waitUntil(
|
||||||
@@ -408,12 +432,12 @@ struct CourierEndToEndTests {
|
|||||||
)
|
)
|
||||||
carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false)
|
carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
let handedOver = await TestHelpers.waitUntil(
|
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) == 2 },
|
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.shortTimeout
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#expect(!handedOverWithoutLinkProof)
|
||||||
#expect(carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
|
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
|
||||||
@@ -755,9 +779,9 @@ struct MessageRouterCourierTests {
|
|||||||
)
|
)
|
||||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||||
var drops: [(content: String, messageID: String, key: Data)] = []
|
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))
|
drops.append((content, messageID, key))
|
||||||
return true
|
completion(true)
|
||||||
}
|
}
|
||||||
var carried: [String] = []
|
var carried: [String] = []
|
||||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||||
|
|||||||
@@ -35,19 +35,15 @@ struct FragmentationTests {
|
|||||||
// Use a small fragment size to ensure multiple pieces
|
// Use a small fragment size to ensure multiple pieces
|
||||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||||
|
|
||||||
// Shuffle fragments to simulate out-of-order arrival
|
// Reverse deterministically to simulate out-of-order arrival without
|
||||||
let shuffled = fragments.shuffled()
|
// making a failure depend on a random permutation.
|
||||||
|
let outOfOrder = fragments.reversed()
|
||||||
|
|
||||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
for fragment in outOfOrder {
|
||||||
for (i, fragment) in shuffled.enumerated() {
|
|
||||||
if i > 0 {
|
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
|
||||||
}
|
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
await ble._test_drainFragmentPipeline()
|
||||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
|
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||||
@@ -73,16 +69,11 @@ struct FragmentationTests {
|
|||||||
frags.insert(dup, at: 1)
|
frags.insert(dup, at: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
for fragment in frags {
|
||||||
for (i, fragment) in frags.enumerated() {
|
|
||||||
if i > 0 {
|
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
|
||||||
}
|
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
await ble._test_drainFragmentPipeline()
|
||||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
|
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
@@ -126,14 +117,11 @@ struct FragmentationTests {
|
|||||||
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
|
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
|
||||||
#expect(!fragments.isEmpty)
|
#expect(!fragments.isEmpty)
|
||||||
|
|
||||||
for (i, fragment) in fragments.enumerated() {
|
for fragment in fragments {
|
||||||
if i > 0 {
|
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
|
||||||
}
|
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteID, signingPublicKey: signingKey)
|
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")
|
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||||
#expect(message.content.hasPrefix("[file]"))
|
#expect(message.content.hasPrefix("[file]"))
|
||||||
@@ -173,15 +161,11 @@ struct FragmentationTests {
|
|||||||
corrupted[0] = p
|
corrupted[0] = p
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in corrupted.enumerated() {
|
for fragment in corrupted {
|
||||||
if i > 0 {
|
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
|
||||||
}
|
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
await ble._test_drainFragmentPipeline()
|
||||||
try await sleep(0.5)
|
|
||||||
|
|
||||||
// Should not deliver since one fragment is invalid and reassembly can't complete
|
// Should not deliver since one fragment is invalid and reassembly can't complete
|
||||||
#expect(capture.publicMessages.isEmpty)
|
#expect(capture.publicMessages.isEmpty)
|
||||||
@@ -207,10 +191,6 @@ extension FragmentationTests {
|
|||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||||
private var _receivedMessages: [BitchatMessage] = []
|
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 {
|
private func withLock<T>(_ body: () -> T) -> T {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -227,139 +207,11 @@ extension FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
lock.lock()
|
withLock { _receivedMessages.append(message) }
|
||||||
_receivedMessages.append(message)
|
|
||||||
let count = _receivedMessages.count
|
|
||||||
let expected = expectedReceivedMessageCount
|
|
||||||
let continuation = receivedMessageContinuation
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
if count >= expected, let cont = continuation {
|
|
||||||
lock.lock()
|
|
||||||
receivedMessageContinuation = nil
|
|
||||||
lock.unlock()
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
lock.lock()
|
withLock { _publicMessages.append((peerID, nickname, content)) }
|
||||||
_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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
|
|||||||
@@ -41,12 +41,17 @@ private final class StubLocationManaging: LocationStateManaging {
|
|||||||
weak var delegate: CLLocationManagerDelegate?
|
weak var delegate: CLLocationManagerDelegate?
|
||||||
var desiredAccuracy: CLLocationAccuracy = 0
|
var desiredAccuracy: CLLocationAccuracy = 0
|
||||||
var distanceFilter: CLLocationDistance = 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 requestWhenInUseAuthorization() {}
|
||||||
func requestLocation() {}
|
func requestLocation() {}
|
||||||
func startUpdatingLocation() {}
|
func startUpdatingLocation() {}
|
||||||
func stopUpdatingLocation() {}
|
func stopUpdatingLocation() { stopUpdatingLocationCallCount += 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class StubLocationGeocoder: LocationStateGeocoding {
|
private final class StubLocationGeocoder: LocationStateGeocoding {
|
||||||
@@ -62,7 +67,11 @@ private final class StubLocationGeocoder: LocationStateGeocoding {
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
@MainActor
|
@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 suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
|
||||||
let defaults = storage ?? UserDefaults(suiteName: suiteName)!
|
let defaults = storage ?? UserDefaults(suiteName: suiteName)!
|
||||||
if storage == nil {
|
if storage == nil {
|
||||||
@@ -70,9 +79,9 @@ private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateM
|
|||||||
}
|
}
|
||||||
return LocationStateManager(
|
return LocationStateManager(
|
||||||
storage: defaults,
|
storage: defaults,
|
||||||
locationManager: StubLocationManaging(),
|
locationManager: StubLocationManaging(authorizationStatus: authorizationStatus),
|
||||||
geocoder: StubLocationGeocoder(),
|
geocoder: StubLocationGeocoder(),
|
||||||
shouldInitializeCoreLocation: false
|
shouldInitializeCoreLocation: shouldInitializeCoreLocation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,14 +181,21 @@ struct GeoChannelCoordinatorContextTests {
|
|||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func buildingCellJoinsSamplingOnlyAfterNotesReveal() async {
|
func buildingCellJoinsSamplingOnlyAfterNotesReveal() async {
|
||||||
TorManager.shared.setAppForeground(true)
|
TorManager.shared.setAppForeground(true)
|
||||||
let locationManager = makeLocationManager()
|
let locationManager = makeLocationManager(
|
||||||
|
authorizationStatus: .authorizedAlways,
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
#expect(await waitUntil { locationManager.permissionState == .authorized })
|
||||||
let context = MockGeoChannelContext()
|
let context = MockGeoChannelContext()
|
||||||
let revealed = CurrentValueSubject<Bool, Never>(false)
|
let revealed = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let notesEnabled = CurrentValueSubject<Bool, Never>(true)
|
||||||
let coordinator = GeoChannelCoordinator(
|
let coordinator = GeoChannelCoordinator(
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
bookmarksStore: locationManager,
|
bookmarksStore: locationManager,
|
||||||
torManager: TorManager.shared,
|
torManager: TorManager.shared,
|
||||||
notesRevealed: revealed.eraseToAnyPublisher(),
|
notesRevealed: revealed.eraseToAnyPublisher(),
|
||||||
|
locationNotesEnabled: true,
|
||||||
|
locationNotesSettings: notesEnabled.eraseToAnyPublisher(),
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
defer { withExtendedLifetime(coordinator) {} }
|
defer { withExtendedLifetime(coordinator) {} }
|
||||||
@@ -205,13 +221,66 @@ struct GeoChannelCoordinatorContextTests {
|
|||||||
revealed.send(true)
|
revealed.send(true)
|
||||||
#expect(await waitUntil { context.beginSamplingCalls.last?.contains(building) == true })
|
#expect(await waitUntil { context.beginSamplingCalls.last?.contains(building) == true })
|
||||||
#expect(context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count)
|
#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
|
@Test @MainActor
|
||||||
func releasedContext_isHeldWeaklyAndSafelyIgnored() async {
|
func releasedContext_isHeldWeaklyAndSafelyIgnored() async {
|
||||||
let locationManager = makeLocationManager()
|
let locationManager = makeLocationManager()
|
||||||
var context: MockGeoChannelContext? = MockGeoChannelContext()
|
var context: MockGeoChannelContext? = MockGeoChannelContext()
|
||||||
weak var weakContext = context
|
let weakContext = { [weak context] in context }
|
||||||
let coordinator = GeoChannelCoordinator(
|
let coordinator = GeoChannelCoordinator(
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
bookmarksStore: locationManager,
|
bookmarksStore: locationManager,
|
||||||
@@ -223,7 +292,7 @@ struct GeoChannelCoordinatorContextTests {
|
|||||||
|
|
||||||
// The coordinator must not keep the owner alive (it is owned by it).
|
// The coordinator must not keep the owner alive (it is owned by it).
|
||||||
context = nil
|
context = nil
|
||||||
#expect(weakContext == nil)
|
#expect(weakContext() == nil)
|
||||||
|
|
||||||
// Events after the owner is gone are safely dropped.
|
// Events after the owner is gone are safely dropped.
|
||||||
locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru")))
|
locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru")))
|
||||||
|
|||||||
@@ -312,6 +312,30 @@ struct MessageDeduplicationServiceTests {
|
|||||||
#expect(service.contentTimestamp(forKey: key) == now)
|
#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() {
|
@Test func normalizedContentKey_consistentWithNormalizer() {
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||||
let content = "Hello World"
|
let content = "Hello World"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
// BCH-01-009: Configurable error simulation for testing
|
// BCH-01-009: Configurable error simulation for testing
|
||||||
var simulatedReadError: KeychainReadResult?
|
var simulatedReadError: KeychainReadResult?
|
||||||
var simulatedSaveError: KeychainSaveResult?
|
var simulatedSaveError: KeychainSaveResult?
|
||||||
|
var simulatedGenericReadError: KeychainReadResult?
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -82,6 +83,16 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
serviceStorage[service]?[key]
|
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) {
|
func delete(key: String, service: String) {
|
||||||
serviceStorage[service]?.removeValue(forKey: key)
|
serviceStorage[service]?.removeValue(forKey: key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,62 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
XCTAssertEqual(counter.noteCount, 0)
|
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() {
|
func test_checkNotesHint_requiresAuthorizedLocationPermission() {
|
||||||
let relays = SubscriptionRecorder()
|
let relays = SubscriptionRecorder()
|
||||||
let counter = NearbyNotesCounter(
|
let counter = NearbyNotesCounter(
|
||||||
@@ -127,6 +183,107 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
XCTAssertFalse(NoticesView.revealsNearbyNotes(onSwitchingTo: .mesh, geoGeohash: "u4pruydq"))
|
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() {
|
func test_pool_sharesOneManagerPerGeohash_andCancelsOnLastRelease() {
|
||||||
let relays = SubscriptionRecorder()
|
let relays = SubscriptionRecorder()
|
||||||
let pool = LocationNotesPool(
|
let pool = LocationNotesPool(
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ struct NoiseEncryptionTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Reuse
|
// Local error type for the keychain failure cases in this suite.
|
||||||
private struct KeychainTestError: Error, CustomStringConvertible {
|
private struct KeychainTestError: Error, CustomStringConvertible {
|
||||||
let message: String
|
let message: String
|
||||||
init(_ message: String) { self.message = message }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -791,6 +791,10 @@ private final class PerfPipelineFixture {
|
|||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let conversations = ConversationStore()
|
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.conversations = conversations
|
||||||
self.viewModel = ChatViewModel(
|
self.viewModel = ChatViewModel(
|
||||||
@@ -798,7 +802,8 @@ private final class PerfPipelineFixture {
|
|||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
conversations: conversations
|
conversations: conversations,
|
||||||
|
locationManager: locationManager
|
||||||
)
|
)
|
||||||
self.privateInbox = PrivateInboxModel(conversations: conversations)
|
self.privateInbox = PrivateInboxModel(conversations: conversations)
|
||||||
self.publicChat = PublicChatModel(conversations: conversations)
|
self.publicChat = PublicChatModel(conversations: conversations)
|
||||||
|
|||||||
@@ -186,7 +186,12 @@ struct CourierStoreBridgePublishTests {
|
|||||||
// eligible again after the cooldown. `carriedCount` publishes
|
// eligible again after the cooldown. `carriedCount` publishes
|
||||||
// asynchronously, so it is not asserted here.)
|
// 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)
|
#expect(store.envelopesForBridgePublish(cooldown: 600).isEmpty)
|
||||||
|
|
||||||
// After cooldown: eligible again.
|
// After cooldown: eligible again.
|
||||||
|
|||||||
@@ -128,4 +128,18 @@ struct PublicMessagePipelineTests {
|
|||||||
#expect(delegate.messages(in: .mesh).isEmpty)
|
#expect(delegate.messages(in: .mesh).isEmpty)
|
||||||
#expect(delegate.recordedContentKeys.isEmpty)
|
#expect(delegate.recordedContentKeys.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func removeMessage_discardsBridgeAliasBeforeBatchFlush() {
|
||||||
|
let pipeline = PublicMessagePipeline()
|
||||||
|
let delegate = TestPipelineDelegate()
|
||||||
|
pipeline.delegate = delegate
|
||||||
|
|
||||||
|
pipeline.enqueue(makeMessage(id: "bridge-event", content: "same radio payload", timestamp: Date()), to: .mesh)
|
||||||
|
pipeline.removeMessage(withID: "bridge-event")
|
||||||
|
pipeline.flushIfNeeded()
|
||||||
|
|
||||||
|
#expect(delegate.committed.isEmpty)
|
||||||
|
#expect(delegate.recordedContentKeys.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ struct BLEAnnounceThrottleTests {
|
|||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
throttle.shouldSend(force: false, now: now)
|
_ = throttle.shouldSend(force: false, now: now)
|
||||||
|
|
||||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func strictDirectTransferIsRejectedWithoutBeingQueuedWhenSlotsAreFull() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
|
||||||
|
let strict = makeRequest(
|
||||||
|
type: MessageType.fileTransfer.rawValue,
|
||||||
|
transferId: "strict",
|
||||||
|
requireDirectPeerLink: true
|
||||||
|
)
|
||||||
|
|
||||||
|
guard case .start = scheduler.submit(active, maxConcurrentTransfers: 1) else {
|
||||||
|
Issue.record("Expected active transfer to reserve the only slot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = scheduler.submit(strict, maxConcurrentTransfers: 1)
|
||||||
|
|
||||||
|
if case let .rejectedStrict(request, transferId) = result {
|
||||||
|
#expect(request.requireDirectPeerLink)
|
||||||
|
#expect(transferId == "strict")
|
||||||
|
#expect(scheduler.activeCount == 1)
|
||||||
|
#expect(scheduler.pendingCount == 0)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected strict transfer to reject instead of entering the pending queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func submitQueuesDuplicateActiveTransferAtFront() {
|
func submitQueuesDuplicateActiveTransferAtFront() {
|
||||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
@@ -240,7 +267,8 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
|||||||
type: UInt8,
|
type: UInt8,
|
||||||
transferId: String?,
|
transferId: String?,
|
||||||
payload: String? = nil,
|
payload: String? = nil,
|
||||||
directedPeer: PeerID? = nil
|
directedPeer: PeerID? = nil,
|
||||||
|
requireDirectPeerLink: Bool = false
|
||||||
) -> BLEOutboundFragmentTransferRequest {
|
) -> BLEOutboundFragmentTransferRequest {
|
||||||
BLEOutboundFragmentTransferRequest(
|
BLEOutboundFragmentTransferRequest(
|
||||||
packet: BitchatPacket(
|
packet: BitchatPacket(
|
||||||
@@ -255,7 +283,8 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
|||||||
pad: false,
|
pad: false,
|
||||||
maxChunk: nil,
|
maxChunk: nil,
|
||||||
directedPeer: directedPeer,
|
directedPeer: directedPeer,
|
||||||
transferId: transferId
|
transferId: transferId,
|
||||||
|
requireDirectPeerLink: requireDirectPeerLink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,33 @@ struct BLEOutboundLinkPlannerTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
#expect(plan.fragmentChunkSize == BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: smallestLimit))
|
#expect(plan.fragmentChunkSize == BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: smallestLimit))
|
||||||
|
#expect(plan.selectedLinks.peripheralIDs == Set(["p1"]))
|
||||||
|
#expect(plan.selectedLinks.centralIDs == Set(["c1"]))
|
||||||
|
#expect(!plan.shouldSpoolDirectedPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oversizedDirectedCourierDoesNotUseUnrelatedPeersMTUAsHandoffSuccess() {
|
||||||
|
let recipient = PeerID(str: "1122334455667788")
|
||||||
|
let unrelated = PeerID(str: "8877665544332211")
|
||||||
|
let packet = makePacket(type: .courierEnvelope, recipient: recipient)
|
||||||
|
|
||||||
|
let plan = BLEOutboundLinkPlanner.plan(
|
||||||
|
packet: packet,
|
||||||
|
dataCount: 512,
|
||||||
|
peripheralIDs: ["unrelated-link"],
|
||||||
|
peripheralWriteLimits: [64],
|
||||||
|
centralIDs: [],
|
||||||
|
centralNotifyLimits: [],
|
||||||
|
ingressRecord: nil,
|
||||||
|
excludedLinks: [],
|
||||||
|
peripheralPeerBindings: ["unrelated-link": unrelated],
|
||||||
|
directedOnlyPeer: recipient,
|
||||||
|
requireDirectPeerLink: true
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(plan.directedPeerHint == recipient)
|
||||||
|
#expect(plan.fragmentChunkSize == nil)
|
||||||
#expect(plan.selectedLinks.peripheralIDs.isEmpty)
|
#expect(plan.selectedLinks.peripheralIDs.isEmpty)
|
||||||
#expect(plan.selectedLinks.centralIDs.isEmpty)
|
#expect(plan.selectedLinks.centralIDs.isEmpty)
|
||||||
#expect(!plan.shouldSpoolDirectedPacket)
|
#expect(!plan.shouldSpoolDirectedPacket)
|
||||||
@@ -93,6 +120,28 @@ struct BLEOutboundLinkPlannerTests {
|
|||||||
#expect(plan.shouldSpoolDirectedPacket)
|
#expect(plan.shouldSpoolDirectedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func bridgeCourierPacketDoesNotTurnProcessLocalSpoolIntoHandoffSuccess() {
|
||||||
|
let recipient = PeerID(str: "1122334455667788")
|
||||||
|
let packet = makePacket(type: .courierEnvelope, recipient: recipient)
|
||||||
|
|
||||||
|
let plan = BLEOutboundLinkPlanner.plan(
|
||||||
|
packet: packet,
|
||||||
|
dataCount: 32,
|
||||||
|
peripheralIDs: [],
|
||||||
|
peripheralWriteLimits: [],
|
||||||
|
centralIDs: [],
|
||||||
|
centralNotifyLimits: [],
|
||||||
|
ingressRecord: nil,
|
||||||
|
excludedLinks: [],
|
||||||
|
directedOnlyPeer: recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(plan.selectedLinks.peripheralIDs.isEmpty)
|
||||||
|
#expect(plan.selectedLinks.centralIDs.isEmpty)
|
||||||
|
#expect(!plan.shouldSpoolDirectedPacket)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func publicBroadcastDoesNotSpoolWhenNoLinksAreAvailable() {
|
func publicBroadcastDoesNotSpoolWhenNoLinksAreAvailable() {
|
||||||
let packet = makePacket(type: .message)
|
let packet = makePacket(type: .message)
|
||||||
|
|||||||
@@ -67,4 +67,19 @@ struct BLEOutboundNotificationBufferTests {
|
|||||||
|
|
||||||
#expect(buffer.isEmpty)
|
#expect(buffer.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unsubscribeRemovesTargetSpecificCiphertextOnlyForThatCentral() {
|
||||||
|
var buffer = BLEOutboundNotificationBuffer<String>()
|
||||||
|
_ = buffer.enqueue(data: Data([1]), targets: ["gone"], capCount: 4)
|
||||||
|
_ = buffer.enqueue(data: Data([2]), targets: ["gone", "live"], capCount: 4)
|
||||||
|
_ = buffer.enqueue(data: Data([3]), targets: nil, capCount: 4)
|
||||||
|
|
||||||
|
buffer.removeTarget { $0 == "gone" }
|
||||||
|
let remaining = buffer.takeAll()
|
||||||
|
|
||||||
|
#expect(remaining.map(\.data) == [Data([2]), Data([3])])
|
||||||
|
#expect(remaining[0].targets == ["live"])
|
||||||
|
#expect(remaining[1].targets == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,38 @@ struct BLEOutboundWriteBufferTests {
|
|||||||
|
|
||||||
#expect(buffer.peripheralIDs.isEmpty)
|
#expect(buffer.peripheralIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptanceReportsWhenNewLowPriorityWriteIsTrimmed() {
|
||||||
|
var buffer = BLEOutboundWriteBuffer()
|
||||||
|
let peerID = "peer-1"
|
||||||
|
_ = buffer.enqueue(
|
||||||
|
data: Data(repeating: 0x01, count: 8),
|
||||||
|
for: peerID,
|
||||||
|
priority: .high,
|
||||||
|
capBytes: 8
|
||||||
|
)
|
||||||
|
|
||||||
|
let attempt = buffer.enqueueReportingAcceptance(
|
||||||
|
data: Data(repeating: 0x02, count: 8),
|
||||||
|
for: peerID,
|
||||||
|
priority: .low,
|
||||||
|
capBytes: 8
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!attempt.accepted)
|
||||||
|
#expect(buffer.takeAll(for: peerID).compactMap(\.data.first) == [0x01])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func disconnectDiscardRemovesOnlyThatPeripheralQueue() {
|
||||||
|
var buffer = BLEOutboundWriteBuffer()
|
||||||
|
_ = buffer.enqueue(data: Data([1]), for: "gone", priority: .high, capBytes: 100)
|
||||||
|
_ = buffer.enqueue(data: Data([2]), for: "live", priority: .high, capBytes: 100)
|
||||||
|
|
||||||
|
buffer.discardAll(for: "gone")
|
||||||
|
|
||||||
|
#expect(buffer.takeAll(for: "gone").isEmpty)
|
||||||
|
#expect(buffer.takeAll(for: "live").map(\.data) == [Data([2])])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ struct BridgeCourierServiceTests {
|
|||||||
var held: [CourierEnvelope] = []
|
var held: [CourierEnvelope] = []
|
||||||
var sealResult: CourierEnvelope?
|
var sealResult: CourierEnvelope?
|
||||||
var deliverResult = true
|
var deliverResult = true
|
||||||
|
var openResult = true
|
||||||
|
/// nil leaves the simulated relay confirmation in flight.
|
||||||
|
var automaticPublishResult: Bool? = true
|
||||||
|
|
||||||
private(set) var publishedEvents: [NostrEvent] = []
|
private(set) var publishedEvents: [NostrEvent] = []
|
||||||
private(set) var openedSubscriptions: [[String]] = []
|
private(set) var openedSubscriptions: [[String]] = []
|
||||||
@@ -33,14 +36,28 @@ struct BridgeCourierServiceTests {
|
|||||||
private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = []
|
private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = []
|
||||||
private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = []
|
private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = []
|
||||||
private(set) var heldCooldowns: [TimeInterval] = []
|
private(set) var heldCooldowns: [TimeInterval] = []
|
||||||
|
private(set) var markedHeldEnvelopes: [CourierEnvelope] = []
|
||||||
|
private(set) var scheduledTimers: [(delay: TimeInterval, fire: @MainActor () -> Void)] = []
|
||||||
|
private(set) var pendingPublishCompletions: [@MainActor (Bool) -> Void] = []
|
||||||
|
|
||||||
let service: BridgeCourierService
|
let service: BridgeCourierService
|
||||||
|
|
||||||
init(dedupStore: BridgeDropDedupStore? = nil) {
|
init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) {
|
||||||
service = BridgeCourierService(dedupStore: dedupStore)
|
service = BridgeCourierService(now: now, dedupStore: dedupStore)
|
||||||
service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false }
|
service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false }
|
||||||
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
|
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
|
||||||
service.publishEvent = { [weak self] event in self?.publishedEvents.append(event) }
|
service.publishEvent = { [weak self] event, completion in
|
||||||
|
guard let self else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.publishedEvents.append(event)
|
||||||
|
if let result = self.automaticPublishResult {
|
||||||
|
completion(result)
|
||||||
|
} else {
|
||||||
|
self.pendingPublishCompletions.append(completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) }
|
service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) }
|
||||||
service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 }
|
service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 }
|
||||||
service.myNoiseKey = { [weak self] in self?.myKey }
|
service.myNoiseKey = { [weak self] in self?.myKey }
|
||||||
@@ -49,7 +66,10 @@ struct BridgeCourierServiceTests {
|
|||||||
self?.sealRequests.append((content, messageID, key))
|
self?.sealRequests.append((content, messageID, key))
|
||||||
return self?.sealResult
|
return self?.sealResult
|
||||||
}
|
}
|
||||||
service.openEnvelope = { [weak self] envelope in self?.openedEnvelopes.append(envelope) }
|
service.openEnvelope = { [weak self] envelope in
|
||||||
|
self?.openedEnvelopes.append(envelope)
|
||||||
|
return self?.openResult ?? false
|
||||||
|
}
|
||||||
service.deliverToPeer = { [weak self] envelope, peer in
|
service.deliverToPeer = { [weak self] envelope, peer in
|
||||||
self?.delivered.append((envelope, peer))
|
self?.delivered.append((envelope, peer))
|
||||||
return self?.deliverResult ?? false
|
return self?.deliverResult ?? false
|
||||||
@@ -58,7 +78,17 @@ struct BridgeCourierServiceTests {
|
|||||||
self?.heldCooldowns.append(cooldown)
|
self?.heldCooldowns.append(cooldown)
|
||||||
return self?.held ?? []
|
return self?.held ?? []
|
||||||
}
|
}
|
||||||
service.scheduleTimer = { _, _ in } // timers driven manually
|
service.markHeldEnvelopePublished = { [weak self] envelope in
|
||||||
|
self?.markedHeldEnvelopes.append(envelope)
|
||||||
|
}
|
||||||
|
service.scheduleTimer = { [weak self] delay, fire in
|
||||||
|
self?.scheduledTimers.append((delay, fire))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNextPublish(_ succeeded: Bool) {
|
||||||
|
guard !pendingPublishCompletions.isEmpty else { return }
|
||||||
|
pendingPublishCompletions.removeFirst()(succeeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func randomKey() -> Data {
|
static func randomKey() -> Data {
|
||||||
@@ -121,6 +151,115 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(fixture.sealRequests.isEmpty)
|
#expect(fixture.sealRequests.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func missingRelayPublisherDoesNotConsumeDurableDedupSlot() {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let key = Fixture.randomKey()
|
||||||
|
fixture.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
fixture.service.publishEvent = nil
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
|
fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
|
||||||
|
fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
|
||||||
|
#expect(fixture.sealRequests.count == 2)
|
||||||
|
#expect(fixture.publishedEvents.isEmpty)
|
||||||
|
#expect(results == [false, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func relayRejectionDoesNotPersistDedupAndRetryCanSucceed() {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let key = Fixture.randomKey()
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
|
||||||
|
let failed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
|
failed.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
failed.automaticPublishResult = false
|
||||||
|
var failedResults: [Bool] = []
|
||||||
|
failed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) {
|
||||||
|
failedResults.append($0)
|
||||||
|
}
|
||||||
|
failed.service.flushDedupSnapshot()
|
||||||
|
#expect(failedResults == [false])
|
||||||
|
#expect(failed.publishedEvents.count == 1)
|
||||||
|
|
||||||
|
// A relaunch over the failed attempt must be allowed to send again.
|
||||||
|
let retry = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
|
retry.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
var retryResults: [Bool] = []
|
||||||
|
retry.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) {
|
||||||
|
retryResults.append($0)
|
||||||
|
}
|
||||||
|
retry.service.flushDedupSnapshot()
|
||||||
|
#expect(retryResults == [true])
|
||||||
|
#expect(retry.publishedEvents.count == 1)
|
||||||
|
|
||||||
|
// Only confirmed relay acceptance consumes durable dedup.
|
||||||
|
let confirmed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
|
confirmed.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
confirmed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key)
|
||||||
|
#expect(confirmed.publishedEvents.isEmpty)
|
||||||
|
#expect(confirmed.sealRequests.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let key = Fixture.randomKey()
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
|
fixture.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
fixture.automaticPublishResult = nil
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
|
fixture.service.depositDrop(content: "in flight", messageID: messageID, recipientNoiseKey: key) {
|
||||||
|
results.append($0)
|
||||||
|
}
|
||||||
|
let staleCompletion = try #require(fixture.pendingPublishCompletions.first)
|
||||||
|
fixture.service.wipe()
|
||||||
|
#expect(results == [false])
|
||||||
|
|
||||||
|
// The pre-wipe relay completion cannot resurrect durable dedup or
|
||||||
|
// complete the caller a second time.
|
||||||
|
staleCompletion(true)
|
||||||
|
fixture.service.flushDedupSnapshot()
|
||||||
|
#expect(results == [false])
|
||||||
|
|
||||||
|
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
|
relaunched.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
relaunched.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key)
|
||||||
|
#expect(relaunched.publishedEvents.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bridgeDisableCancelsPendingAndInFlightPublishes() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let key = Fixture.randomKey()
|
||||||
|
fixture.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
fixture.automaticPublishResult = nil
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
|
fixture.service.depositDrop(content: "in flight", messageID: "in-flight", recipientNoiseKey: key) {
|
||||||
|
results.append($0)
|
||||||
|
}
|
||||||
|
let staleCompletion = try #require(fixture.pendingPublishCompletions.first)
|
||||||
|
|
||||||
|
fixture.relaysConnected = false
|
||||||
|
fixture.service.depositDrop(content: "pending", messageID: "pending", recipientNoiseKey: key) {
|
||||||
|
results.append($0)
|
||||||
|
}
|
||||||
|
#expect(fixture.service.pendingDrops.count == 1)
|
||||||
|
|
||||||
|
fixture.bridgeOn = false
|
||||||
|
fixture.service.refresh()
|
||||||
|
#expect(results == [false, false])
|
||||||
|
#expect(fixture.service.pendingDrops.isEmpty)
|
||||||
|
|
||||||
|
staleCompletion(true)
|
||||||
|
#expect(results == [false, false])
|
||||||
|
}
|
||||||
|
|
||||||
@Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() {
|
@Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() {
|
||||||
let fixture = Fixture()
|
let fixture = Fixture()
|
||||||
fixture.relaysConnected = false
|
fixture.relaysConnected = false
|
||||||
@@ -148,15 +287,18 @@ struct BridgeCourierServiceTests {
|
|||||||
fixture.sealResult = makeEnvelope(recipientKey: key)
|
fixture.sealResult = makeEnvelope(recipientKey: key)
|
||||||
|
|
||||||
let firstID = UUID().uuidString
|
let firstID = UUID().uuidString
|
||||||
#expect(fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key))
|
var firstResults: [Bool] = []
|
||||||
|
fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key) { firstResults.append($0) }
|
||||||
// Fill past capacity so the first drop is evicted.
|
// Fill past capacity so the first drop is evicted.
|
||||||
for i in 1...BridgeCourierService.Limits.maxPendingDrops {
|
for i in 1...BridgeCourierService.Limits.maxPendingDrops {
|
||||||
fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key)
|
fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key)
|
||||||
}
|
}
|
||||||
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
||||||
|
#expect(firstResults == [false])
|
||||||
|
|
||||||
// The evicted first drop is deposit-able again (slot released).
|
// The evicted first drop is deposit-able again (slot released).
|
||||||
#expect(fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key))
|
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
||||||
|
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
||||||
@@ -170,13 +312,45 @@ struct BridgeCourierServiceTests {
|
|||||||
ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1)
|
ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1)
|
||||||
)
|
)
|
||||||
let messageID = UUID().uuidString
|
let messageID = UUID().uuidString
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
|
fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
|
||||||
#expect(fixture.publishedEvents.isEmpty)
|
#expect(fixture.publishedEvents.isEmpty)
|
||||||
|
|
||||||
// The retry sweep must not seal the same payload again.
|
// The retry sweep must not seal the same payload again.
|
||||||
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
|
fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
|
||||||
#expect(fixture.sealRequests.count == 1)
|
#expect(fixture.sealRequests.count == 1)
|
||||||
|
#expect(results == [false, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectedOversizeDropKeysExpireAndStayBounded() {
|
||||||
|
var date = Date(timeIntervalSince1970: 1_750_000_000)
|
||||||
|
let fixture = Fixture(now: { date })
|
||||||
|
let key = Fixture.randomKey()
|
||||||
|
fixture.sealResult = makeEnvelope(
|
||||||
|
recipientKey: key,
|
||||||
|
ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
let firstID = "oversize-0"
|
||||||
|
fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key)
|
||||||
|
for index in 1...BridgeCourierService.Limits.maxTrackedIDs {
|
||||||
|
date = date.addingTimeInterval(1)
|
||||||
|
fixture.service.depositDrop(content: "big", messageID: "oversize-\(index)", recipientNoiseKey: key)
|
||||||
|
}
|
||||||
|
let afterCapacityFill = fixture.sealRequests.count
|
||||||
|
date = date.addingTimeInterval(1)
|
||||||
|
fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key)
|
||||||
|
#expect(fixture.sealRequests.count == afterCapacityFill + 1)
|
||||||
|
|
||||||
|
let newestID = "oversize-\(BridgeCourierService.Limits.maxTrackedIDs)"
|
||||||
|
let beforeExpiry = fixture.sealRequests.count
|
||||||
|
fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key)
|
||||||
|
#expect(fixture.sealRequests.count == beforeExpiry)
|
||||||
|
|
||||||
|
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
|
||||||
|
fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key)
|
||||||
|
#expect(fixture.sealRequests.count == beforeExpiry + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func publishedDropDedupSurvivesRelaunch() throws {
|
@Test func publishedDropDedupSurvivesRelaunch() throws {
|
||||||
@@ -192,8 +366,10 @@ struct BridgeCourierServiceTests {
|
|||||||
|
|
||||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
#expect(fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey))
|
var publishResults: [Bool] = []
|
||||||
|
fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { publishResults.append($0) }
|
||||||
#expect(fixture.publishedEvents.count == 1)
|
#expect(fixture.publishedEvents.count == 1)
|
||||||
|
#expect(publishResults == [true])
|
||||||
// Persistence is coalesced; a real launch flushes within a second or
|
// Persistence is coalesced; a real launch flushes within a second or
|
||||||
// on backgrounding — tests flush explicitly.
|
// on backgrounding — tests flush explicitly.
|
||||||
fixture.service.flushDedupSnapshot()
|
fixture.service.flushDedupSnapshot()
|
||||||
@@ -202,9 +378,11 @@ struct BridgeCourierServiceTests {
|
|||||||
// publish the same message ID again (before even re-sealing it).
|
// publish the same message ID again (before even re-sealing it).
|
||||||
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
|
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
#expect(!relaunched.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey))
|
var relaunchResults: [Bool] = []
|
||||||
|
relaunched.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) }
|
||||||
#expect(relaunched.publishedEvents.isEmpty)
|
#expect(relaunched.publishedEvents.isEmpty)
|
||||||
#expect(relaunched.sealRequests.isEmpty)
|
#expect(relaunched.sealRequests.isEmpty)
|
||||||
|
#expect(relaunchResults == [false])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func seenDropEventDedupSurvivesRelaunch() throws {
|
@Test func seenDropEventDedupSurvivesRelaunch() throws {
|
||||||
@@ -245,7 +423,7 @@ struct BridgeCourierServiceTests {
|
|||||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
fixture.relaysConnected = false
|
fixture.relaysConnected = false
|
||||||
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
#expect(fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
|
fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey)
|
||||||
#expect(fixture.publishedEvents.isEmpty)
|
#expect(fixture.publishedEvents.isEmpty)
|
||||||
// Even a flush while the drop is still pending must exclude its key.
|
// Even a flush while the drop is still pending must exclude its key.
|
||||||
fixture.service.flushDedupSnapshot()
|
fixture.service.flushDedupSnapshot()
|
||||||
@@ -253,7 +431,7 @@ struct BridgeCourierServiceTests {
|
|||||||
// "App killed before relays connected": pendingDrops were memory-only.
|
// "App killed before relays connected": pendingDrops were memory-only.
|
||||||
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
|
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
#expect(relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
|
relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey)
|
||||||
#expect(relaunched.publishedEvents.count == 1)
|
#expect(relaunched.publishedEvents.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +447,7 @@ struct BridgeCourierServiceTests {
|
|||||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
fixture.relaysConnected = false
|
fixture.relaysConnected = false
|
||||||
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
#expect(fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
|
fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey)
|
||||||
fixture.relaysConnected = true
|
fixture.relaysConnected = true
|
||||||
fixture.service.flushPendingDrops()
|
fixture.service.flushPendingDrops()
|
||||||
#expect(fixture.publishedEvents.count == 1)
|
#expect(fixture.publishedEvents.count == 1)
|
||||||
@@ -277,8 +455,10 @@ struct BridgeCourierServiceTests {
|
|||||||
|
|
||||||
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
|
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
#expect(!relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
|
var relaunchResults: [Bool] = []
|
||||||
|
relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) }
|
||||||
#expect(relaunched.publishedEvents.isEmpty)
|
#expect(relaunched.publishedEvents.isEmpty)
|
||||||
|
#expect(relaunchResults == [false])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func failedGatewayHandoffReleasesSeenSlot() throws {
|
@Test func failedGatewayHandoffReleasesSeenSlot() throws {
|
||||||
@@ -307,6 +487,30 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(fixture.delivered.count == 2)
|
#expect(fixture.delivered.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func staleWatchSetDeliveryIsNotConsumedBeforePeerBecomesCurrent() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let peerKey = Fixture.randomKey()
|
||||||
|
let peer = PeerID(str: "aabbccdd00112233")
|
||||||
|
let event = try makeDropEvent(for: makeEnvelope(recipientKey: peerKey))
|
||||||
|
|
||||||
|
// A callback from the previous relay subscription can land after its
|
||||||
|
// peer was removed from the bounded watch set. Ignore it without
|
||||||
|
// poisoning the persistent event-ID dedup record.
|
||||||
|
fixture.service.refresh()
|
||||||
|
fixture.service.handleDropEvent(event)
|
||||||
|
#expect(fixture.delivered.isEmpty)
|
||||||
|
|
||||||
|
fixture.localPeers = [(peer, peerKey)]
|
||||||
|
fixture.service.refresh()
|
||||||
|
fixture.service.handleDropEvent(event)
|
||||||
|
#expect(fixture.delivered.count == 1)
|
||||||
|
|
||||||
|
// Once the current peer's physical handoff succeeds, normal durable
|
||||||
|
// dedup applies.
|
||||||
|
fixture.service.handleDropEvent(event)
|
||||||
|
#expect(fixture.delivered.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func distinctDropsUseDistinctThrowawayKeys() {
|
@Test func distinctDropsUseDistinctThrowawayKeys() {
|
||||||
let fixture = Fixture()
|
let fixture = Fixture()
|
||||||
let keyA = Fixture.randomKey()
|
let keyA = Fixture.randomKey()
|
||||||
@@ -328,6 +532,50 @@ struct BridgeCourierServiceTests {
|
|||||||
|
|
||||||
#expect(fixture.publishedEvents.count == 1)
|
#expect(fixture.publishedEvents.count == 1)
|
||||||
#expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown])
|
#expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown])
|
||||||
|
#expect(fixture.markedHeldEnvelopes == fixture.held)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectedHeldPublishDoesNotStartCooldown() {
|
||||||
|
let fixture = Fixture()
|
||||||
|
fixture.automaticPublishResult = false
|
||||||
|
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
|
||||||
|
|
||||||
|
fixture.service.publishHeldEnvelopes()
|
||||||
|
|
||||||
|
#expect(fixture.publishedEvents.count == 1)
|
||||||
|
#expect(fixture.markedHeldEnvelopes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func heldPublishIsSingleFlightAndRetryableAfterRejection() {
|
||||||
|
let fixture = Fixture()
|
||||||
|
fixture.automaticPublishResult = nil
|
||||||
|
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
|
||||||
|
|
||||||
|
fixture.service.publishHeldEnvelopes()
|
||||||
|
fixture.service.publishHeldEnvelopes()
|
||||||
|
#expect(fixture.publishedEvents.count == 1)
|
||||||
|
|
||||||
|
fixture.resolveNextPublish(false)
|
||||||
|
fixture.service.publishHeldEnvelopes()
|
||||||
|
#expect(fixture.publishedEvents.count == 2)
|
||||||
|
#expect(fixture.markedHeldEnvelopes.isEmpty)
|
||||||
|
|
||||||
|
fixture.resolveNextPublish(true)
|
||||||
|
#expect(fixture.markedHeldEnvelopes == fixture.held)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bridgeDisableInvalidatesHeldPublishOperation() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
fixture.automaticPublishResult = nil
|
||||||
|
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
|
||||||
|
|
||||||
|
fixture.service.publishHeldEnvelopes()
|
||||||
|
let staleCompletion = try #require(fixture.pendingPublishCompletions.first)
|
||||||
|
fixture.bridgeOn = false
|
||||||
|
fixture.service.refresh()
|
||||||
|
staleCompletion(true)
|
||||||
|
|
||||||
|
#expect(fixture.markedHeldEnvelopes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subscription management
|
// MARK: - Subscription management
|
||||||
@@ -364,6 +612,32 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(fixture.closedSubscriptions == 1)
|
#expect(fixture.closedSubscriptions == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func announceDebounceSchedulesTrailingRefreshForPeersLearnedInsideWindow() throws {
|
||||||
|
var date = Date(timeIntervalSince1970: 1_750_000_000)
|
||||||
|
let fixture = Fixture(now: { date })
|
||||||
|
|
||||||
|
// Leading edge opens the own-tag subscription immediately.
|
||||||
|
fixture.service.refreshAfterVerifiedAnnounce()
|
||||||
|
#expect(fixture.openedSubscriptions.count == 1)
|
||||||
|
|
||||||
|
// A second peer learned inside the debounce window must not wait for
|
||||||
|
// the 30-minute periodic timer.
|
||||||
|
date = date.addingTimeInterval(10)
|
||||||
|
fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), Fixture.randomKey())]
|
||||||
|
fixture.service.refreshAfterVerifiedAnnounce()
|
||||||
|
fixture.service.refreshAfterVerifiedAnnounce() // coalesces, not a second timer
|
||||||
|
|
||||||
|
let trailingTimers = fixture.scheduledTimers.filter { $0.delay < 100 }
|
||||||
|
#expect(trailingTimers.count == 1)
|
||||||
|
let trailing = try #require(trailingTimers.first)
|
||||||
|
#expect(trailing.delay == 50)
|
||||||
|
date = date.addingTimeInterval(50)
|
||||||
|
trailing.fire()
|
||||||
|
|
||||||
|
#expect(fixture.openedSubscriptions.count == 2)
|
||||||
|
#expect(fixture.openedSubscriptions.last?.count == 6)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Inbound drops
|
// MARK: - Inbound drops
|
||||||
|
|
||||||
@Test func dropForUsIsOpened() throws {
|
@Test func dropForUsIsOpened() throws {
|
||||||
@@ -378,6 +652,21 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(fixture.delivered.isEmpty)
|
#expect(fixture.delivered.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func transientOwnDropOpenFailureRemainsRetryable() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let myKey = try #require(fixture.myKey)
|
||||||
|
fixture.service.refresh()
|
||||||
|
let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey))
|
||||||
|
|
||||||
|
fixture.openResult = false
|
||||||
|
fixture.service.handleDropEvent(event)
|
||||||
|
fixture.openResult = true
|
||||||
|
fixture.service.handleDropEvent(event)
|
||||||
|
fixture.service.handleDropEvent(event)
|
||||||
|
|
||||||
|
#expect(fixture.openedEnvelopes.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func duplicateDropEventOpensOnce() throws {
|
@Test func duplicateDropEventOpensOnce() throws {
|
||||||
let fixture = Fixture()
|
let fixture = Fixture()
|
||||||
let myKey = try #require(fixture.myKey)
|
let myKey = try #require(fixture.myKey)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ struct BridgeDropDedupStoreTests {
|
|||||||
lifetime: 3600,
|
lifetime: 3600,
|
||||||
entries: [
|
entries: [
|
||||||
"stale": now.addingTimeInterval(-7200),
|
"stale": now.addingTimeInterval(-7200),
|
||||||
"fresh": now.addingTimeInterval(-60),
|
"fresh": now.addingTimeInterval(-60)
|
||||||
],
|
],
|
||||||
now: now
|
now: now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Testing
|
|||||||
@Suite("Mesh bridge policy")
|
@Suite("Mesh bridge policy")
|
||||||
@MainActor
|
@MainActor
|
||||||
struct BridgeServiceTests {
|
struct BridgeServiceTests {
|
||||||
private static let cell = "u4pruy"
|
nonisolated private static let cell = "u4pruy"
|
||||||
|
|
||||||
/// Closure-injected harness around `BridgeService` recording every side
|
/// Closure-injected harness around `BridgeService` recording every side
|
||||||
/// effect, with a controllable clock, location, and connectivity.
|
/// effect, with a controllable clock, location, and connectivity.
|
||||||
@@ -30,6 +30,7 @@ struct BridgeServiceTests {
|
|||||||
var bridgePeers: [PeerID] = []
|
var bridgePeers: [PeerID] = []
|
||||||
var sendSucceeds = true
|
var sendSucceeds = true
|
||||||
var locallySeenMessageIDs: Set<String> = []
|
var locallySeenMessageIDs: Set<String> = []
|
||||||
|
var injectedPresenceOverride: ((String) -> Bool)?
|
||||||
var nickname = "tester"
|
var nickname = "tester"
|
||||||
|
|
||||||
private(set) var published: [(event: NostrEvent, cell: String)] = []
|
private(set) var published: [(event: NostrEvent, cell: String)] = []
|
||||||
@@ -41,6 +42,7 @@ struct BridgeServiceTests {
|
|||||||
}
|
}
|
||||||
private(set) var broadcasts: [Data] = []
|
private(set) var broadcasts: [Data] = []
|
||||||
private(set) var injected: [BridgeService.InboundBridgeMessage] = []
|
private(set) var injected: [BridgeService.InboundBridgeMessage] = []
|
||||||
|
private(set) var removedInjectedMessageIDs: [String] = []
|
||||||
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
|
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
|
||||||
private(set) var openedSubscriptions: [[String]] = []
|
private(set) var openedSubscriptions: [[String]] = []
|
||||||
private(set) var closedSubscriptions = 0
|
private(set) var closedSubscriptions = 0
|
||||||
@@ -54,13 +56,20 @@ struct BridgeServiceTests {
|
|||||||
let defaults: UserDefaults
|
let defaults: UserDefaults
|
||||||
let service: BridgeService
|
let service: BridgeService
|
||||||
|
|
||||||
init(enabled: Bool = true) {
|
init(
|
||||||
|
enabled: Bool = true,
|
||||||
|
verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() }
|
||||||
|
) {
|
||||||
let suite = "BridgeServiceTests-\(UUID().uuidString)"
|
let suite = "BridgeServiceTests-\(UUID().uuidString)"
|
||||||
defaults = UserDefaults(suiteName: suite)!
|
defaults = UserDefaults(suiteName: suite)!
|
||||||
defaults.removePersistentDomain(forName: suite)
|
defaults.removePersistentDomain(forName: suite)
|
||||||
identity = try! NostrIdentity.generate()
|
identity = try! NostrIdentity.generate()
|
||||||
let clock = clock
|
let clock = clock
|
||||||
service = BridgeService(defaults: defaults) { clock.now }
|
service = BridgeService(
|
||||||
|
defaults: defaults,
|
||||||
|
now: { clock.now },
|
||||||
|
verifyEventSignature: verifyEventSignature
|
||||||
|
)
|
||||||
service.publishToRelays = { [weak self] event, cell in
|
service.publishToRelays = { [weak self] event, cell in
|
||||||
self?.published.append((event, cell))
|
self?.published.append((event, cell))
|
||||||
}
|
}
|
||||||
@@ -86,6 +95,14 @@ struct BridgeServiceTests {
|
|||||||
service.injectInbound = { [weak self] message in
|
service.injectInbound = { [weak self] message in
|
||||||
self?.injected.append(message)
|
self?.injected.append(message)
|
||||||
}
|
}
|
||||||
|
service.removeInjectedInbound = { [weak self] messageID in
|
||||||
|
self?.removedInjectedMessageIDs.append(messageID)
|
||||||
|
}
|
||||||
|
service.isInjectedInboundPresent = { [weak self] messageID in
|
||||||
|
guard let self else { return false }
|
||||||
|
return self.injectedPresenceOverride?(messageID)
|
||||||
|
?? self.injected.contains { $0.messageID == messageID }
|
||||||
|
}
|
||||||
service.isMessageSeenLocally = { [weak self] id in
|
service.isMessageSeenLocally = { [weak self] id in
|
||||||
self?.locallySeenMessageIDs.contains(id) ?? false
|
self?.locallySeenMessageIDs.contains(id) ?? false
|
||||||
}
|
}
|
||||||
@@ -117,8 +134,8 @@ struct BridgeServiceTests {
|
|||||||
|
|
||||||
// MARK: Event helpers
|
// MARK: Event helpers
|
||||||
|
|
||||||
private static let remoteMeshSenderID = "feedfacecafef00d"
|
nonisolated private static let remoteMeshSenderID = "feedfacecafef00d"
|
||||||
private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000
|
nonisolated private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000
|
||||||
|
|
||||||
private func makeRemoteEvent(
|
private func makeRemoteEvent(
|
||||||
cell: String = BridgeServiceTests.cell,
|
cell: String = BridgeServiceTests.cell,
|
||||||
@@ -233,7 +250,7 @@ struct BridgeServiceTests {
|
|||||||
"m",
|
"m",
|
||||||
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "hello hill"),
|
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "hello hill"),
|
||||||
sender.id,
|
sender.id,
|
||||||
String(timestampMs),
|
String(timestampMs)
|
||||||
]))
|
]))
|
||||||
#expect(published.event.tags.contains(["n", "tester"]))
|
#expect(published.event.tags.contains(["n", "tester"]))
|
||||||
}
|
}
|
||||||
@@ -263,7 +280,7 @@ struct BridgeServiceTests {
|
|||||||
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
|
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
|
||||||
#expect(oldParserKeys == [
|
#expect(oldParserKeys == [
|
||||||
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "first"),
|
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "first"),
|
||||||
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "second"),
|
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "second")
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +357,8 @@ struct BridgeServiceTests {
|
|||||||
|
|
||||||
#expect(fixture.injected.count == 1)
|
#expect(fixture.injected.count == 1)
|
||||||
#expect(fixture.injected.first?.content == event.content)
|
#expect(fixture.injected.first?.content == event.content)
|
||||||
|
#expect(fixture.injected.first?.messageID == event.id)
|
||||||
|
#expect(fixture.injected.first?.senderNickname == "remote#\(event.pubkey.suffix(4))")
|
||||||
#expect(fixture.service.bridgedPeerCount == 1)
|
#expect(fixture.service.bridgedPeerCount == 1)
|
||||||
// Serving duty: after the jitter holdoff, the remote event rides out
|
// Serving duty: after the jitter holdoff, the remote event rides out
|
||||||
// as a fromBridge broadcast — one switch, no gateway toggle.
|
// as a fromBridge broadcast — one switch, no gateway toggle.
|
||||||
@@ -401,18 +420,118 @@ struct BridgeServiceTests {
|
|||||||
|
|
||||||
fixture.service.handleRendezvousEvent(event)
|
fixture.service.handleRendezvousEvent(event)
|
||||||
|
|
||||||
// The island already heard this over radio: no duplicate render, no
|
// The island already heard this over radio: no duplicate render or
|
||||||
// wasted airtime — but the sender still counts as a (local)
|
// wasted airtime. The public hint cannot attribute the Nostr signer,
|
||||||
// participant, never a bridged one.
|
// so it does not mutate participant state either.
|
||||||
#expect(fixture.injected.isEmpty)
|
#expect(fixture.injected.isEmpty)
|
||||||
#expect(fixture.broadcasts.isEmpty)
|
#expect(fixture.broadcasts.isEmpty)
|
||||||
#expect(fixture.service.bridgedPeerCount == 0)
|
#expect(fixture.service.bridgedPeerCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func radioConfirmedSenderStaysLocalForLaterEvents() throws {
|
@Test func bridgeFirstThenAuthenticatedRadioReplacesAliasesAndCancelsDownlink() throws {
|
||||||
// Sticky local attribution keyed on the derived stable ID: one
|
let fixture = Fixture(enabled: true)
|
||||||
// radio-confirmed message marks the pubkey as an islander, so their
|
fixture.service.refreshRendezvous()
|
||||||
// later events never inflate the bridged count.
|
let content = "bridge arrived first"
|
||||||
|
let radioMessageID = stableID(content: content)
|
||||||
|
let event = try makeRemoteEvent(content: content)
|
||||||
|
|
||||||
|
fixture.service.handleRendezvousEvent(event)
|
||||||
|
#expect(fixture.injected.map(\.messageID) == [event.id])
|
||||||
|
#expect(fixture.service.bridgedPeerCount == 1)
|
||||||
|
|
||||||
|
// This entry point is called only after the BLE packet signature has
|
||||||
|
// authenticated. The radio row must win; the public m-tag hint is not
|
||||||
|
// trusted enough to suppress it.
|
||||||
|
fixture.service.handleAuthenticatedRadioMessage(messageID: radioMessageID)
|
||||||
|
fixture.fireScheduledTimers()
|
||||||
|
|
||||||
|
#expect(fixture.removedInjectedMessageIDs == [event.id])
|
||||||
|
#expect(fixture.broadcasts.isEmpty)
|
||||||
|
#expect(fixture.service.bridgedPeerCount == 1)
|
||||||
|
|
||||||
|
// A different signer copying the same public mesh coordinates after
|
||||||
|
// radio authentication cannot put a bridge alias back into the row.
|
||||||
|
fixture.service.handleRendezvousEvent(try makeRemoteEvent(content: content))
|
||||||
|
#expect(fixture.injected.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func disablingBridgeDoesNotForgetAliasNeededByLaterRadioCopy() throws {
|
||||||
|
let fixture = Fixture(enabled: true)
|
||||||
|
fixture.service.refreshRendezvous()
|
||||||
|
let content = "radio arrives after opt-out"
|
||||||
|
let event = try makeRemoteEvent(content: content)
|
||||||
|
|
||||||
|
fixture.service.handleRendezvousEvent(event)
|
||||||
|
fixture.service.setEnabled(false)
|
||||||
|
fixture.service.handleAuthenticatedRadioMessage(messageID: stableID(content: content))
|
||||||
|
|
||||||
|
#expect(fixture.removedInjectedMessageIDs == [event.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aliasPruningUsesExactRowLivenessWithoutDeletingHistory() throws {
|
||||||
|
let fixture = Fixture(enabled: true, verifyEventSignature: { _ in true })
|
||||||
|
fixture.service.refreshRendezvous()
|
||||||
|
|
||||||
|
func event(index: Int) -> NostrEvent {
|
||||||
|
let senderID = String(format: "%016llx", UInt64(index + 1))
|
||||||
|
let content = "bridge overflow \(index)"
|
||||||
|
let timestampMs = UInt64(1_750_000_000_000) + UInt64(index)
|
||||||
|
var event = NostrEvent(
|
||||||
|
pubkey: String(format: "%064llx", UInt64(index + 1)),
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: [
|
||||||
|
["r", Self.cell],
|
||||||
|
["n", "remote"],
|
||||||
|
["m", "unused", senderID, String(timestampMs)]
|
||||||
|
],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
event.id = String(format: "%064llx", UInt64(index + 10_000))
|
||||||
|
event.sig = String(repeating: "0", count: 128)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldest = event(index: 0)
|
||||||
|
fixture.service.handleRendezvousEvent(oldest)
|
||||||
|
for index in 1...BridgeService.Limits.maxTrackedEventIDs {
|
||||||
|
if index.isMultiple(of: 500) { fixture.advance(61) }
|
||||||
|
fixture.service.handleRendezvousEvent(event(index: index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loop/dedup caches are intentionally smaller, but valid ingress
|
||||||
|
// through that boundary must not delete a still-visible bridge row.
|
||||||
|
#expect(fixture.removedInjectedMessageIDs.isEmpty)
|
||||||
|
|
||||||
|
for index in (BridgeService.Limits.maxTrackedEventIDs + 1)..<BridgeService.Limits.maxTrackedRadioAliases {
|
||||||
|
if index.isMultiple(of: 500) { fixture.advance(61) }
|
||||||
|
fixture.service.handleRendezvousEvent(event(index: index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp-order trimming need not match arrival order. Model the
|
||||||
|
// store having evicted a middle-arrival row, then exceed alias capacity.
|
||||||
|
let noLongerVisibleID = event(index: 700).id
|
||||||
|
fixture.injectedPresenceOverride = { $0 != noLongerVisibleID }
|
||||||
|
fixture.service.handleRendezvousEvent(event(index: BridgeService.Limits.maxTrackedRadioAliases))
|
||||||
|
|
||||||
|
// Pruning discarded only proof for a row already absent. It never
|
||||||
|
// invokes active row deletion, and the still-visible oldest row keeps
|
||||||
|
// its radio-replacement proof despite out-of-order timestamps.
|
||||||
|
#expect(fixture.removedInjectedMessageIDs.isEmpty)
|
||||||
|
fixture.service.handleAuthenticatedRadioMessage(
|
||||||
|
messageID: stableID(
|
||||||
|
content: oldest.content,
|
||||||
|
meshSenderID: "0000000000000001",
|
||||||
|
meshTimestampMs: 1_750_000_000_000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(fixture.removedInjectedMessageIDs == [oldest.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func copiedRadioHintCannotMakeSignerStickyLocal() throws {
|
||||||
|
// A remote signer can copy public radio coordinates and content. That
|
||||||
|
// suppresses only the duplicate row; it cannot mark the signing key as
|
||||||
|
// local and hide the signer's later bridge traffic from the people UI.
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
@@ -427,33 +546,34 @@ struct BridgeServiceTests {
|
|||||||
meshTimestampMs: Self.remoteMeshTimestampMs + 1,
|
meshTimestampMs: Self.remoteMeshTimestampMs + 1,
|
||||||
identity: identity
|
identity: identity
|
||||||
))
|
))
|
||||||
#expect(fixture.service.bridgedPeerCount == 0)
|
#expect(fixture.service.bridgedPeerCount == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func spoofedOriginCoordinatesCannotSuppressTheGenuineMessage() throws {
|
@Test func spoofedOriginCoordinatesCannotSuppressTheGenuineMessage() throws {
|
||||||
// Attack: sign an event claiming a victim's origin coordinates in the
|
// Attack: copy the victim's exact content + mesh sender + timestamp,
|
||||||
// `m` tag to pre-poison the dedup key. The key is recomputed from the
|
// then sign under another Nostr key and arrive first. Public mesh
|
||||||
// event's own content, so the spoof lands under a different ID and the
|
// coordinates are only a radio-copy hint; signed event IDs own bridge
|
||||||
// genuine message still renders.
|
// dedup, so the attacker cannot reserve the genuine event's slot.
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
let spoof = try makeRemoteEvent(content: "impostor payload")
|
let spoof = try makeRemoteEvent(content: "the exact victim message")
|
||||||
let genuine = try makeRemoteEvent(content: "the real message")
|
let genuine = try makeRemoteEvent(content: "the exact victim message")
|
||||||
|
|
||||||
fixture.service.handleRendezvousEvent(spoof)
|
fixture.service.handleRendezvousEvent(spoof)
|
||||||
fixture.service.handleRendezvousEvent(genuine)
|
fixture.service.handleRendezvousEvent(genuine)
|
||||||
|
|
||||||
#expect(fixture.injected.count == 2)
|
#expect(fixture.injected.count == 2)
|
||||||
#expect(fixture.injected.map(\.messageID) == [
|
#expect(fixture.injected.map(\.messageID) == [spoof.id, genuine.id])
|
||||||
stableID(content: "impostor payload"),
|
#expect(fixture.injected.map(\.senderNickname) == [
|
||||||
stableID(content: "the real message"),
|
"remote#\(spoof.pubkey.suffix(4))",
|
||||||
|
"remote#\(genuine.pubkey.suffix(4))"
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func forgedStableIDElementIsNeverTrusted() throws {
|
@Test func forgedStableIDElementIsNeverTrusted() throws {
|
||||||
// Element 1 of the `m` tag exists only for old parsers. An event
|
// Element 1 of the `m` tag exists only for old parsers. An event
|
||||||
// claiming the genuine message's stable ID there — over different
|
// claiming the genuine message's stable ID there — over different
|
||||||
// content — must still key on its own derived ID, so it cannot
|
// content — must still key on its signed event ID, so it cannot
|
||||||
// pre-poison the genuine message's dedup slot.
|
// pre-poison the genuine message's dedup slot.
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
@@ -465,7 +585,7 @@ struct BridgeServiceTests {
|
|||||||
kind: .ephemeralEvent,
|
kind: .ephemeralEvent,
|
||||||
tags: [
|
tags: [
|
||||||
["r", Self.cell],
|
["r", Self.cell],
|
||||||
["m", genuineID, Self.remoteMeshSenderID, String(Self.remoteMeshTimestampMs)],
|
["m", genuineID, Self.remoteMeshSenderID, String(Self.remoteMeshTimestampMs)]
|
||||||
],
|
],
|
||||||
content: "impostor payload"
|
content: "impostor payload"
|
||||||
).sign(with: identity.schnorrSigningKey())
|
).sign(with: identity.schnorrSigningKey())
|
||||||
@@ -474,15 +594,12 @@ struct BridgeServiceTests {
|
|||||||
fixture.service.handleRendezvousEvent(forged)
|
fixture.service.handleRendezvousEvent(forged)
|
||||||
fixture.service.handleRendezvousEvent(genuine)
|
fixture.service.handleRendezvousEvent(genuine)
|
||||||
|
|
||||||
#expect(fixture.injected.map(\.messageID) == [
|
#expect(fixture.injected.map(\.messageID) == [forged.id, genuine.id])
|
||||||
stableID(content: "impostor payload"),
|
|
||||||
genuineID,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func oldFormatMeshTagFallsBackToEventID() throws {
|
@Test func oldFormatMeshTagAlsoUsesAuthenticatedEventID() throws {
|
||||||
// A 2-element `m` tag from an old sender carries an ID no other
|
// A 2-element `m` tag from an old sender is just as unauthenticated as
|
||||||
// device can recompute; the event ID keeps today's behavior.
|
// the current coordinates; the signed event ID remains authoritative.
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
@@ -522,6 +639,53 @@ struct BridgeServiceTests {
|
|||||||
#expect(fixture.service.bridgedPeerCount == 1)
|
#expect(fixture.service.bridgedPeerCount == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func participantStateIsCappedUnderRotatingKeyFlood() throws {
|
||||||
|
let fixture = Fixture(enabled: true)
|
||||||
|
fixture.service.refreshRendezvous()
|
||||||
|
|
||||||
|
for _ in 0..<(BridgeService.Limits.maxParticipants + 20) {
|
||||||
|
fixture.service.handleRendezvousEvent(try makePresenceEvent())
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fixture.service.bridgedPeerCount == BridgeService.Limits.maxParticipants)
|
||||||
|
#expect(fixture.service.bridgedParticipants.count == BridgeService.Limits.maxParticipants)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func relayIngressRateLimitsOneSigningKey() throws {
|
||||||
|
let fixture = Fixture(enabled: true)
|
||||||
|
fixture.service.refreshRendezvous()
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
for index in 0..<(BridgeService.Limits.inboundEventsPerMinutePerSigner + 10) {
|
||||||
|
fixture.service.handleRendezvousEvent(try makeRemoteEvent(
|
||||||
|
content: "rate \(index)",
|
||||||
|
meshTimestampMs: Self.remoteMeshTimestampMs + UInt64(index),
|
||||||
|
identity: identity
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fixture.injected.count == BridgeService.Limits.inboundEventsPerMinutePerSigner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func invalidIngressCannotTriggerUnboundedSignatureVerification() throws {
|
||||||
|
var verificationCount = 0
|
||||||
|
let fixture = Fixture(enabled: true) { _ in
|
||||||
|
verificationCount += 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fixture.service.refreshRendezvous()
|
||||||
|
let event = try makeRemoteEvent()
|
||||||
|
|
||||||
|
for _ in 0..<(BridgeService.Limits.signatureVerificationAttemptsPerMinute + 20) {
|
||||||
|
fixture.service.handleRendezvousEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(verificationCount == BridgeService.Limits.signatureVerificationAttemptsPerMinute)
|
||||||
|
fixture.advance(61)
|
||||||
|
fixture.service.handleRendezvousEvent(event)
|
||||||
|
#expect(verificationCount == BridgeService.Limits.signatureVerificationAttemptsPerMinute + 1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func staleParticipantsAgeOutOfTheCount() throws {
|
@Test func staleParticipantsAgeOutOfTheCount() throws {
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Testing
|
|||||||
@Suite("Gateway mode policy")
|
@Suite("Gateway mode policy")
|
||||||
@MainActor
|
@MainActor
|
||||||
struct GatewayServiceTests {
|
struct GatewayServiceTests {
|
||||||
private static let geohash = "u4pruy"
|
nonisolated private static let geohash = "u4pruy"
|
||||||
|
|
||||||
/// Closure-injected harness around `GatewayService` recording every
|
/// Closure-injected harness around `GatewayService` recording every
|
||||||
/// side effect, with a controllable clock and relay connectivity.
|
/// side effect, with a controllable clock and relay connectivity.
|
||||||
|
|||||||
@@ -86,6 +86,46 @@ final class LocationStateManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
|
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_permissionRevocation_endsLiveRefreshWithoutDiscardingExplicitState() async {
|
||||||
|
let storage = makeStorage()
|
||||||
|
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: storage,
|
||||||
|
locationManager: locationManager,
|
||||||
|
geocoder: MockLocationGeocoder(),
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
let authorized = await waitUntil { manager.permissionState == .authorized }
|
||||||
|
XCTAssertTrue(authorized)
|
||||||
|
|
||||||
|
manager.locationManager(
|
||||||
|
CLLocationManager(),
|
||||||
|
didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)]
|
||||||
|
)
|
||||||
|
let channelsLoaded = await waitUntil { !manager.availableChannels.isEmpty }
|
||||||
|
XCTAssertTrue(channelsLoaded)
|
||||||
|
let cachedChannels = manager.availableChannels
|
||||||
|
manager.addBookmark("u4pru")
|
||||||
|
manager.markTeleported(for: "9q8yy", true)
|
||||||
|
manager.select(.location(GeohashChannel(level: .city, geohash: "9q8yy")))
|
||||||
|
let teleported = await waitUntil { manager.teleported }
|
||||||
|
XCTAssertTrue(teleported)
|
||||||
|
|
||||||
|
manager.beginLiveRefresh()
|
||||||
|
XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)
|
||||||
|
|
||||||
|
manager.locationManager(CLLocationManager(), didChangeAuthorization: .restricted)
|
||||||
|
|
||||||
|
let restricted = await waitUntil { manager.permissionState == .restricted }
|
||||||
|
XCTAssertTrue(restricted)
|
||||||
|
XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)
|
||||||
|
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
|
||||||
|
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
|
||||||
|
XCTAssertEqual(manager.availableChannels, cachedChannels, "cached display state can remain")
|
||||||
|
XCTAssertEqual(manager.bookmarks, ["u4pru"])
|
||||||
|
XCTAssertTrue(manager.teleported, "an explicit remote selection survives device revocation")
|
||||||
|
}
|
||||||
|
|
||||||
func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {
|
func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {
|
||||||
let geocoder = MockLocationGeocoder()
|
let geocoder = MockLocationGeocoder()
|
||||||
geocoder.enqueue(
|
geocoder.enqueue(
|
||||||
|
|||||||
@@ -68,6 +68,145 @@ struct MessageOutboxStoreTests {
|
|||||||
#expect(other.load().isEmpty)
|
#expect(other.load().isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func permanentlyMissingDeviceKeyDiscardsOrphanAndNewSavesSurviveRelaunch() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
.save([peerID: [makeMessage("orphaned")]])
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
|
||||||
|
// Models a device restore: Application Support brought the sealed
|
||||||
|
// file across, but its AfterFirstUnlockThisDeviceOnly key cannot.
|
||||||
|
keychain.deleteAll(service: "chat.bitchat.outbox")
|
||||||
|
let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
|
||||||
|
restored.save([peerID: [makeMessage("fresh")]])
|
||||||
|
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(relaunched[peerID]?.map(\.messageID) == ["fresh"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func temporarilyLockedKeyDoesNotDiscardDurableSnapshot() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
.save([peerID: [makeMessage("durable")]])
|
||||||
|
let durableBytes = try? Data(contentsOf: fileURL)
|
||||||
|
|
||||||
|
keychain.simulatedGenericReadError = .deviceLocked
|
||||||
|
let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
#expect((try? Data(contentsOf: fileURL)) == durableBytes)
|
||||||
|
|
||||||
|
keychain.simulatedGenericReadError = nil
|
||||||
|
let recovered = restored.retryDeferredLoad()
|
||||||
|
#expect(recovered?[peerID]?.map(\.messageID) == ["durable"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicWipeInvalidatesQueuedRecoveryCallback() async {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
.save([peerID: [makeMessage("durable")]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restored = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
var deliveredRecoveries: [MessageOutboxStore.Snapshot] = []
|
||||||
|
restored.setRecoveryHandler { deliveredRecoveries.append($0) }
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restored.retryDeferredLoad() // queues the handler onto MainActor
|
||||||
|
restored.wipe() // invalidates it before the queued Task runs
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
#expect(deliveredRecoveries.isEmpty)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func wipeBetweenRecoveryUnlockAndNotificationDropsRecoveredSnapshot() async {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
.save([peerID: [makeMessage("durable")]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
var gapAction: (() -> Void)?
|
||||||
|
let restored = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
},
|
||||||
|
beforeRecoveryNotification: { gapAction?() }
|
||||||
|
)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
var deliveredRecoveries: [MessageOutboxStore.Snapshot] = []
|
||||||
|
restored.setRecoveryHandler { deliveredRecoveries.append($0) }
|
||||||
|
gapAction = { restored.wipe() }
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restored.retryDeferredLoad()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
#expect(deliveredRecoveries.isEmpty)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deferredRemovalTombstoneFiltersUnseenDurableMessage() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
.save([peerID: [makeMessage("durable")]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restored = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
restored.recordRemoval(messageID: "durable")
|
||||||
|
restored.save([:])
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
let recovered = restored.retryDeferredLoad()
|
||||||
|
#expect(recovered?.isEmpty == true)
|
||||||
|
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func wipeRemovesFileAndKey() {
|
@Test func wipeRemovesFileAndKey() {
|
||||||
let fileURL = makeTempURL()
|
let fileURL = makeTempURL()
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
@@ -89,4 +228,69 @@ struct MessageOutboxStoreTests {
|
|||||||
store.save([peerID: []])
|
store.save([peerID: []])
|
||||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func protectedDataReadFailureDefersWriteAndMergesOnRecovery() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
|
||||||
|
let seed = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
seed.save([peerID: [makeMessage("durable")]])
|
||||||
|
let durableBytes = try? Data(contentsOf: fileURL)
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restored = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
restored.save([peerID: [makeMessage("during-wake")]])
|
||||||
|
|
||||||
|
// The unreadable durable snapshot was not replaced by the partial
|
||||||
|
// in-memory outbox from the locked restoration.
|
||||||
|
#expect((try? Data(contentsOf: fileURL)) == durableBytes)
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
let recovered = restored.retryDeferredLoad()
|
||||||
|
#expect(Set(recovered?[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"])
|
||||||
|
|
||||||
|
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func lockedWakeRemovalDoesNotResurrectPendingMessageOnRecovery() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
.save([peerID: [makeMessage("durable")]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restored = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
restored.save([peerID: [makeMessage("wake")]])
|
||||||
|
// A later delivery ack produces the complete empty in-memory view.
|
||||||
|
restored.save([:])
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
let recovered = restored.retryDeferredLoad()
|
||||||
|
#expect(recovered?[peerID]?.map(\.messageID) == ["durable"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -493,6 +493,40 @@ struct MessageRouterTests {
|
|||||||
#expect(transport.sentCourierMessages.isEmpty)
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func bridgeDepositCarriesOnlyAfterConfirmedSendAndRetriesFailure() async {
|
||||||
|
let recipient = PeerID(str: "00000000000000ba")
|
||||||
|
let recipientKey = Data(repeating: 0xBA, count: 32)
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
|
||||||
|
)
|
||||||
|
var completions: [@MainActor (Bool) -> Void] = []
|
||||||
|
router.bridgeCourierDeposit = { _, _, _, completion in
|
||||||
|
completions.append(completion)
|
||||||
|
}
|
||||||
|
var carried: [String] = []
|
||||||
|
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||||
|
|
||||||
|
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "bridge-ack")
|
||||||
|
#expect(completions.count == 1)
|
||||||
|
#expect(carried.isEmpty)
|
||||||
|
|
||||||
|
// Sweeps while the WebSocket write is pending must not duplicate it.
|
||||||
|
router.retryBridgeCourierDeposits()
|
||||||
|
#expect(completions.count == 1)
|
||||||
|
|
||||||
|
completions.removeFirst()(false)
|
||||||
|
#expect(carried.isEmpty)
|
||||||
|
|
||||||
|
// A failed socket write releases the in-flight slot for a real retry.
|
||||||
|
router.retryBridgeCourierDeposits()
|
||||||
|
#expect(completions.count == 1)
|
||||||
|
completions.removeFirst()(true)
|
||||||
|
#expect(carried == ["bridge-ack"])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Outbox persistence
|
// MARK: - Outbox persistence
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -547,6 +581,295 @@ struct MessageRouterTests {
|
|||||||
router2.flushOutbox(for: peerID)
|
router2.flushOutbox(for: peerID)
|
||||||
#expect(transport2.sentPrivateMessages.isEmpty)
|
#expect(transport2.sentPrivateMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-protected-data-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000df")
|
||||||
|
|
||||||
|
let durable = MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Before reboot",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: "durable",
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restoredStore = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||||
|
router.sendPrivate("During wake", to: peerID, recipientNickname: "Peer", messageID: "wake")
|
||||||
|
|
||||||
|
transport.reachablePeers.insert(peerID)
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restoredStore.retryDeferredLoad()
|
||||||
|
// Recovery is delivered to the main actor without blocking the
|
||||||
|
// protected-data notification callback.
|
||||||
|
await Task.yield()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["durable", "wake"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func ackWhileColdLoadIsLockedDoesNotResurrectUnseenDurableMessage() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-locked-ack-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000e0")
|
||||||
|
let durable = MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Already delivered",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: "acked-while-locked",
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restoredStore = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.reachablePeers.insert(peerID)
|
||||||
|
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||||
|
|
||||||
|
// The router's locked cold-load view is empty, but the ack still has
|
||||||
|
// to suppress the durable message hidden on disk.
|
||||||
|
router.markDelivered("acked-while-locked")
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restoredStore.retryDeferredLoad()
|
||||||
|
await Task.yield()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.isEmpty)
|
||||||
|
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func ackAfterRecoveryCaptureBeforeMainActorMergeCannotResurrectMessage() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-recovery-gap-ack-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000e1")
|
||||||
|
let durable = MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Captured then acked",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: "recovery-gap-ack",
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restoredStore = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.reachablePeers.insert(peerID)
|
||||||
|
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restoredStore.retryDeferredLoad() // captures recovery and queues MainActor merge
|
||||||
|
router.markDelivered("recovery-gap-ack") // runs before that queued Task
|
||||||
|
await Task.yield()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.isEmpty)
|
||||||
|
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func enqueueAfterRecoveryCapturePreservesUnseenDurableAndNewMessages() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-recovery-gap-enqueue-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000e2")
|
||||||
|
let durable = MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Before unlock",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: "recovery-gap-durable",
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restoredStore = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restoredStore.retryDeferredLoad() // queues merge of the durable message
|
||||||
|
router.sendPrivate("During gap", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-new")
|
||||||
|
transport.reachablePeers.insert(peerID)
|
||||||
|
await Task.yield()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["recovery-gap-durable", "recovery-gap-new"])
|
||||||
|
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["recovery-gap-durable", "recovery-gap-new"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func removingKnownWakeMessageInRecoveryGapPreservesOnlyUnseenDurableState() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-recovery-gap-known-removal-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000e3")
|
||||||
|
let durable = MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Unseen durable",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: "recovery-gap-unseen",
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restoredStore = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||||
|
router.sendPrivate("Known wake", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-known")
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
||||||
|
|
||||||
|
// Secure direct flush removes the wake message before recovery's
|
||||||
|
// MainActor merge. It must remain removed, while the unseen durable
|
||||||
|
// message still arrives through the pending recovery claim.
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
await Task.yield()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
let sentIDs = transport.sentPrivateMessages.map(\.messageID)
|
||||||
|
#expect(sentIDs.filter { $0 == "recovery-gap-known" }.count == 1)
|
||||||
|
#expect(sentIDs.filter { $0 == "recovery-gap-unseen" }.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func failedRecoveryWriteStillPreservesUnseenDurableStateAcrossGapRemoval() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-recovery-write-failure-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000e4")
|
||||||
|
let durable = MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Unseen before unlock",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: "recovery-write-failure-unseen",
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
var failWrites = false
|
||||||
|
var injectedFailureCount = 0
|
||||||
|
let restoredStore = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
},
|
||||||
|
writeData: { data, url, options in
|
||||||
|
if failWrites {
|
||||||
|
injectedFailureCount += 1
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError)
|
||||||
|
}
|
||||||
|
try data.write(to: url, options: options)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||||
|
router.sendPrivate(
|
||||||
|
"Known wake",
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Peer",
|
||||||
|
messageID: "recovery-write-failure-known"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The first post-unlock save reads durable D and knows router state W,
|
||||||
|
// but its D+W write fails. A later retry must retain the original
|
||||||
|
// unseen-D classification instead of mistaking the cached union for
|
||||||
|
// a fully router-known snapshot.
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
failWrites = true
|
||||||
|
router.sendPrivate(
|
||||||
|
"Known wake",
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Peer",
|
||||||
|
messageID: "recovery-write-failure-known"
|
||||||
|
)
|
||||||
|
failWrites = false
|
||||||
|
#expect(injectedFailureCount == 1)
|
||||||
|
|
||||||
|
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
router.flushOutbox(for: peerID) // removes W before queued callback
|
||||||
|
|
||||||
|
// The gap save may remove W, but it must leave unseen D durable until
|
||||||
|
// MessageRouter receives the pending recovery callback.
|
||||||
|
let gapSnapshot = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(gapSnapshot[peerID]?.map(\.messageID) == ["recovery-write-failure-unseen"])
|
||||||
|
|
||||||
|
await Task.yield()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
let sentIDs = transport.sentPrivateMessages.map(\.messageID)
|
||||||
|
#expect(sentIDs.filter { $0 == "recovery-write-failure-known" }.count == 1)
|
||||||
|
#expect(sentIDs.filter { $0 == "recovery-write-failure-unseen" }.count == 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
|
/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
|
||||||
|
|||||||
@@ -345,6 +345,121 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0)
|
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_sendEventImmediately_allRejectsFailThenOKRetrySucceeds() async throws {
|
||||||
|
let relays = [
|
||||||
|
"wss://confirmed-reject-one.example",
|
||||||
|
"wss://confirmed-reject-two.example"
|
||||||
|
]
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
context.manager.ensureConnections(to: relays)
|
||||||
|
let connected = await waitUntil {
|
||||||
|
relays.allSatisfy { relay in
|
||||||
|
context.manager.relays.first(where: { $0.url == relay })?.isConnected == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
|
||||||
|
let event = try makeSignedEvent(content: "confirmed reject then retry")
|
||||||
|
var results: [Bool] = []
|
||||||
|
var completionsWereOnMain: [Bool] = []
|
||||||
|
context.manager.sendEventImmediately(event, to: relays) {
|
||||||
|
results.append($0)
|
||||||
|
completionsWereOnMain.append(Thread.isMainThread)
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertTrue(results.isEmpty, "socket writes alone must not confirm durability")
|
||||||
|
for relay in relays {
|
||||||
|
try context.sessionFactory.latestConnection(for: relay)?.emitOK(
|
||||||
|
eventID: event.id,
|
||||||
|
success: false,
|
||||||
|
reason: "rejected"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let failedCompleted = await waitUntil { results.count == 1 }
|
||||||
|
XCTAssertTrue(failedCompleted)
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
XCTAssertEqual(completionsWereOnMain, [true])
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||||
|
|
||||||
|
// The explicit rejection leaves the same event retryable.
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
context.manager.sendEventImmediately(event, to: relays) {
|
||||||
|
results.append($0)
|
||||||
|
completionsWereOnMain.append(Thread.isMainThread)
|
||||||
|
}
|
||||||
|
try context.sessionFactory.latestConnection(for: relays[0])?.emitOK(
|
||||||
|
eventID: event.id,
|
||||||
|
success: true,
|
||||||
|
reason: "accepted"
|
||||||
|
)
|
||||||
|
let successfulCompleted = await waitUntil { results.count == 2 }
|
||||||
|
XCTAssertTrue(successfulCompleted)
|
||||||
|
XCTAssertEqual(results, [false, true])
|
||||||
|
XCTAssertEqual(completionsWereOnMain, [true, true])
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_sendEventImmediately_mixedRelayOKUsesAcceptedResultOnce() async throws {
|
||||||
|
let relays = ["wss://confirmed-mixed-one.example", "wss://confirmed-mixed-two.example"]
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
context.manager.ensureConnections(to: relays)
|
||||||
|
let connected = await waitUntil {
|
||||||
|
relays.allSatisfy { relay in
|
||||||
|
context.manager.relays.first(where: { $0.url == relay })?.isConnected == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
|
||||||
|
let event = try makeSignedEvent(content: "mixed confirmation")
|
||||||
|
var results: [Bool] = []
|
||||||
|
context.manager.sendEventImmediately(event, to: relays) { results.append($0) }
|
||||||
|
try context.sessionFactory.latestConnection(for: relays[0])?.emitOK(
|
||||||
|
eventID: event.id,
|
||||||
|
success: false,
|
||||||
|
reason: "policy"
|
||||||
|
)
|
||||||
|
try context.sessionFactory.latestConnection(for: relays[1])?.emitOK(
|
||||||
|
eventID: event.id,
|
||||||
|
success: true,
|
||||||
|
reason: "accepted"
|
||||||
|
)
|
||||||
|
|
||||||
|
let completed = await waitUntil { results.count == 1 }
|
||||||
|
XCTAssertTrue(completed)
|
||||||
|
XCTAssertEqual(results, [true])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_sendEventImmediately_timeoutFailsAndIgnoresLateWriteAndOK() async throws {
|
||||||
|
let relay = "wss://confirmed-timeout.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
context.manager.ensureConnections(to: [relay])
|
||||||
|
let connected = await waitUntil {
|
||||||
|
context.manager.relays.first(where: { $0.url == relay })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relay))
|
||||||
|
connection.deferSendCompletions = true
|
||||||
|
|
||||||
|
let event = try makeSignedEvent(content: "confirmation timeout")
|
||||||
|
var results: [Bool] = []
|
||||||
|
context.manager.sendEventImmediately(event, to: [relay]) { results.append($0) }
|
||||||
|
XCTAssertTrue(results.isEmpty)
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.scheduler.scheduled.first?.delay,
|
||||||
|
TransportConfig.nostrConfirmedSendAckTimeoutSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
context.scheduler.runNext()
|
||||||
|
let timedOut = await waitUntil { results.count == 1 }
|
||||||
|
XCTAssertTrue(timedOut)
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
|
||||||
|
connection.flushDeferredSendCompletions()
|
||||||
|
try connection.emitOK(eventID: event.id, success: true, reason: "late")
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
}
|
||||||
|
|
||||||
func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws {
|
func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws {
|
||||||
let context = makeContext(
|
let context = makeContext(
|
||||||
permission: .authorized,
|
permission: .authorized,
|
||||||
@@ -1158,6 +1273,38 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(firstConnection.cancelCallCount, 1)
|
XCTAssertEqual(firstConnection.cancelCallCount, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_staleSocketFailureCannotFailConfirmedSendOnReplacementConnection() async throws {
|
||||||
|
let relayURL = "wss://retry-stale-confirmation.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
|
let initiallyConnected = await waitUntil {
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(initiallyConnected)
|
||||||
|
let oldConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||||
|
|
||||||
|
context.manager.retryConnection(to: relayURL)
|
||||||
|
let replaced = await waitUntil {
|
||||||
|
guard let current = context.sessionFactory.latestConnection(for: relayURL) else { return false }
|
||||||
|
return current !== oldConnection &&
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(replaced)
|
||||||
|
let currentConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||||
|
|
||||||
|
let event = try makeSignedEvent(content: "replacement confirmation")
|
||||||
|
var results: [Bool] = []
|
||||||
|
context.manager.sendEventImmediately(event, to: [relayURL]) { results.append($0) }
|
||||||
|
oldConnection.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||||
|
XCTAssertTrue(results.isEmpty)
|
||||||
|
XCTAssertTrue(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true)
|
||||||
|
|
||||||
|
try currentConnection.emitOK(eventID: event.id, success: true, reason: "accepted")
|
||||||
|
let confirmed = await waitUntil { results == [true] }
|
||||||
|
XCTAssertTrue(confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async {
|
func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async {
|
||||||
let relayURL = "wss://retry-tor.example"
|
let relayURL = "wss://retry-tor.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true)
|
||||||
@@ -1643,7 +1790,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
|||||||
|
|
||||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||||
private let pingError: Error?
|
private let pingError: Error?
|
||||||
private let sendError: Error?
|
var sendError: Error?
|
||||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
private(set) var resumeCallCount = 0
|
private(set) var resumeCallCount = 0
|
||||||
private(set) var cancelCallCount = 0
|
private(set) var cancelCallCount = 0
|
||||||
@@ -1687,7 +1834,9 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
func flushDeferredSendCompletions() {
|
func flushDeferredSendCompletions() {
|
||||||
let pending = deferredSendCompletions
|
let pending = deferredSendCompletions
|
||||||
deferredSendCompletions = []
|
deferredSendCompletions = []
|
||||||
pending.forEach { $0(sendError) }
|
pending.forEach {
|
||||||
|
$0(sendError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ struct ViewSmokeTests {
|
|||||||
playback.stop()
|
playback.stop()
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
|
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
|
||||||
await VoiceRecorder.shared.cancelRecording()
|
await VoiceRecorder.shared.cancelRecording(owner: VoiceRecorder.RecordingOwner())
|
||||||
|
|
||||||
#expect(bins.count == 16)
|
#expect(bins.count == 16)
|
||||||
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
|
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//
|
||||||
|
// VoiceCaptureSessionTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class StubPTTCapture: PTTCapturing {
|
||||||
|
var onFrames: (([Data]) -> Void)?
|
||||||
|
var stopResult: (url: URL?, encodedFrames: Int)
|
||||||
|
var startError: Error?
|
||||||
|
private(set) var startCount = 0
|
||||||
|
private(set) var cancelCount = 0
|
||||||
|
|
||||||
|
init(
|
||||||
|
stopResult: (url: URL?, encodedFrames: Int),
|
||||||
|
startError: Error? = nil
|
||||||
|
) {
|
||||||
|
self.stopResult = stopResult
|
||||||
|
self.startError = startError
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(outputURL: URL) async throws {
|
||||||
|
startCount += 1
|
||||||
|
if let startError {
|
||||||
|
throw startError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||||
|
stopResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
cancelCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CaptureLeaseSession: SessionApplying, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _activationCalls: [Bool] = []
|
||||||
|
|
||||||
|
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
|
||||||
|
|
||||||
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
|
||||||
|
lock.withLock { _activationCalls.append(active) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
||||||
|
let isLive = false
|
||||||
|
private let startError: Error?
|
||||||
|
private(set) var finishStarted = false
|
||||||
|
private(set) var cancelCount = 0
|
||||||
|
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
||||||
|
|
||||||
|
init(startError: Error? = nil) {
|
||||||
|
self.startError = startError
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool { true }
|
||||||
|
func start() async throws {
|
||||||
|
if let startError { throw startError }
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async -> URL? {
|
||||||
|
finishStarted = true
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
finishContinuation = continuation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() async {
|
||||||
|
cancelCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFinish(with url: URL?) {
|
||||||
|
let continuation = finishContinuation
|
||||||
|
finishContinuation = nil
|
||||||
|
continuation?.resume(returning: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct VoiceCaptureSessionTests {
|
||||||
|
private func waitUntil(
|
||||||
|
_ condition: () -> Bool,
|
||||||
|
sourceLocation: SourceLocation = #_sourceLocation
|
||||||
|
) async {
|
||||||
|
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||||
|
while !condition(), ContinuousClock.now < deadline {
|
||||||
|
await Task.yield()
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
}
|
||||||
|
#expect(condition(), sourceLocation: sourceLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isRecording(_ state: VoiceRecordingViewModel.State) -> Bool {
|
||||||
|
if case .recording = state { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func staleCaptureCallbackCannotInvalidateNewGeneration() {
|
||||||
|
let generations = PTTCaptureGeneration()
|
||||||
|
let old = generations.begin()
|
||||||
|
generations.invalidate()
|
||||||
|
let current = generations.begin()
|
||||||
|
|
||||||
|
#expect(!generations.invalidate(ifCurrent: old))
|
||||||
|
#expect(generations.isCurrent(current))
|
||||||
|
#expect(generations.invalidate(ifCurrent: current))
|
||||||
|
#expect(!generations.isCurrent(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func coordinatorCancellationIsNotReportedAsAStartedCapture() async {
|
||||||
|
let capture = StubPTTCapture(
|
||||||
|
stopResult: (nil, 0),
|
||||||
|
startError: CancellationError()
|
||||||
|
)
|
||||||
|
let session = PTTLiveVoiceSession(
|
||||||
|
sendPacket: { _ in },
|
||||||
|
capture: capture
|
||||||
|
)
|
||||||
|
|
||||||
|
await #expect(throws: CancellationError.self) {
|
||||||
|
try await session.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func interruptedShortCaptureIsCanceledEvenAfterLongHold() async throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("ptt-interrupted-test-\(UUID().uuidString).m4a")
|
||||||
|
_ = FileManager.default.createFile(atPath: url.path, contents: Data([0x00]))
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let capture = StubPTTCapture(stopResult: (url, 1))
|
||||||
|
var sentPackets: [Data] = []
|
||||||
|
var now = Date()
|
||||||
|
let session = PTTLiveVoiceSession(
|
||||||
|
sendPacket: { sentPackets.append($0) },
|
||||||
|
capture: capture,
|
||||||
|
now: { now },
|
||||||
|
burstID: Data(repeating: 0xA5, count: 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
try await session.start()
|
||||||
|
now = now.addingTimeInterval(2)
|
||||||
|
let result = await session.finish()
|
||||||
|
|
||||||
|
#expect(result == nil)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
let packet = try #require(sentPackets.last.flatMap(VoiceBurstPacket.decode))
|
||||||
|
guard case .canceled = packet.kind else {
|
||||||
|
Issue.record("Expected a canceled control packet for a subsecond interrupted capture")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func droppingCaptureLeaseReturnsItsCoordinatorToken() async throws {
|
||||||
|
let rawSession = CaptureLeaseSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: rawSession)
|
||||||
|
let token = try await coordinator.acquire(.capture) {}
|
||||||
|
var lease: PTTCaptureSessionLease? = PTTCaptureSessionLease(coordinator: coordinator)
|
||||||
|
lease?.install(token)
|
||||||
|
|
||||||
|
lease = nil
|
||||||
|
await coordinator.drain()
|
||||||
|
|
||||||
|
#expect(rawSession.activationCalls == [true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectedNewHoldAndStaleFinalizeLeaveNewerGenerationIdle() async {
|
||||||
|
let oldSession = GatedVoiceCaptureSession()
|
||||||
|
let newSession = GatedVoiceCaptureSession(
|
||||||
|
startError: VoiceRecorder.RecorderError.recordingInProgress
|
||||||
|
)
|
||||||
|
var sessions: [GatedVoiceCaptureSession] = [oldSession, newSession]
|
||||||
|
let viewModel = VoiceRecordingViewModel()
|
||||||
|
viewModel.sessionProvider = { sessions.removeFirst() }
|
||||||
|
|
||||||
|
viewModel.start(shouldShow: true)
|
||||||
|
await waitUntil { self.isRecording(viewModel.state) }
|
||||||
|
viewModel.finish(completion: { _ in })
|
||||||
|
await waitUntil { oldSession.finishStarted }
|
||||||
|
|
||||||
|
viewModel.start(shouldShow: true)
|
||||||
|
await waitUntil { newSession.cancelCount == 1 && viewModel.state == .idle }
|
||||||
|
#expect(viewModel.state == .idle)
|
||||||
|
|
||||||
|
// The older finalize now fails after the newer press has completed.
|
||||||
|
// It must not replace the newer generation's idle state with an alert.
|
||||||
|
oldSession.resolveFinish(with: nil)
|
||||||
|
for _ in 0..<20 {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
#expect(viewModel.state == .idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// VoiceNotePlaybackControllerTests.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 (the
|
||||||
|
/// blocking session IPC runs off the main thread) while the test reads from
|
||||||
|
/// the main actor.
|
||||||
|
private final class RecordingAudioSession: SessionApplying, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _activationCalls: [Bool] = []
|
||||||
|
private var _categoryCallCount = 0
|
||||||
|
private var _categoryError: Error?
|
||||||
|
|
||||||
|
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
|
||||||
|
var categoryCallCount: Int { lock.withLock { _categoryCallCount } }
|
||||||
|
var categoryError: Error? {
|
||||||
|
get { lock.withLock { _categoryError } }
|
||||||
|
set { lock.withLock { _categoryError = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
|
||||||
|
try lock.withLock {
|
||||||
|
_categoryCallCount += 1
|
||||||
|
if let error = _categoryError {
|
||||||
|
_categoryError = nil
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
|
||||||
|
lock.withLock { _activationCalls.append(active) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlaybackSessionError: Error {}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct VoiceNotePlaybackControllerTests {
|
||||||
|
/// A short silent PCM file `AVAudioPlayer` can open on the test host.
|
||||||
|
private func makeTempVoiceNote(seconds: Double = 0.2) throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("voice-note-test-\(UUID().uuidString).caf")
|
||||||
|
let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1))
|
||||||
|
let file = try AVAudioFile(forWriting: url, settings: format.settings)
|
||||||
|
let frames = AVAudioFrameCount(seconds * 16_000)
|
||||||
|
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames))
|
||||||
|
buffer.frameLength = frames
|
||||||
|
try file.write(from: buffer)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitUntil(
|
||||||
|
_ condition: () -> Bool,
|
||||||
|
sourceLocation: SourceLocation = #_sourceLocation
|
||||||
|
) async {
|
||||||
|
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||||
|
while !condition(), ContinuousClock.now < deadline {
|
||||||
|
await Task.yield()
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
}
|
||||||
|
#expect(condition(), sourceLocation: sourceLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func seekWhilePausedDoesNotAcquireSession() throws {
|
||||||
|
let session = RecordingAudioSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let url = try makeTempVoiceNote()
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let controller = VoiceNotePlaybackController(
|
||||||
|
url: url,
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
exclusivity: VoiceNotePlaybackCoordinator()
|
||||||
|
)
|
||||||
|
controller.seek(to: 0.5)
|
||||||
|
|
||||||
|
// The scrub position moved (the player is real and ready)...
|
||||||
|
#expect(controller.progress > 0.25)
|
||||||
|
// ...but nothing is audible, so the session must not be held: an
|
||||||
|
// acquired-while-paused token on a discarded row would pin the
|
||||||
|
// session (and any escalated category) forever.
|
||||||
|
#expect(session.activationCalls.isEmpty)
|
||||||
|
#expect(!controller.isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deinitReleasesSessionAndStopsPlayback() async throws {
|
||||||
|
let session = RecordingAudioSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let url = try makeTempVoiceNote()
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
// Fresh exclusivity slot: a parallel test's play() must not pause
|
||||||
|
// this controller while its session acquire is in flight.
|
||||||
|
var controller: VoiceNotePlaybackController? = VoiceNotePlaybackController(
|
||||||
|
url: url,
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
exclusivity: VoiceNotePlaybackCoordinator()
|
||||||
|
)
|
||||||
|
controller?.play()
|
||||||
|
// The session acquire is asynchronous now (its blocking IPC runs off
|
||||||
|
// the main thread), so await the activation instead of asserting
|
||||||
|
// right after play().
|
||||||
|
await waitUntil { session.activationCalls == [true] }
|
||||||
|
|
||||||
|
// Navigating away discards the row's @StateObject mid-playback:
|
||||||
|
// deinit must release the session hold (a fire-and-forget hop onto
|
||||||
|
// the coordinator's queue).
|
||||||
|
controller = nil
|
||||||
|
|
||||||
|
await waitUntil { session.activationCalls == [true, false] }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func activationFailureDoesNotStartUnregisteredPlayback() async throws {
|
||||||
|
let session = RecordingAudioSession()
|
||||||
|
session.categoryError = PlaybackSessionError()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let url = try makeTempVoiceNote(seconds: 30)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let controller = VoiceNotePlaybackController(
|
||||||
|
url: url,
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
exclusivity: VoiceNotePlaybackCoordinator()
|
||||||
|
)
|
||||||
|
controller.play()
|
||||||
|
|
||||||
|
await waitUntil {
|
||||||
|
session.categoryCallCount == 1 && !controller.isPlaybackStartPending
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(session.activationCalls.isEmpty)
|
||||||
|
#expect(!controller.isPlaying)
|
||||||
|
#expect(controller.currentTime == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
//
|
||||||
|
// VoiceRecorderTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let activationGate = DispatchSemaphore(value: 0)
|
||||||
|
private let shouldGateFirstActivation: Bool
|
||||||
|
private var gatedFirstActivation = false
|
||||||
|
private var _activationCalls: [Bool] = []
|
||||||
|
private var _activationBegan = false
|
||||||
|
|
||||||
|
init(gateFirstActivation: Bool = false) {
|
||||||
|
self.shouldGateFirstActivation = gateFirstActivation
|
||||||
|
}
|
||||||
|
|
||||||
|
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
|
||||||
|
var activationBegan: Bool { lock.withLock { _activationBegan } }
|
||||||
|
|
||||||
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
|
||||||
|
let shouldWait = lock.withLock { () -> Bool in
|
||||||
|
_activationCalls.append(active)
|
||||||
|
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
|
||||||
|
gatedFirstActivation = true
|
||||||
|
_activationBegan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if shouldWait {
|
||||||
|
activationGate.wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeActivation() {
|
||||||
|
activationGate.signal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestVoiceAudioRecorder: VoiceAudioRecording {
|
||||||
|
let prepareResult: Bool
|
||||||
|
let recordResult: Bool
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _isRecording = false
|
||||||
|
private var _isMeteringEnabled = false
|
||||||
|
private var _prepareCallCount = 0
|
||||||
|
private var _recordedDurations: [TimeInterval] = []
|
||||||
|
private var _stopCallCount = 0
|
||||||
|
|
||||||
|
init(prepareResult: Bool, recordResult: Bool) {
|
||||||
|
self.prepareResult = prepareResult
|
||||||
|
self.recordResult = recordResult
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRecording: Bool { lock.withLock { _isRecording } }
|
||||||
|
var isMeteringEnabled: Bool {
|
||||||
|
get { lock.withLock { _isMeteringEnabled } }
|
||||||
|
set { lock.withLock { _isMeteringEnabled = newValue } }
|
||||||
|
}
|
||||||
|
var prepareCallCount: Int { lock.withLock { _prepareCallCount } }
|
||||||
|
var recordedDurations: [TimeInterval] { lock.withLock { _recordedDurations } }
|
||||||
|
var stopCallCount: Int { lock.withLock { _stopCallCount } }
|
||||||
|
|
||||||
|
func prepareToRecord() -> Bool {
|
||||||
|
lock.withLock { _prepareCallCount += 1 }
|
||||||
|
return prepareResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(forDuration duration: TimeInterval) -> Bool {
|
||||||
|
lock.withLock {
|
||||||
|
_recordedDurations.append(duration)
|
||||||
|
if recordResult {
|
||||||
|
_isRecording = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recordResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
lock.withLock {
|
||||||
|
_stopCallCount += 1
|
||||||
|
_isRecording = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Models `record(forDuration:)` reaching its duration cap before the
|
||||||
|
/// caller invokes `VoiceRecorder.stopRecording(owner:)`.
|
||||||
|
func simulateAutomaticStop() {
|
||||||
|
lock.withLock { _isRecording = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
||||||
|
struct Plan {
|
||||||
|
let prepareResult: Bool
|
||||||
|
let recordResult: Bool
|
||||||
|
|
||||||
|
static let success = Plan(prepareResult: true, recordResult: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var plans: [Plan]
|
||||||
|
private var _recorders: [TestVoiceAudioRecorder] = []
|
||||||
|
private var _urls: [URL] = []
|
||||||
|
|
||||||
|
init(plans: [Plan]) {
|
||||||
|
self.plans = plans
|
||||||
|
}
|
||||||
|
|
||||||
|
var recorders: [TestVoiceAudioRecorder] { lock.withLock { _recorders } }
|
||||||
|
var urls: [URL] { lock.withLock { _urls } }
|
||||||
|
|
||||||
|
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
|
||||||
|
let plan = lock.withLock { plans.isEmpty ? .success : plans.removeFirst() }
|
||||||
|
// AVAudioRecorder creates its output during initialization. A real
|
||||||
|
// byte on disk lets the tests distinguish preserve from delete.
|
||||||
|
try Data([0x01]).write(to: url)
|
||||||
|
let recorder = TestVoiceAudioRecorder(
|
||||||
|
prepareResult: plan.prepareResult,
|
||||||
|
recordResult: plan.recordResult
|
||||||
|
)
|
||||||
|
lock.withLock {
|
||||||
|
_recorders.append(recorder)
|
||||||
|
_urls.append(url)
|
||||||
|
}
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot async gate that proves `VoiceRecorder.stopRecording` has reached
|
||||||
|
/// its actor-reentrant padding boundary, then holds it there until the test has
|
||||||
|
/// exercised a competing owner. Unlike `Task.yield()` plus a short real sleep,
|
||||||
|
/// this remains deterministic when the full test suite saturates the executor.
|
||||||
|
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _entered = false
|
||||||
|
private var isOpen = false
|
||||||
|
private var openWaiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
var entered: Bool { lock.withLock { _entered } }
|
||||||
|
|
||||||
|
func wait() async {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
let resumeImmediately = lock.withLock { () -> Bool in
|
||||||
|
_entered = true
|
||||||
|
guard !isOpen else { return true }
|
||||||
|
openWaiters.append(continuation)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if resumeImmediately {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func open() {
|
||||||
|
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
|
||||||
|
isOpen = true
|
||||||
|
defer { openWaiters.removeAll() }
|
||||||
|
return openWaiters
|
||||||
|
}
|
||||||
|
waiters.forEach { $0.resume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct VoiceRecorderTests {
|
||||||
|
private func makeTemporaryDirectory() throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("voice-recorder-tests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitUntil(
|
||||||
|
_ condition: () -> Bool,
|
||||||
|
sourceLocation: SourceLocation = #_sourceLocation
|
||||||
|
) async {
|
||||||
|
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||||
|
while !condition(), ContinuousClock.now < deadline {
|
||||||
|
await Task.yield()
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
}
|
||||||
|
#expect(condition(), sourceLocation: sourceLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
|
||||||
|
let directory = try makeTemporaryDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
let session = VoiceRecorderTestSession(gateFirstActivation: true)
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
|
||||||
|
let voiceRecorder = VoiceRecorder(
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
recorderFactory: factory,
|
||||||
|
permissionGranted: { true },
|
||||||
|
paddingInterval: 0,
|
||||||
|
outputDirectory: directory
|
||||||
|
)
|
||||||
|
let owner = VoiceRecorder.RecordingOwner()
|
||||||
|
|
||||||
|
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
|
||||||
|
await waitUntil { session.activationBegan }
|
||||||
|
|
||||||
|
await voiceRecorder.cancelRecording(owner: owner)
|
||||||
|
session.resumeActivation()
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await startTask.value
|
||||||
|
Issue.record("The canceled session acquire unexpectedly started recording")
|
||||||
|
} catch {
|
||||||
|
#expect(error is CancellationError)
|
||||||
|
}
|
||||||
|
await coordinator.drain()
|
||||||
|
|
||||||
|
#expect(factory.recorders.isEmpty)
|
||||||
|
#expect(session.activationCalls == [true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func prepareFailureCleansUpAndAllowsTheNextRecording() async throws {
|
||||||
|
try await verifyFailedStart(
|
||||||
|
firstPlan: .init(prepareResult: false, recordResult: true),
|
||||||
|
expectedPrepareCalls: 1,
|
||||||
|
expectedRecordCalls: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func recordFailureCleansUpAndAllowsTheNextRecording() async throws {
|
||||||
|
try await verifyFailedStart(
|
||||||
|
firstPlan: .init(prepareResult: true, recordResult: false),
|
||||||
|
expectedPrepareCalls: 1,
|
||||||
|
expectedRecordCalls: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func automaticStopReturnsAndPreservesFileThenNextRecordingWorks() async throws {
|
||||||
|
let directory = try makeTemporaryDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
let session = VoiceRecorderTestSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
|
||||||
|
let voiceRecorder = VoiceRecorder(
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
recorderFactory: factory,
|
||||||
|
permissionGranted: { true },
|
||||||
|
paddingInterval: 0,
|
||||||
|
outputDirectory: directory
|
||||||
|
)
|
||||||
|
let firstOwner = VoiceRecorder.RecordingOwner()
|
||||||
|
|
||||||
|
let firstURL = try await voiceRecorder.startRecording(owner: firstOwner)
|
||||||
|
let firstRecorder = try #require(factory.recorders.first)
|
||||||
|
#expect(firstRecorder.recordedDurations == [120])
|
||||||
|
firstRecorder.simulateAutomaticStop()
|
||||||
|
|
||||||
|
let finishedURL = await voiceRecorder.stopRecording(owner: firstOwner)
|
||||||
|
await coordinator.drain()
|
||||||
|
#expect(finishedURL == firstURL)
|
||||||
|
#expect(firstRecorder.stopCallCount == 0)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
||||||
|
#expect(session.activationCalls == [true, false])
|
||||||
|
|
||||||
|
let secondOwner = VoiceRecorder.RecordingOwner()
|
||||||
|
let secondURL = try await voiceRecorder.startRecording(owner: secondOwner)
|
||||||
|
#expect(secondURL != firstURL)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
||||||
|
#expect(factory.recorders.count == 2)
|
||||||
|
let secondRecorder = try #require(factory.recorders.last)
|
||||||
|
|
||||||
|
#expect(await voiceRecorder.stopRecording(owner: secondOwner) == secondURL)
|
||||||
|
await coordinator.drain()
|
||||||
|
#expect(secondRecorder.stopCallCount == 1)
|
||||||
|
#expect(session.activationCalls == [true, false, true, false])
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectedNewHoldCancelCannotDeleteHoldFinishingDuringPadding() async throws {
|
||||||
|
let directory = try makeTemporaryDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
let session = VoiceRecorderTestSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
|
||||||
|
let paddingGate = VoiceRecorderPaddingGate()
|
||||||
|
let voiceRecorder = VoiceRecorder(
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
recorderFactory: factory,
|
||||||
|
permissionGranted: { true },
|
||||||
|
paddingInterval: 0.05,
|
||||||
|
outputDirectory: directory,
|
||||||
|
testingHooks: .init(waitForStopPadding: { _ in await paddingGate.wait() })
|
||||||
|
)
|
||||||
|
let finishingHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||||
|
let rejectedHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||||
|
|
||||||
|
try await finishingHold.start()
|
||||||
|
let firstURL = try #require(factory.urls.first)
|
||||||
|
let finishTask = Task { await finishingHold.finish() }
|
||||||
|
await waitUntil { paddingGate.entered }
|
||||||
|
|
||||||
|
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
|
||||||
|
try await rejectedHold.start()
|
||||||
|
}
|
||||||
|
// This is the view-model error path that used to globally cancel the
|
||||||
|
// shared recorder and delete `firstURL` during the padding sleep.
|
||||||
|
await rejectedHold.cancel()
|
||||||
|
paddingGate.open()
|
||||||
|
|
||||||
|
#expect(await finishTask.value == firstURL)
|
||||||
|
await coordinator.drain()
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
||||||
|
#expect(factory.recorders[0].stopCallCount == 1)
|
||||||
|
#expect(session.activationCalls == [true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stalePreviousHoldCancelCannotStopNewRecording() async throws {
|
||||||
|
let directory = try makeTemporaryDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
let session = VoiceRecorderTestSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
|
||||||
|
let voiceRecorder = VoiceRecorder(
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
recorderFactory: factory,
|
||||||
|
permissionGranted: { true },
|
||||||
|
paddingInterval: 0,
|
||||||
|
outputDirectory: directory
|
||||||
|
)
|
||||||
|
let previousHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||||
|
let currentHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||||
|
|
||||||
|
try await previousHold.start()
|
||||||
|
let firstURL = try #require(factory.urls.first)
|
||||||
|
#expect(await previousHold.finish() == firstURL)
|
||||||
|
|
||||||
|
try await currentHold.start()
|
||||||
|
let secondURL = try #require(factory.urls.last)
|
||||||
|
let secondRecorder = try #require(factory.recorders.last)
|
||||||
|
await previousHold.cancel()
|
||||||
|
|
||||||
|
#expect(secondRecorder.isRecording)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
||||||
|
#expect(await currentHold.finish() == secondURL)
|
||||||
|
await coordinator.drain()
|
||||||
|
#expect(secondRecorder.stopCallCount == 1)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verifyFailedStart(
|
||||||
|
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
||||||
|
expectedPrepareCalls: Int,
|
||||||
|
expectedRecordCalls: Int
|
||||||
|
) async throws {
|
||||||
|
let directory = try makeTemporaryDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
let session = VoiceRecorderTestSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: session)
|
||||||
|
let factory = TestVoiceAudioRecorderFactory(plans: [firstPlan, .success])
|
||||||
|
let voiceRecorder = VoiceRecorder(
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
recorderFactory: factory,
|
||||||
|
permissionGranted: { true },
|
||||||
|
paddingInterval: 0,
|
||||||
|
outputDirectory: directory
|
||||||
|
)
|
||||||
|
let failedOwner = VoiceRecorder.RecordingOwner()
|
||||||
|
|
||||||
|
await #expect(throws: VoiceRecorder.RecorderError.failedToStartRecording) {
|
||||||
|
try await voiceRecorder.startRecording(owner: failedOwner)
|
||||||
|
}
|
||||||
|
await coordinator.drain()
|
||||||
|
|
||||||
|
let failedRecorder = try #require(factory.recorders.first)
|
||||||
|
let failedURL = try #require(factory.urls.first)
|
||||||
|
#expect(failedRecorder.prepareCallCount == expectedPrepareCalls)
|
||||||
|
#expect(failedRecorder.recordedDurations.count == expectedRecordCalls)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: failedURL.path))
|
||||||
|
#expect(session.activationCalls == [true, false])
|
||||||
|
|
||||||
|
let nextOwner = VoiceRecorder.RecordingOwner()
|
||||||
|
let nextURL = try await voiceRecorder.startRecording(owner: nextOwner)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: nextURL.path))
|
||||||
|
#expect(await voiceRecorder.stopRecording(owner: nextOwner) == nextURL)
|
||||||
|
await coordinator.drain()
|
||||||
|
#expect(session.activationCalls == [true, false, true, false])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ The crate declares `rust-version = "1.90"` and uses `arti-client` / `tor-rtcompa
|
|||||||
|
|
||||||
- `aarch64-apple-ios`
|
- `aarch64-apple-ios`
|
||||||
- `aarch64-apple-ios-sim`
|
- `aarch64-apple-ios-sim`
|
||||||
|
- `x86_64-apple-ios`
|
||||||
- `aarch64-apple-darwin`
|
- `aarch64-apple-darwin`
|
||||||
|
- `x86_64-apple-darwin`
|
||||||
|
|
||||||
It builds release static libraries with size-oriented flags (`opt-level=z`, fat LTO, one codegen unit, `panic=abort`, stripped symbols), normalizes static-archive metadata with `xcrun libtool -static -D`, then packages them with `xcodebuild -create-xcframework`.
|
It builds release static libraries with size-oriented flags (`opt-level=z`, fat LTO, one codegen unit, `panic=abort`, stripped symbols), normalizes static-archive metadata with `xcrun libtool -static -D`, then packages them with `xcodebuild -create-xcframework`.
|
||||||
|
|
||||||
@@ -29,30 +31,34 @@ From the repo root:
|
|||||||
```sh
|
```sh
|
||||||
cd localPackages/Arti
|
cd localPackages/Arti
|
||||||
rustup toolchain install 1.96.0
|
rustup toolchain install 1.96.0
|
||||||
rustup default 1.96.0
|
rustup target add --toolchain 1.96.0 aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios aarch64-apple-darwin x86_64-apple-darwin
|
||||||
rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin
|
rustup run 1.96.0 cargo install cbindgen --version 0.29.4 --locked
|
||||||
cargo install cbindgen
|
|
||||||
./build-ios.sh
|
./build-ios.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`build-ios.sh` defaults to the audited `1.96.0` toolchain and refuses a rustc
|
||||||
|
version other than `1.96.0`; it likewise requires cbindgen `0.29.4`. Set
|
||||||
|
`RUST_TOOLCHAIN`, `RUSTC_VERSION`, or `CBINDGEN_VERSION` explicitly only when
|
||||||
|
intentionally updating the binary provenance and hashes below.
|
||||||
|
|
||||||
After rebuilding, verify that:
|
After rebuilding, verify that:
|
||||||
|
|
||||||
- `Cargo.lock` changes are intentional and reviewed.
|
- `Cargo.lock` changes are intentional and reviewed.
|
||||||
- `Frameworks/include/arti.h` still matches the exported FFI functions used by `TorManager`.
|
- `Frameworks/include/arti.h` still matches the exported FFI functions used by `TorManager`.
|
||||||
- `Frameworks/arti.xcframework` contains iOS device, iOS simulator, and macOS arm64 slices.
|
- `Frameworks/arti.xcframework` contains iOS device arm64, universal iOS simulator arm64+x86_64, and universal macOS arm64+x86_64 slices.
|
||||||
- The main app still passes iOS tests and the macOS build.
|
- The main app still passes iOS tests and the macOS build.
|
||||||
|
|
||||||
## Audited Rebuild
|
## Audited Rebuild
|
||||||
|
|
||||||
The June 2026 artifact below was rebuilt from source on this host with:
|
The July 2026 artifact below was rebuilt from source on this host with:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
rustc 1.96.0 (ac68faa20 2026-05-25)
|
rustc 1.96.0 (ac68faa20 2026-05-25)
|
||||||
cargo 1.96.0 (30a34c682 2026-05-25)
|
cargo 1.96.0 (30a34c682 2026-05-25)
|
||||||
rustup 1.29.0 (28d1352db 2026-03-05)
|
rustup 1.29.0 (28d1352db 2026-03-05)
|
||||||
cbindgen 0.29.3
|
cbindgen 0.29.4
|
||||||
Xcode 26.5
|
Xcode 26.6
|
||||||
Build version 17F42
|
Build version 17F113
|
||||||
```
|
```
|
||||||
|
|
||||||
Rust 1.86.0 was also checked during the audit and no longer builds this lockfile because `typed-index-collections@3.4.0` requires Rust 1.90.0 or newer.
|
Rust 1.86.0 was also checked during the audit and no longer builds this lockfile because `typed-index-collections@3.4.0` requires Rust 1.90.0 or newer.
|
||||||
@@ -70,13 +76,13 @@ find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0
|
|||||||
Current hashes:
|
Current hashes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
2083d44eafc765db1ffa2691a5c5fabe60b4edbb82b574169ca0c6b98e245e3a localPackages/Arti/Frameworks/arti.xcframework/Info.plist
|
cac99db408280bbef15cae8ce64c8ccdbf2e8863c205168d59f83fe8ab680f94 localPackages/Arti/Frameworks/arti.xcframework/Info.plist
|
||||||
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h
|
|
||||||
85febff37b751df667a3cab8222de2e1450cefe44b5b62c419adcbce48b9663f localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a
|
|
||||||
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h
|
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h
|
||||||
fd25ee379d709a794733fc3c052746d1e6f7b25fec23e5f5234008a3434ce879 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a
|
5461a231a786812e91e7965290031ea3479fdc5c6459553e46988ecafbbc2a3d localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a
|
||||||
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h
|
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/Headers/arti.h
|
||||||
8c426a41dc3eb76cc3e3e22e3356b9d11dbebdf0a0f248c5ac892e1839352c75 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a
|
af8f5f636eb6affb309b3e44f13e48498eb2540c77af44ddcd7fdf9241b1e317 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a
|
||||||
|
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/Headers/arti.h
|
||||||
|
7c9afe98227f1767567ddcd4e35d9dfffe70309c302c4dbc9a6c9d6aeefab007 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a
|
||||||
```
|
```
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|||||||
+88
-68
@@ -1,79 +1,99 @@
|
|||||||
BitChat Privacy Assessment
|
# bitchat Privacy Assessment
|
||||||
==========================
|
|
||||||
|
|
||||||
Scope
|
Last reviewed: July 2026
|
||||||
- Mesh transport (BLE) behavior and metadata minimization
|
|
||||||
- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted)
|
|
||||||
- Nostr-backed public geohash channels, presence heartbeats, and location notes
|
|
||||||
- Optional CoreLocation use for geohash channel discovery
|
|
||||||
- Read receipts and delivery acknowledgments
|
|
||||||
- Logging/telemetry posture and controls
|
|
||||||
|
|
||||||
Summary
|
## Scope
|
||||||
- No accounts and no project-operated servers. Mesh traffic is peer-to-peer; Nostr is used for mutual-favorite private fallback and public geohash features.
|
|
||||||
- BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts.
|
|
||||||
- Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads.
|
|
||||||
- UI and storage remain mostly ephemeral; message content is not persisted to disk by default. Minimal local state (e.g., read-receipt IDs, favorites, selected/bookmarked geohashes) is stored for UX and is bounded or user-wipeable.
|
|
||||||
- Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed.
|
|
||||||
|
|
||||||
BLE Privacy Considerations
|
- BLE discovery, mesh routing, gossip sync, private delivery, and courier behavior
|
||||||
- Announce content: Unchanged — nickname + Noise public key only.
|
- Nostr private fallback, bridge courier drops, mesh bridging, geohash channels, and notices
|
||||||
- Local Name: Not used (explicitly disabled). Avoids leaking device/OS identity.
|
- CoreLocation and reverse geocoding
|
||||||
- Address: iOS uses BLE MAC randomization; BitChat does not attempt to set static addresses.
|
- Local persistence, panic wipe, logging, and App Store privacy manifests
|
||||||
- Announce jitter: Each announce is delayed by a small random jitter to avoid synchronization-based correlation.
|
|
||||||
- Scanning: Foreground scanning uses “allow duplicates” briefly to improve discovery latency; background uses standard scanning parameters.
|
|
||||||
- RSSI gating: The acceptance threshold adapts to nearby density (approx. -95 to -80 dBm) to reduce long-distance observations in dense areas and improve connectivity in sparse ones.
|
|
||||||
- Fragmentation: Fragments use write-with-response for reliability (less re-broadcast churn = fewer repeated signals).
|
|
||||||
- GATT permissions: Private characteristic disallows .read; we use notify/write/writeWithoutResponse to avoid exposing plaintext attributes over GATT.
|
|
||||||
|
|
||||||
Mesh Routing and Multi-hop Limits
|
The user-facing contract is `PRIVACY_POLICY.md`. This document records implementation-level behavior and residual risks that should be re-audited when storage or transport semantics change.
|
||||||
- Encrypted relays permitted with random per-hop delay (small jitter) to smooth floods.
|
|
||||||
- TTL cap: Encrypted payloads are capped at 2 hops, limiting metadata spread and path reconstruction risk while enabling close-range relays.
|
|
||||||
|
|
||||||
Nostr Private Messaging Fallback
|
## Current Posture
|
||||||
- Usage criteria: Only attempted for mutual favorites or where a Nostr key has been exchanged (stored in favorites).
|
|
||||||
- Payload confidentiality: Messages embed a BitChat Noise-encrypted packet inside a NIP-17 gift wrap; relays see only random-looking ciphertext.
|
|
||||||
- Timestamp handling: Gift wraps add small randomized offsets to reduce exact timing correlation.
|
|
||||||
- Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata.
|
|
||||||
- Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available.
|
|
||||||
|
|
||||||
Location Channels and Geohash Public Chats
|
- The project operates no account system, analytics pipeline, advertising SDK, or project-owned messaging backend.
|
||||||
- Location permission: Optional when-in-use CoreLocation access computes local geohash channel options. Exact coordinates are held in memory only and are not included in BitChat or Nostr payloads.
|
- Mesh transport is peer-to-peer. Optional internet features use third-party Nostr relays and can expose public content, coarse geohashes, timing, relay, and network metadata.
|
||||||
- Local state: Selected channel, teleported geohashes, bookmarks, and bookmark display names are stored in `UserDefaults`; the panic action clears location presence state along with identity/session state.
|
- Private payloads are end-to-end encrypted, but public mesh, board, bridge, and geohash content is intentionally visible to its participants.
|
||||||
- Geohash precision: User-selected channels can range from region-level to building-level. Public geohash messages and location notes expose the selected geohash tag to relays and participants.
|
- Local storage is bounded where practical and included in panic wipe, but it is not wholly ephemeral. The app persists the stores listed below.
|
||||||
- Presence minimization: Automatic presence heartbeats are restricted to low-precision region/province/city geohashes and use randomized timing.
|
- The app and share extension each bundle a privacy manifest declaring their actual required-reason API use.
|
||||||
- Per-geohash identities: Public geohash Nostr identities are derived from a device seed stored in the keychain, reducing cross-channel linkability compared with a single stable public key.
|
|
||||||
- Relay metadata: Relays can observe event kind, geohash tag, public key, timestamp, and network metadata. Content in public geohash channels is intentionally public to that channel.
|
|
||||||
|
|
||||||
Read Receipts and Delivery Acks
|
## BLE Discovery and Metadata
|
||||||
- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists.
|
|
||||||
- Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs.
|
|
||||||
- Coalescing (optional future): When entering a chat with many unread, only send READ for the latest message, marking older as read locally to reduce metadata.
|
|
||||||
|
|
||||||
Data Retention and State
|
Signed announces can expose:
|
||||||
- Messages: Ephemeral in-memory only; history is bounded per chat and trimmed.
|
|
||||||
- Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory.
|
|
||||||
- Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action.
|
|
||||||
- Location channels: Exact coordinates are not persisted by BitChat. Selected/bookmarked geohashes, teleport flags, and bookmark display names persist locally until removed, panic-wiped, or the app is deleted.
|
|
||||||
- Geohash identities: Device seed is stored in the keychain and used to derive per-geohash Nostr identities deterministically.
|
|
||||||
- Relay persistence: Public geohash events, location notes, and encrypted gift wraps may be retained by relays according to each relay's policy.
|
|
||||||
- Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports.
|
|
||||||
|
|
||||||
Logging and Telemetry
|
- Nickname, persistent Noise public key, and Ed25519 signing public key
|
||||||
- Centralized `SecureLogger` filters potential secrets and uses OSLog privacy markers.
|
- Capability flags
|
||||||
- Default level: `info`; release builds suppress debug. Developers can set `BITCHAT_LOG_LEVEL=debug|info|warning|error|fault`.
|
- A bounded set of short direct-neighbor identifiers
|
||||||
- Transport routing, ACK sends, subscribe/connect noise were downgraded from info→debug.
|
- A coarse rendezvous geohash when the bridge capability is enabled
|
||||||
- OS/system errors (e.g., transient WebSocket disconnects) may still appear in system logs; BitChat avoids re-logging those unless actionable.
|
|
||||||
|
|
||||||
Residual Risks and Mitigations
|
The app does not advertise the device's user-assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address. RSSI, timing, traffic volume, and radio fingerprints remain observable to nearby receivers.
|
||||||
- RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization.
|
|
||||||
- Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts.
|
|
||||||
- Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range.
|
|
||||||
- Geohash inference: Public location-channel tags reveal approximate area. Mitigated by explicit channel selection, low-precision automatic presence, and per-geohash identities.
|
|
||||||
- Bookmark persistence: Locally stored geohash bookmarks may reveal places of interest on a seized/unlocked device. Mitigated by panic wipe and local-only storage.
|
|
||||||
|
|
||||||
Recommendations (Next)
|
Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded.
|
||||||
- Add optional coalesced READ behavior for large backlogs.
|
|
||||||
- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts.
|
## Private Messaging and Courier Delivery
|
||||||
- Allow user-configurable Nostr relay set with a “private relays only” toggle.
|
|
||||||
- Add a user-facing precision warning before posting in block/building-level geohash channels.
|
- Direct mesh sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||||
|
- Undelivered outgoing private messages remain in a bounded, ChaChaPoly-sealed outbox for at most 24 hours. Its key is stored in the keychain.
|
||||||
|
- Physical couriers store opaque Noise-sealed envelopes, not plaintext. Deposits have trust-tier quotas, per-depositor caps, a global cap, and at most a 24-hour lifetime.
|
||||||
|
- Spray-and-wait copies are bounded and progress cannot be replenished by replaying a deposit.
|
||||||
|
- Delivery status advances only after real transport admission or explicit relay acceptance; late failure cannot downgrade a delivered/read message.
|
||||||
|
- Panic wipe deletes the outbox, courier mail, keys, dedup state, and active transport state.
|
||||||
|
|
||||||
|
Residual risk: private-message metadata such as timing, radio adjacency, ciphertext size, rotating recipient tags, and relay connections remains observable. A compromised recipient device can disclose plaintext.
|
||||||
|
|
||||||
|
## Public Gossip, Boards, and Media
|
||||||
|
|
||||||
|
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
||||||
|
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
||||||
|
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
||||||
|
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal.
|
||||||
|
|
||||||
|
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
||||||
|
|
||||||
|
## Nostr and Mesh Bridge
|
||||||
|
|
||||||
|
- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata.
|
||||||
|
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
||||||
|
- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
||||||
|
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
||||||
|
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
||||||
|
|
||||||
|
Residual risk: Nostr relay retention and logging are outside project control. Public events may be copied indefinitely. Timing, coarse location, and participation can be correlated even when content is encrypted or per-cell identities are used.
|
||||||
|
|
||||||
|
## Location
|
||||||
|
|
||||||
|
- When-in-use CoreLocation access computes geohash choices and bridge cells. Permission revocation stops live sampling and releases subscriptions.
|
||||||
|
- Exact coordinates are not persisted by bitchat or placed into mesh/Nostr payloads.
|
||||||
|
- Selected/bookmarked geohashes, teleport flags, and display names persist in local preferences; a fine geohash can identify a small area.
|
||||||
|
- Friendly place names use `CLGeocoder.reverseGeocodeLocation`. Apple may process the supplied location under its own privacy terms, so this operation is not accurately described as wholly on-device.
|
||||||
|
- Automatic presence is limited to lower-precision geohashes; precise posts occur through user-selected channels, notes, notices, or the bridge behavior presented in the UI.
|
||||||
|
|
||||||
|
## Logging and Telemetry
|
||||||
|
|
||||||
|
- `SecureLogger` uses OSLog privacy markers and filters likely secrets. Release builds suppress debug verbosity.
|
||||||
|
- No project analytics or telemetry endpoint exists.
|
||||||
|
- Apple system logs, Nostr relays, network providers, and nearby radios can still observe operational metadata outside the project's logging layer.
|
||||||
|
|
||||||
|
## Privacy Manifests
|
||||||
|
|
||||||
|
`bitchat/PrivacyInfo.xcprivacy` declares:
|
||||||
|
|
||||||
|
- UserDefaults: `CA92.1` for app-only preferences and `1C8F.1` for the shared app group
|
||||||
|
- File timestamps/metadata: `C617.1` for app-container files and `3B52.1` for user-granted files
|
||||||
|
- System boot time: `35F9.1` for elapsed-time deadlines and timers
|
||||||
|
|
||||||
|
`bitchatShareExtension/PrivacyInfo.xcprivacy` declares app-group UserDefaults reason `1C8F.1`. Both manifests declare no tracking domains and no data collection by the app developer. They must remain bundled in their respective executable bundles.
|
||||||
|
|
||||||
|
## Panic Wipe Coverage
|
||||||
|
|
||||||
|
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test.
|
||||||
|
|
||||||
|
## Release Review Checklist
|
||||||
|
|
||||||
|
- Reconcile every new Application Support, UserDefaults, keychain, cache, or relay write with this assessment and `PRIVACY_POLICY.md`.
|
||||||
|
- Re-scan required-reason APIs and validate both bundled `PrivacyInfo.xcprivacy` files before archive submission.
|
||||||
|
- Verify panic wipe reaches any newly added persistent store.
|
||||||
|
- Treat geohash precision, bridge-cell changes, new relay tags, and announce fields as privacy-surface changes.
|
||||||
|
- Re-run real-device Bluetooth, background/locked-device recovery, location revocation, and audio-route checks; simulators cannot validate the physical side of those behaviors.
|
||||||
|
|||||||
@@ -26,12 +26,13 @@
|
|||||||
<key>HeadersPath</key>
|
<key>HeadersPath</key>
|
||||||
<string>Headers</string>
|
<string>Headers</string>
|
||||||
<key>LibraryIdentifier</key>
|
<key>LibraryIdentifier</key>
|
||||||
<string>ios-arm64-simulator</string>
|
<string>ios-arm64_x86_64-simulator</string>
|
||||||
<key>LibraryPath</key>
|
<key>LibraryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>libarti_bitchat.a</string>
|
||||||
<key>SupportedArchitectures</key>
|
<key>SupportedArchitectures</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
|
<string>x86_64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>SupportedPlatform</key>
|
<key>SupportedPlatform</key>
|
||||||
<string>ios</string>
|
<string>ios</string>
|
||||||
@@ -44,12 +45,13 @@
|
|||||||
<key>HeadersPath</key>
|
<key>HeadersPath</key>
|
||||||
<string>Headers</string>
|
<string>Headers</string>
|
||||||
<key>LibraryIdentifier</key>
|
<key>LibraryIdentifier</key>
|
||||||
<string>macos-arm64</string>
|
<string>macos-arm64_x86_64</string>
|
||||||
<key>LibraryPath</key>
|
<key>LibraryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>libarti_bitchat.a</string>
|
||||||
<key>SupportedArchitectures</key>
|
<key>SupportedArchitectures</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
|
<string>x86_64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>SupportedPlatform</key>
|
<key>SupportedPlatform</key>
|
||||||
<string>macos</string>
|
<string>macos</string>
|
||||||
|
|||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -5,11 +5,20 @@
|
|||||||
# Output: Frameworks/arti.xcframework containing static libraries for:
|
# Output: Frameworks/arti.xcframework containing static libraries for:
|
||||||
# - aarch64-apple-ios (iOS device)
|
# - aarch64-apple-ios (iOS device)
|
||||||
# - aarch64-apple-ios-sim (iOS simulator, Apple Silicon)
|
# - aarch64-apple-ios-sim (iOS simulator, Apple Silicon)
|
||||||
# - x86_64-apple-ios (iOS simulator, Intel - optional)
|
# - x86_64-apple-ios (iOS simulator, Intel)
|
||||||
# - aarch64-apple-darwin (macOS)
|
# - aarch64-apple-darwin (macOS, Apple Silicon)
|
||||||
|
# - x86_64-apple-darwin (macOS, Intel)
|
||||||
#
|
#
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# rustup/cargo install tools here on standard setups; GUI/Xcode shells often
|
||||||
|
# omit it from PATH even though `cargo` itself is available elsewhere.
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
RUST_TOOLCHAIN="${RUST_TOOLCHAIN:-1.96.0}"
|
||||||
|
RUSTC_VERSION="${RUSTC_VERSION:-1.96.0}"
|
||||||
|
CBINDGEN_VERSION="${CBINDGEN_VERSION:-0.29.4}"
|
||||||
|
export RUSTC="$(rustup which --toolchain "$RUST_TOOLCHAIN" rustc)"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
@@ -23,7 +32,9 @@ OUTPUT_DIR="$SCRIPT_DIR/Frameworks"
|
|||||||
TARGETS=(
|
TARGETS=(
|
||||||
"aarch64-apple-ios" # iOS device
|
"aarch64-apple-ios" # iOS device
|
||||||
"aarch64-apple-ios-sim" # iOS simulator (Apple Silicon)
|
"aarch64-apple-ios-sim" # iOS simulator (Apple Silicon)
|
||||||
"aarch64-apple-darwin" # macOS
|
"x86_64-apple-ios" # iOS simulator (Intel)
|
||||||
|
"aarch64-apple-darwin" # macOS (Apple Silicon)
|
||||||
|
"x86_64-apple-darwin" # macOS (Intel)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
@@ -45,23 +56,35 @@ check_prerequisites() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v cargo &> /dev/null; then
|
if ! rustup run "$RUST_TOOLCHAIN" cargo --version &> /dev/null; then
|
||||||
log_error "Cargo is not installed. Please install via rustup."
|
log_error "Cargo is not installed in rustup toolchain $RUST_TOOLCHAIN."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local actual_rustc
|
||||||
|
actual_rustc="$(rustup run "$RUST_TOOLCHAIN" rustc --version)"
|
||||||
|
if [[ "$actual_rustc" != "rustc $RUSTC_VERSION "* ]]; then
|
||||||
|
log_error "Expected rustc $RUSTC_VERSION in $RUST_TOOLCHAIN; found $actual_rustc."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check/install targets
|
# Check/install targets
|
||||||
for target in "${TARGETS[@]}"; do
|
for target in "${TARGETS[@]}"; do
|
||||||
if ! rustup target list --installed | grep -q "$target"; then
|
if ! rustup target list --toolchain "$RUST_TOOLCHAIN" --installed | grep -q "$target"; then
|
||||||
log_info "Installing target: $target"
|
log_info "Installing target: $target"
|
||||||
rustup target add "$target"
|
rustup target add --toolchain "$RUST_TOOLCHAIN" "$target"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Install cbindgen if needed
|
# Install cbindgen if needed
|
||||||
if ! command -v cbindgen &> /dev/null; then
|
if ! command -v cbindgen &> /dev/null; then
|
||||||
log_info "Installing cbindgen..."
|
log_info "Installing cbindgen $CBINDGEN_VERSION..."
|
||||||
cargo install cbindgen
|
rustup run "$RUST_TOOLCHAIN" cargo install cbindgen --version "$CBINDGEN_VERSION" --locked
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(cbindgen --version)" != "cbindgen $CBINDGEN_VERSION" ]]; then
|
||||||
|
log_error "Expected cbindgen $CBINDGEN_VERSION; found $(cbindgen --version)."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Prerequisites OK"
|
log_info "Prerequisites OK"
|
||||||
@@ -100,7 +123,7 @@ build_target() {
|
|||||||
setup_rustflags "$target"
|
setup_rustflags "$target"
|
||||||
|
|
||||||
# Build release
|
# Build release
|
||||||
cargo build --release --target "$target" -p "$CRATE_NAME"
|
rustup run "$RUST_TOOLCHAIN" cargo build --release --target "$target" -p "$CRATE_NAME"
|
||||||
|
|
||||||
# Check output
|
# Check output
|
||||||
local lib_path="target/$target/release/$LIB_NAME"
|
local lib_path="target/$target/release/$LIB_NAME"
|
||||||
@@ -123,9 +146,7 @@ create_xcframework() {
|
|||||||
rm -rf "$xcframework_path"
|
rm -rf "$xcframework_path"
|
||||||
mkdir -p "$OUTPUT_DIR"
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
# Build the xcodebuild command
|
# Normalize each architecture before combining the universal slices.
|
||||||
local cmd="xcodebuild -create-xcframework"
|
|
||||||
|
|
||||||
for target in "${TARGETS[@]}"; do
|
for target in "${TARGETS[@]}"; do
|
||||||
local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME"
|
local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME"
|
||||||
if [[ -f "$lib_path" ]]; then
|
if [[ -f "$lib_path" ]]; then
|
||||||
@@ -138,18 +159,43 @@ create_xcframework() {
|
|||||||
xcrun libtool -static -D -no_warning_for_no_symbols "$lib_path" -o "$normalized_path"
|
xcrun libtool -static -D -no_warning_for_no_symbols "$lib_path" -o "$normalized_path"
|
||||||
mv "$normalized_path" "$lib_path"
|
mv "$normalized_path" "$lib_path"
|
||||||
|
|
||||||
cmd="$cmd -library $lib_path"
|
|
||||||
|
|
||||||
# Add headers if they exist
|
|
||||||
local header_dir="$OUTPUT_DIR/include"
|
|
||||||
if [[ -d "$header_dir" ]]; then
|
|
||||||
cmd="$cmd -headers $header_dir"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
log_warn "Skipping missing library: $lib_path"
|
log_error "Missing required library: $lib_path"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
local simulator_dir="$SCRIPT_DIR/target/ios-universal-simulator/release"
|
||||||
|
local simulator_lib="$simulator_dir/$LIB_NAME"
|
||||||
|
mkdir -p "$simulator_dir"
|
||||||
|
xcrun lipo -create \
|
||||||
|
"$SCRIPT_DIR/target/aarch64-apple-ios-sim/release/$LIB_NAME" \
|
||||||
|
"$SCRIPT_DIR/target/x86_64-apple-ios/release/$LIB_NAME" \
|
||||||
|
-output "$simulator_lib"
|
||||||
|
|
||||||
|
# Normalize the universal archive after lipo so repeated rebuilds remain
|
||||||
|
# byte-stable just like the single-architecture inputs.
|
||||||
|
local normalized_simulator="$simulator_lib.normalized"
|
||||||
|
xcrun libtool -static -D -no_warning_for_no_symbols "$simulator_lib" -o "$normalized_simulator"
|
||||||
|
mv "$normalized_simulator" "$simulator_lib"
|
||||||
|
|
||||||
|
local macos_dir="$SCRIPT_DIR/target/macos-universal/release"
|
||||||
|
local macos_lib="$macos_dir/$LIB_NAME"
|
||||||
|
mkdir -p "$macos_dir"
|
||||||
|
xcrun lipo -create \
|
||||||
|
"$SCRIPT_DIR/target/aarch64-apple-darwin/release/$LIB_NAME" \
|
||||||
|
"$SCRIPT_DIR/target/x86_64-apple-darwin/release/$LIB_NAME" \
|
||||||
|
-output "$macos_lib"
|
||||||
|
|
||||||
|
local normalized_macos="$macos_lib.normalized"
|
||||||
|
xcrun libtool -static -D -no_warning_for_no_symbols "$macos_lib" -o "$normalized_macos"
|
||||||
|
mv "$normalized_macos" "$macos_lib"
|
||||||
|
|
||||||
|
local header_dir="$OUTPUT_DIR/include"
|
||||||
|
local cmd="xcodebuild -create-xcframework"
|
||||||
|
cmd="$cmd -library $SCRIPT_DIR/target/aarch64-apple-ios/release/$LIB_NAME -headers $header_dir"
|
||||||
|
cmd="$cmd -library $simulator_lib -headers $header_dir"
|
||||||
|
cmd="$cmd -library $macos_lib -headers $header_dir"
|
||||||
cmd="$cmd -output $xcframework_path"
|
cmd="$cmd -output $xcframework_path"
|
||||||
|
|
||||||
log_info "Running: $cmd"
|
log_info "Running: $cmd"
|
||||||
@@ -185,12 +231,13 @@ create_xcframework() {
|
|||||||
<key>HeadersPath</key>
|
<key>HeadersPath</key>
|
||||||
<string>Headers</string>
|
<string>Headers</string>
|
||||||
<key>LibraryIdentifier</key>
|
<key>LibraryIdentifier</key>
|
||||||
<string>ios-arm64-simulator</string>
|
<string>ios-arm64_x86_64-simulator</string>
|
||||||
<key>LibraryPath</key>
|
<key>LibraryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>libarti_bitchat.a</string>
|
||||||
<key>SupportedArchitectures</key>
|
<key>SupportedArchitectures</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
|
<string>x86_64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>SupportedPlatform</key>
|
<key>SupportedPlatform</key>
|
||||||
<string>ios</string>
|
<string>ios</string>
|
||||||
@@ -203,12 +250,13 @@ create_xcframework() {
|
|||||||
<key>HeadersPath</key>
|
<key>HeadersPath</key>
|
||||||
<string>Headers</string>
|
<string>Headers</string>
|
||||||
<key>LibraryIdentifier</key>
|
<key>LibraryIdentifier</key>
|
||||||
<string>macos-arm64</string>
|
<string>macos-arm64_x86_64</string>
|
||||||
<key>LibraryPath</key>
|
<key>LibraryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>libarti_bitchat.a</string>
|
||||||
<key>SupportedArchitectures</key>
|
<key>SupportedArchitectures</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
|
<string>x86_64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>SupportedPlatform</key>
|
<key>SupportedPlatform</key>
|
||||||
<string>macos</string>
|
<string>macos</string>
|
||||||
|
|||||||
@@ -32,12 +32,27 @@ public protocol KeychainManagerProtocol {
|
|||||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
/// Load data from a custom service
|
/// Load data from a custom service
|
||||||
func load(key: String, service: String) -> Data?
|
func load(key: String, service: String) -> Data?
|
||||||
|
/// Load data from a custom service while preserving Keychain status.
|
||||||
|
/// Callers that own encrypted files need to distinguish a missing key
|
||||||
|
/// from a temporarily inaccessible key before replacing those files.
|
||||||
|
func loadWithResult(key: String, service: String) -> KeychainReadResult
|
||||||
/// Delete data from a custom service
|
/// Delete data from a custom service
|
||||||
func delete(key: String, service: String)
|
func delete(key: String, service: String)
|
||||||
/// Delete every item stored under a custom service
|
/// Delete every item stored under a custom service
|
||||||
func deleteAll(service: String)
|
func deleteAll(service: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension KeychainManagerProtocol {
|
||||||
|
/// Source-compatible fallback for lightweight/test implementations. The
|
||||||
|
/// production manager overrides this with the underlying OSStatus.
|
||||||
|
func loadWithResult(key: String, service: String) -> KeychainReadResult {
|
||||||
|
if let data = load(key: key, service: service) {
|
||||||
|
return .success(data)
|
||||||
|
}
|
||||||
|
return .itemNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Keychain Error Types
|
// MARK: - Keychain Error Types
|
||||||
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
|
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
// TODO: Create a module for test helpers
|
// Kept local until the test-helper module is split out.
|
||||||
final class MockKeychain: KeychainManagerProtocol {
|
final class MockKeychain: KeychainManagerProtocol {
|
||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
private var serviceStorage: [String: [String: Data]] = [:]
|
private var serviceStorage: [String: [String: Data]] = [:]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// TODO: Create a module for test helpers
|
// Kept local until the test-helper module is split out.
|
||||||
struct TestConstants {
|
struct TestConstants {
|
||||||
static let defaultTimeout: TimeInterval = 5.0
|
static let defaultTimeout: TimeInterval = 5.0
|
||||||
static let shortTimeout: TimeInterval = 1.0
|
static let shortTimeout: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
@testable import BitFoundation
|
@testable import BitFoundation
|
||||||
|
|
||||||
// TODO: Create a module for test helpers
|
// Kept local until the test-helper module is split out.
|
||||||
final class TestHelpers {
|
final class TestHelpers {
|
||||||
|
|
||||||
// MARK: - Key Generation
|
// MARK: - Key Generation
|
||||||
|
|||||||
Reference in New Issue
Block a user