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.
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1
Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM
store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed,
well-scoped findings; deeper architectural/security items are tracked
separately.
- PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s
150ms retry pause left the mic live and streaming for up to 120s, because
cancel() no-op'd once `completed` was set. Bail after the sleep if the hold
was released, and make cancel() always tear down a late-started capture.
- Bridge courier depositDrop reported success and burned the dedup slot before
the drop was actually published (evicted/compose-fail = lying 📦 "carried"
with no retry). Only consume publishedDropKeys on durable accept; add
BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey).
- Blocked senders resurfaced via archived "heard here earlier" echoes, the one
path that bypassed the live block filter — filter at seed time.
- A late optimistic .sent clobbered the router's .carried state; extend
ConversationStore.shouldSkipStatusUpdate to a full precedence guard
(sending < sent < carried < delivered < read).
- Read receipts were permanently burned when the router dropped them (marked
sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only
record as sent on a successful route, else retry on the next read scan.
- MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a
peer that never reconnects sat on .sending until relaunch — run it in the
120s bridge sweep.
- Sightings tally now rolls over at midnight while idle; wave notification
action localized across all 29 locales; bridged anon#tag uses suffix(4) like
everything else; makeThrowawayIdentity delegates to NostrIdentity.generate();
.swiftlint.yml excludes .claude worktrees.
- Add regression tests: carried→sent no-downgrade, carried→delivered upgrade,
evicted pending drop stays retryable.
- Bump MARKETING_VERSION to 1.7.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage
The stricter no-downgrade guard (delivered/carried never regress to sent) broke
two things the earlier commit didn't catch locally (perf tests are skipped in
the default run, and Periphery runs only in CI):
- PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered
assuming both directions apply; the delivered -> sent half is now correctly
skipped, so the pass measured 0 updates. Alternate two delivered timestamps
instead — every update is real, no downgrade.
- Routing the geoDM "not in a location channel" error into the thread removed
the only caller of ChatPrivateConversationContext.addSystemMessage, leaving
it (and its mock) dead per Periphery. Drop the protocol requirement, the mock
impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is
compile-time enforced: the context can no longer emit a public system line).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge
- Read receipts: claim the receipt in sentReadReceipts synchronously
before spawning the routing task (chat open runs two read scans in one
MainActor stretch, so the async insert let every unread message route
twice), and release the claim when the route fails so the
retry-on-failed-route behavior is preserved.
- Delivery status: extend the no-downgrade guard so the `.sending`
stamp a pre-handshake resend emits can no longer clobber
carried/delivered/read (the 📦 indicator survived `.sent` but not
`.sending`).
- Archived echoes: blocking a peer now purges their carried public
messages from the gossip archive at block time (UnifiedPeerService
and /block), while the fingerprint-to-peerID mapping is still known —
the seed-time filter can't resolve offline non-favorite strangers and
stays only as defense-in-depth. New Transport hook (default no-op) +
GossipSyncManager.removePublicMessages with immediate persist.
- Bridge courier: an envelope that can't encode within the drop size
caps fails identically on every attempt; consume the dedup slot so
the 120s retry sweep stops re-running Noise sealing on it.
- MeshSightingsTracker: cache the day-key DateFormatter instead of
building one per call.
Tests: double-markAsRead dedup + failed-route retry, carried→sending
no-downgrade matrix, block-time purge (manager + service wiring),
oversize-drop slot consumption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Also skip the sent→sending downgrade in the delivery-status guard
Codex review follow-up: sendPrivateMessage without an established Noise
session emits `.sending` asynchronously, so it can land after the
message already reached `.sent` and visibly walk "Sent" back to
"Sending...". Treat `.sending` as weaker than `.sent` too — the status
was already truthful. `.failed` → `.sending` stays allowed so a retry
after a real failure remains visible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Retain Codable properties in Periphery scan (same fix as #1421)
The noiseKey assign-only false positive fired persistently on this branch
(twice, including a rerun) despite the baselined USR. Byte-identical to the
fix on fix/announce-replay-link-steal so the branches merge cleanly in
either order.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so
platform-specific code is never touched), with test targets indexed and
the share extension built. 277 dead declarations removed or demoted:
dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed-
feature remnants (autocomplete command suggestions, back-swipe tuning,
MediaSendError, GeohashParticipantTracker), unused Tor dormancy
bindings, assign-only properties, unused parameters (renamed to _), and
redundant public accessibility. 13 orphaned localization keys deleted
across all 29 locales (old pre-#1392 location-notes UI, app_info
warnings).
Two real tests were flagged as unused because they never ran: Swift
Testing methods missing @Test (NostrProtocolTests.
testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests.
testAssemblesCompressedLargeFrame). Re-armed both; they pass.
Deliberately kept, now recorded in .periphery.baseline.json: iOS-only
code invisible to the CI macOS scan, C FFI signatures, keep-alive
NWPathMonitor reference, InboundEventKey.eventID (dedup semantics),
wifiBulk capability bit (reserved for Wi-Fi bulk work, used by
BitFoundation package tests), and the String secureClear cluster
(exercised by package tests).
New: .periphery.yml config and an advisory Dead Code CI job (mirrors
the SwiftLint precedent from #1361) that fails on findings not in the
committed baseline.
Verified: full macOS app suite, BitFoundation (119) and BitLogger (13)
package tests green; periphery scan --strict exits clean.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ConversationStore.auditInvariants() verifies the per-conversation and
store-level message-ID indexes, caps, timestamp ordering, unread-set
membership, and selection validity - wired to the existing read-receipt
cleanup cadence, loud (.error) on violation, sampled heartbeat when
healthy (~2.8ms per audit at 5k messages, benchmarked and floored).
Router drops log both outcomes (marked failed / skipped by no-downgrade
guard); relay cap evictions, age sweeps, and jittered reconnect delays
log their counts; mirrored republishes get a sampled proof line. 11 new
invariant tests corrupt store state through DEBUG-only hooks since the
single-writer lockdown makes those states unreachable via intents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a private message is mirrored into stable-key and ephemeral-peer
conversations as one shared BitchatMessage instance, the first
conversation's status apply mutated the shared object and the second
skipped as already-equal - state stayed correct but the mirrored
conversation never republished, so a view observing it rendered stale
delivery/read status. The ID-only fan-out now republishes and emits
.statusChanged for every skipped conversation whose message holds the
applied status; genuinely-rejected distinct copies (downgrades) stay
untouched, and duplicate acks still publish nothing.
Found by Codex review on #1334.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feature models observe per-conversation objects directly: PublicChatModel
forwards the active Conversation's objectWillChange, PrivateInboxModel
republishes only for the selected peer's conversation - background
appends no longer invalidate foreground views. LegacyConversationStore,
the coalescing bridge, and IdentityResolver are deleted (resolver
canonicalization proved display-invisible: nothing enumerates direct
conversations, lookups are by exact peer ID, and raw keying is strictly
more robust - documented as a design deviation). Selection state moves
into the store. ChatViewModel.messages/privateChats survive as derived
read views for coordinators that genuinely need them; hot paths use a
new store-direct privateMessages(for:) witness.
Final numbers vs pre-migration baselines:
pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x)
pipeline.publicIngest 6.8k -> 13.7k msg/s (2.0x)
delivery updates 38k -> 117-133k/s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ConversationStore maintains an exact messageID -> Set<ConversationID>
map at every mutation point (append/upsert/remove/migrate/trim/clear),
so delivery updates are ID-only lookups that fan out to mirrored
ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119
lines: the positional location index, its growth-detection/rebuild
machinery, and the duplicate no-downgrade check are deleted - the rule
now lives in exactly one place. The middle-insertion regression tests
are rewritten against the store since stale positional locations are
structurally impossible now.
delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mesh and geohash timelines are now store conversations. All public
mutation sites flow through store intents; PublicMessagePipeline keeps
its 80ms UI batching but commits batches via store appends with each
buffered entry carrying its destination conversation (a mid-batch
channel switch now flushes instead of dropping the buffer).
ChatViewModel.messages becomes a cached get-only view of the active
conversation, invalidated through the change subject. The mesh
late-insert threshold is consciously removed: it only ever ordered the
non-rendered messages copy, so strict timestamp insertion makes the
working set agree with rendered order. PublicTimelineStore and the
per-message full-array legacy sync are deleted; the coalescing bridge
mirrors public conversations for the remaining legacy readers.
pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady;
store.append 237k/s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All private-message mutations now flow through store intents:
coordinators, PrivateChatManager (its @Published dicts deleted - now
read-only views over the store), outbound sends, delivery status, and
chat migration. The O(1) store dedup replaces the full-scan duplicate
check; insertion order is maintained by the store so sanitizeChat's
re-sort is a documented no-op. Both bootstrapper Combine bridges and
the Task.yield store synchronization are deleted.
ChatViewModel.privateChats/unreadPrivateMessages become get-only derived
views (measured: naive rebuild equals a change-invalidated cache within
noise, so the simpler form stays). Feature models still read the legacy
store, fed by a coalescing LegacyConversationStoreBridge (one mirror
per burst, marked for step-5 deletion).
pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-conversation ObservableObjects with O(1) dedup via an incrementally
maintained message-ID index, binary-search timestamp insertion, folded
cap policies, a no-downgrade delivery rule, and a typed change subject.
All mutation flows through store intents (conversation mutators are
fileprivate). The previous store is renamed LegacyConversationStore
pending deletion in step 5. 16 behavioral tests including per-
conversation publish isolation; store.append benchmarks at ~144k
messages/sec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>