Commit Graph
31 Commits
Author SHA1 Message Date
jack 62e9a1d83d Make private media deletion transactional 2026-07-25 20:42:15 +02:00
jack 8a6d874a22 Retry confirmed private media after reconnect 2026-07-25 20:41:40 +02:00
jack 17113e6fc8 Deliver live transport events synchronously 2026-07-25 20:41:13 +02:00
jackandjack 8a3097fd41 Authenticate private media capabilities in Noise 2026-07-25 20:39:29 +02:00
jackandjack 86d8bb70f7 Harden private media migration compatibility 2026-07-25 20:39:29 +02:00
b7c6f42b3a Close forged-directness link-rebind DoS on DM routing (#1421)
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut

Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".

Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
  established Noise session; Nostr keeps its prompt-delivery predicate;
  the protocol default forwards to canDeliverPromptly for transports
  without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
  it can deliver securely; otherwise it still sends (kicking the
  handshake on a genuine link) but retains a copy and hands a sealed
  copy to couriers, like the reachable path. flushOutbox gets the same
  gate so a flush over an insecure link resends instead of dropping the
  retained copy.

Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff

Review follow-ups on the canDeliverSecurely gate:

- flushOutbox no longer counts connected-but-insecure flushes toward the
  maxSendAttempts drop: the message was actually transmitted over a live
  link, so a peer whose Noise handshake stalls across reconnect flapping
  must not burn through the cap and lose the store-and-forward copy the
  gate exists to preserve. Retention stays bounded by the 24h outbox TTL
  and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
  for reachable-only (heuristic) sends. Regression test: >8 connected-
  insecure flushes keep the retained copy, drop callback never fires.

- Document the known second-replay presence gap: linkBoundToOtherPeer
  reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
  the link, so once a first replay has rebound a link to an absent
  victim's ID, a second replay marks the victim connected. Presence
  display only — DMs stay on the retain+courier path via the router
  gate. Not closed at the announce layer because the post-rebind state
  is indistinguishable from a legitimate rotation/reconnect heal (a
  supported, field-verified flow). Covered by a two-announce test.

- Note the accepted courier-spray metadata tradeoff at the connected-
  insecure send: nearby verified peers receive a sealed copy (they learn
  a DM exists, never its content), cleared on ack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery

Codex review follow-ups on 5017c232:

- P1: a legitimate rotation announce necessarily arrives on a link still
  bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
  new identity as disconnected — and the rebind only fixed link state,
  never the registry. A healed rotation then read as disconnected until
  the peer happened to announce again. rebindLinkAfterVerifiedDirect-
  Announce now promotes the rebound identity to connected after the
  containment checks pass (registry markConnected + topology/snapshot/
  peer-list refresh; the .peerConnected UI event already fired from the
  announce path). This consciously moves the forged-presence residue
  from second-replay to first-successful-rebind — bounded by the rebind
  containment (never steals a live identity, one rebind per link per
  cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
  Comments and the pinned tests updated; new regression test covers
  rotate-on-open-link -> rebind -> isPeerConnected(new) == true.

- P2: BLEService.canDeliverSecurely probed the Noise session with the
  peer ID as given, but sessions are keyed by the short wire ID — a send
  keyed by the full 64-hex Noise key (favorites resolution) misread an
  established session as insecure and needlessly retained + couriered
  every DM until ack. Normalize with toShort() like isPeerConnected.
  Test drives a real XX handshake and asserts both ID forms pass.

- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
  flake fired again and slipped past its baselined USR (read exists in
  loadFromDisk; the indexer intermittently misses it). Replace the
  baseline entry with retain_codable_properties: true — deterministic,
  and a truly-dead Codable field is a persisted-format change anyway.
  Local periphery scan --strict: no unused code detected.

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>
2026-07-09 16:46:33 +02:00
dd6b624cae Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418)
* 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>
2026-07-09 16:44:02 +02:00
74b8a47d02 Empty-mesh liveliness: nearby conversations, echoes, wave, dead drops, radar (#1409)
* Empty-mesh liveliness: nearby conversations, echoes, wave action, dead drops, radar

The empty mesh timeline was a dead end: a grey zero and "nobody in range
yet". This turns it into a live surface and gives the app pull when the
mesh wakes up:

- Nearest conversation: background geohash sampling now tracks actual
  chat messages (not just presence) per regional channel; the empty state
  surfaces the busiest nearby conversation with a preview, one tap to
  join (GeohashChatActivityTracker, fed from GeoPresenceTracker).
- Echoes: the carried 6h store-and-forward window renders as dimmed
  "heard here earlier" rows at launch (new Transport
  collectArchivedPublicMessages -> GossipSyncManager snapshot, decoded
  with signature-derived nicknames; content-identity dedup guards
  against re-synced duplicates).
- Wave: the "bitchatters nearby" notification gains a "wave" quick
  action that broadcasts a mesh 👋 straight from the notification, even
  backgrounded (first UNNotificationCategory in the app).
- Dead drops: /drop pins a note to the current building geohash as a
  kind-1 location note with a 24h NIP-40 expiry; expired notes are now
  dropped client-side at ingest; the notices sheet shows "fades in Xh";
  a "location notes" toggle plus location-permission controls live in
  app info (also fixes the duplicated Voice section).
- Radar: an ambient sonar animation shows the radio scanning, with a
  privacy-safe daily tally ("N devices passed within range today" via
  salted per-day hashes) and a "notes left here" hint that opens the
  notices geo tab.

All new user-facing strings ship in all 29 locales. 1403 tests green,
including new suites for the activity tracker, sightings tally, and
note expiry/drop publishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review round: app-info polish, urgent/expiry parity, centered radar, pin fill, Codex P2s

- App info: LOCATION header uppercased like sibling sections; location
  notes and live voice descriptions shortened (29 locales); redundant
  "location access granted" line removed.
- Notices parity: urgent + expiry controls now show on the geo tab too;
  the bridged Nostr note carries ["t","urgent"] and NIP-40 so relay-side
  readers see both; urgent parsed back from incoming notes.
- Radar moved from the top of the empty state to the center of the chat
  area, below the help text (empty state fills the visible height).
- Header pin fills whenever the scope has notices (was: only unseen),
  and Nostr-only nearby notes now light it too.
- Codex P2 fixes: notification completion deferred until the wave action
  is handled (background suspension dropped the send); NIP-40 notes now
  prune on a timer when they expire while displayed; the location-notes
  kill switch retargets the nearby-notes counter immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hide macOS segmented picker's built-in label in notices composer (duplicate 'expires in')

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Geo notes: permanent (∞) expiry default, urgent stays mesh-only

- Geo expiry picker gains ∞ as the default: a permanent note posts as a
  pure relay note (no NIP-40 tag, no mesh-board copy — a board copy must
  fade within days, contradicting the ∞ the user picked). 1/3/7d keep
  the board + bridged-note path with NIP-40.
- The notes manager is now owned by the notices sheet (not the list) so
  the composer local-echoes ∞ notes into the list; it revives via
  refresh() after a tab-switch cancel, and its expiry-prune timer
  survives cancel (weak self, dies with the instance).
- Urgent toggle returns to mesh-only per review — notes are ambient;
  the read-side urgent-tag parse stays so tagged notes still render.
- macOS: hide the segmented picker's built-in label (duplicate
  "expires in").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clearing the mesh timeline dismisses echoes for good; tighter divider copy

Triple-tap /clear emptied the timeline but the next launch re-seeded
"heard here earlier" from the persisted archive. A MeshEchoSettings
watermark now records the clear; only messages heard after it come back
(the archive itself still carries everything for peers' sync). The
echo dedup keys reset with it, and panic wipe drops the watermark.

Divider copy tightened to "heard here earlier · last 6h" (29 locales).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Echoes visual polish: tinted history block, radar-captioned tally, ambient footer

Device-test feedback on the echoes screen:
- Archived echoes now sit on a subtle tinted background (secondary at
  8%) in addition to the dim, so "heard here earlier" reads as one
  distinct block; the divider carries the echo ID prefix to join it.
- "N devices passed within range today" moves out of the narration
  lines to sit centered under the radar as its caption.
- When the timeline holds only echoes/system lines, a compact ambient
  footer (small radar + tally + live hints) renders below the history
  instead of the whole ambient layer vanishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notes strip persists above the mesh chat; leaner empty-state narration

- The 📍 "notes left here" line was empty-state-only, so starting a
  conversation hid it. It is now a tappable strip pinned above the mesh
  timeline whenever unexpired notes exist at this place (opens the
  notices geo tab); the nearby-notes counter runs for the whole mesh
  timeline, not just the empty state.
- Empty state narration drops "nobody in range yet..." (the radar and
  the sightings caption already say it) and the nearby-conversation
  hint moves below the help line instead of splitting the narration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Radar means searching: hide the sweep once mesh peers are connected or reachable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:24:44 +02:00
ef848857b7 Remove dead code found by full Periphery audit; add scan config + advisory CI (#1410)
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>
2026-07-08 11:24:19 +02:00
78a291ab77 Public-mesh push-to-talk: signed live voice bursts in the mesh channel (#1406)
* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback

Holding the mic in a DM now streams AAC frames live over the Noise session
(walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same
audio as a normal voice note. On release the note ships through the existing
fileTransfer pipeline; receivers that heard the live stream absorb it silently
into the same bubble (matched by the burst ID embedded in the file name), so
reliability comes for free and nobody sees duplicates.

Protocol:
- NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket
  (burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames)
- 210-byte burst-content budget keeps each Noise packet inside the 256-byte
  padding bucket: one BLE frame, never the fragment scheduler
- fire-and-forget: frames are dropped (never queued) without an established
  session; live is only offered when the peer is mesh-reachable

Receive:
- ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip,
  3s idle end, flood/size caps), persists progressively as ADTS .aac so even
  a partial burst is a replayable bubble
- live autoplay only when the conversation is on screen, app active, and the
  new app-info "live voice messages" toggle is on (also gates live sending)
- one-playback-at-a-time via a shared ExclusivePlayback slot

Capture:
- PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the
  finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder)
- VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the
  composer HUD shows a pulsing LIVE treatment when streaming

Includes the push-to-talk design doc, 6 new localization keys across all 29
locales, and unit tests for framing, packetizer budget, ADTS output, codec
round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType
0x29) lands separately on top of this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Public-mesh push-to-talk: signed live voice bursts in the mesh channel

Extends live PTT from DMs to the public mesh timeline. Holding the mic in
the mesh channel now broadcasts the burst live as signed voiceFrame packets
(MessageType 0x29) while the finalized voice note still ships on release —
new clients hear you as you speak and absorb the note silently into the live
bubble; old clients (and late joiners) keep receiving the note exactly as
before, so mixed-version meshes lose nothing.

Wire/relay:
- MessageType.voiceFrame = 0x29: ephemeral signed broadcast, never
  gossip-synced (SyncTypeFlags maps it to no bit), never padded (padding to
  the 512 block would push every ~490-byte signed packet into fragmentation)
- RelayController treats voiceFrame like media fragments: dense-graph TTL
  clamp contains the sustained ~15 pkt/s per-talker stream, tight 8-25 ms
  jitter keeps multi-hop latency inside the receiver's 350 ms jitter buffer
- inbound gate mirrors public messages: broadcast-only, 30 s freshness cap,
  packet signature verified against the claimed sender's announce before any
  audio reaches the UI

App:
- ChatLiveVoiceCoordinator gains burst scopes: public bubbles land in the
  mesh timeline, autoplay only while that timeline is on screen, and the
  finalized-note absorb is scope-bound (a public note can't replace a DM
  burst or vice versa)
- floor courtesy: while someone talks live in the public channel the
  composer mic tints red and pulses, with an accessibility value naming the
  talker ("%@ is speaking", localized in all 29 locales); holding still
  works — a decentralized mesh has no floor arbiter, the tint just
  discourages talk-over

Tests: relay policy (sparse cap + dense clamp), public bubble + talker
indicator lifecycle, note absorption into the mesh store, and scope-binding
rejection; full suite green (1382 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics

Codex review fixes (#1403):
- makeVoiceCaptureSession normalizes the selected peer with toShort() before
  the reachability/session checks and binds the send target to that same
  routing ID — a conversation selected under the stable 64-hex Noise key no
  longer silently falls back to a classic note while the short-ID session is
  established
- the live-voice toggle now gates inbound bursts too: off means
  classic-notes-only in both directions (no live bubble, partial file, or
  early notification; the finalized note still arrives), with a test

Field-test diagnostics (first device run: DM frames decrypted but no bubble
appeared, with no log evidence of which guard dropped them):
- coordinator logs undecodable frames (size + hex prefix) and blocked drops
- makeAssembly logs directory/file-handle failures instead of returning nil
  silently
- PTTLiveVoiceSession logs capture start and finish (packet/frame/duration
  counts); PTTCaptureEngine logs engine start success/failure with the input
  format; BLEService.sendVoiceFrame logs no-session drops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz)

Field testing showed the phone's live capture failing at mic enable with
AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound
to an earlier (playback-only or settling) audio session. The Mac, which has
no session lifecycle, captured fine, which is why public bursts from the Mac
worked while phone-side sends degraded from working (first hold) to sporadic
to dead across holds.

Three layers of defense:
- PTTCaptureEngine recreates its AVAudioEngine on every start(), after the
  session is configured, so the input unit binds to the session that is
  active now; a dead input (0 Hz or 0 channels) is now a distinct, logged
  error instead of a silent setup failure
- PTTLiveVoiceSession retries the capture start once after a 150 ms
  route-settle pause
- VoiceRecordingViewModel falls back to the classic VoiceRecorder within the
  same hold if the live engine still cannot start — a route glitch now costs
  the live stream, never the voice note

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Blue mic when the hold will stream live

The mic button now shows readiness at a glance — and doubles as a build
marker for device testing:
- blue: holding will stream live (DM peer reachable with an established
  Noise session, or the public mesh channel)
- accent (orange in DMs): holding records a classic voice note (no session
  yet, peer unreachable, or live voice toggled off)
- red states unchanged (recording, floor busy)

Refactors capture-backend selection into a single liveVoiceTarget() so the
indicator and makeVoiceCaptureSession can never disagree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revert the blue live-ready mic to the normal accent color

The build-verification marker did its job; idle mic color goes back to the
accent. The LIVE recording HUD remains the signal for whether a hold is
streaming. Keeps the liveVoiceTarget() refactor so backend selection stays
in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Leave a trace on every mic press and every inbound-frame drop

Field testing read "tap does nothing" as breakage: the mic start is async
(permission check + engine spin-up), so releasing before recording begins
has always been a silent cancel — for classic voice notes too. Every press
now logs which backend it chose and, for quick presses, that it released
before recording started.

Also logs the two remaining silent drops: inbound voice frames rejected by
the live-voice toggle (the one unlogged guard left in the receive path) and
the classic-note fallback now includes the toggle state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix mic hold dying instantly in DMs: sheet swipe gesture starved the composer

Field logs showed every DM mic hold ending 3-10 ms after it began, on both
platforms, while public-channel holds worked — the private sheet wraps its
entire content (composer included) in a high-priority swipe-right-to-leave
DragGesture, and a high-priority ancestor drag cancels the mic button's
press-and-hold within milliseconds. Same starvation mechanism as the DM
image-reveal bug (#1402), hitting a drag instead of a tap.

The swipe-to-leave gesture now lives on the message list only, so the
composer's gestures (mic hold, text field, buttons) are out of its reach and
the swipe still works where users actually swipe.

Also stops touching the capture engine when a hold cancels before the engine
ever started: probing inputNode on a never-started engine instantiates its
input unit against whatever session is active and spams benign-but-alarming
AURemoteIO -10851 errors into field logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reorder app info sheet: usage first, then settings, then reference

New section order: HOW TO USE, then the adjustable bits (appearance, voice,
network), then the reference material (features, privacy, symbols legend).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* App info: flow HOW TO USE into one paragraph; "list" and "person" wording

The six how-to-use bullets now read as a single comma-separated paragraph
(same instruction strings, legacy bullet prefix stripped at render). Two
wording updates across all 29 locales: the people icon opens the "list"
(not "sidebar"), and you tap a "person's" name (not a "peer's") to start
a DM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:23:16 +02:00
eacd8f0750 Live push-to-talk voice for DMs (streams while you talk, voice note as fallback) (#1403)
* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback

Holding the mic in a DM now streams AAC frames live over the Noise session
(walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same
audio as a normal voice note. On release the note ships through the existing
fileTransfer pipeline; receivers that heard the live stream absorb it silently
into the same bubble (matched by the burst ID embedded in the file name), so
reliability comes for free and nobody sees duplicates.

Protocol:
- NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket
  (burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames)
- 210-byte burst-content budget keeps each Noise packet inside the 256-byte
  padding bucket: one BLE frame, never the fragment scheduler
- fire-and-forget: frames are dropped (never queued) without an established
  session; live is only offered when the peer is mesh-reachable

Receive:
- ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip,
  3s idle end, flood/size caps), persists progressively as ADTS .aac so even
  a partial burst is a replayable bubble
- live autoplay only when the conversation is on screen, app active, and the
  new app-info "live voice messages" toggle is on (also gates live sending)
- one-playback-at-a-time via a shared ExclusivePlayback slot

Capture:
- PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the
  finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder)
- VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the
  composer HUD shows a pulsing LIVE treatment when streaming

Includes the push-to-talk design doc, 6 new localization keys across all 29
locales, and unit tests for framing, packetizer budget, ADTS output, codec
round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType
0x29) lands separately on top of this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics

Codex review fixes (#1403):
- makeVoiceCaptureSession normalizes the selected peer with toShort() before
  the reachability/session checks and binds the send target to that same
  routing ID — a conversation selected under the stable 64-hex Noise key no
  longer silently falls back to a classic note while the short-ID session is
  established
- the live-voice toggle now gates inbound bursts too: off means
  classic-notes-only in both directions (no live bubble, partial file, or
  early notification; the finalized note still arrives), with a test

Field-test diagnostics (first device run: DM frames decrypted but no bubble
appeared, with no log evidence of which guard dropped them):
- coordinator logs undecodable frames (size + hex prefix) and blocked drops
- makeAssembly logs directory/file-handle failures instead of returning nil
  silently
- PTTLiveVoiceSession logs capture start and finish (packet/frame/duration
  counts); PTTCaptureEngine logs engine start success/failure with the input
  format; BLEService.sendVoiceFrame logs no-session drops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz)

Field testing showed the phone's live capture failing at mic enable with
AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound
to an earlier (playback-only or settling) audio session. The Mac, which has
no session lifecycle, captured fine, which is why public bursts from the Mac
worked while phone-side sends degraded from working (first hold) to sporadic
to dead across holds.

Three layers of defense:
- PTTCaptureEngine recreates its AVAudioEngine on every start(), after the
  session is configured, so the input unit binds to the session that is
  active now; a dead input (0 Hz or 0 channels) is now a distinct, logged
  error instead of a silent setup failure
- PTTLiveVoiceSession retries the capture start once after a 150 ms
  route-settle pause
- VoiceRecordingViewModel falls back to the classic VoiceRecorder within the
  same hold if the live engine still cannot start — a route glitch now costs
  the live stream, never the voice note

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:20:58 +02:00
81a10f73f0 Private groups: creator-managed encrypted group chat over the mesh (#1383)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Private groups: creator-managed encrypted group chat over the mesh

Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs:

Protocol
- MessageType.groupMessage = 0x25: broadcast packets with a cleartext
  16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as
  AEAD AAD), inner Ed25519 sender signature over
  "bitchat-group-msg-v1"|groupID|messageID|timestamp|content
- NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07:
  creator-signed group state (key, epoch, roster) 1:1 over Noise; signature
  over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise
  session peer must BE the creator
- SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens
  1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients
  ignore unknown bits and answer with types they know
- PeerCapabilities.localSupported now advertises .groups

Storage
- GroupStore: symmetric keys in the keychain, roster/name/epoch as
  protected JSON in Application Support; wiped in panicClearAllData()

Behavior
- Non-members relay 0x25 like any broadcast but cannot read it; group
  messages join gossip-sync backfill with the public-message window
- Receivers drop wrong-epoch envelopes, bad sender signatures, and
  senders missing from the creator-signed roster
- Fire-and-flood delivery (no per-member acks in v1)

UI
- Groups open as chat windows through the private-chat sheet (virtual
  "group_" peer IDs); groups section in the people sheet; /group
  create/invite/remove/leave/list commands; invitees get a system message
  + notification and the group appears in their people sheet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes

Addresses the Codex review and adversarial-review findings on #1383:

- TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to
  65535 and truncating, so an oversize group message fails to seal and
  surfaces send_failed rather than shipping ciphertext recipients drop.
- Roster nicknames truncate on a Character boundary (never mid-scalar), so a
  multi-byte nickname can no longer make the whole signed roster undecodable.
- Invites now bump the epoch (rotate the key) like removals, giving every
  roster change a strictly-increasing epoch so out-of-order invite states no
  longer last-writer-wins a just-added member back out.
- Removing a member now sends them a creator-signed roster-without-them under
  a throwaway all-zero key (never the rotated key), so their client
  deactivates the group and surfaces "removed" instead of going silently dark.
- /block is enforced in the group receive path: a blocked member's messages
  are dropped from display and notifications, consistent with every other
  inbound path.
- Media affordances are disabled in group chats (both computed sites) so the
  composer can't strand a media placeholder that never sends; media-in-groups
  is a documented v2 item.
- Creator signature now covers the group name and the sender signature covers
  the epoch (wire-format-affecting; needs Android parity before ship).
- Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered
  receipts can never leak into group conversations under a future refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:59:55 +02:00
f9032cf2b9 Add mesh diagnostics: /ping, /trace, and topology map (#1377)
* Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing

Three fixes for PR #1377 review:

1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
   panicClearAllData / clearCurrentPublicTimeline detached utility-priority
   tasks delete the real ~/Library/Application Support/files tree, which the
   test process shares. The wipe fires at a nondeterministic time and raced
   the sendImage test's JPEG in files/images/outgoing (write then re-read),
   so prepareImagePacket threw and the test timed out. Both wipes are now
   skipped under tests (existing TestEnvironment.isRunningTests pattern);
   this also stops test runs from deleting the developer's real media.

2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
   packet.senderID let one connected peer rotate forged sender IDs to bypass
   the 5-per-10s budget. The limiter now keys on the ingress link (the
   directly connected peer that delivered the packet); the pong still goes
   to the claimed sender. Regression test proves rotating senders over one
   link exhaust one budget (fails 10 vs 5 pongs on the old code).

3. Codex P2: /ping output arrived up to 10s later and was routed from
   selectedPrivateChatPeer at callback time, misrouting the result after a
   chat switch. The origin conversation is now captured when the command is
   issued (CommandOutputDestination) and deferred output is routed there:
   a DM result lands in the origin chat's history even if deselected, and a
   mesh-timeline result pins to #mesh instead of the active channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Conform DiagnosticsMockContext to sendPublicMessage

CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after
this branch forked, so the diagnostics test mock no longer conformed once
main was merged in. Add the no-op stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:08:22 +02:00
2360140760 Transitive verification: vouch for verified peers over Noise (#1380)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Transitive verification: vouch for verified peers over Noise

When a Noise session establishes with a peer I verified and that peer
advertises the .vouch capability, send signed attestations (up to 16,
most recently verified first, at most once per peer per 24h) for the
OTHER fingerprints I verified. Receivers accept vouches only from
senders they verified themselves, verify the Ed25519 signature against
the sender's announce-bound signing key, and surface the result as a
new derived trust tier: vouched (unfilled seal) between casual and
trusted.

Protocol:
- NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations:
  voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp
  (uint64 ms BE), Ed25519 signature over
  "bitchat-vouch-v1" | fingerprint | signingKey | timestamp.
  The voucher is implicit in the authenticated session.
- PeerCapabilities.localSupported now advertises .vouch.

Storage (SecureIdentityStateManager / IdentityCache):
- vouches keyed by vouchee, capped at 8 vouchers each; validity is
  recomputed on read (voucher still verified-by-me, < 30 days old), so
  unverifying a voucher retires their vouches without cascade deletes.
- New IdentityCache fields are Optional so pre-existing encrypted
  caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but
  raw values are strings, so persisted values are unaffected (and
  vouched itself is never persisted).
- Panic wipe clears vouch state with the rest of the identity cache.

UI: unfilled checkmark.seal badge in the mesh peer list (filled seal
stays exclusive to verified) and a "vouched for by N people you
verified" section with voucher names in FingerprintView; VoiceOver
labels and xcstrings entries included.

Tests: attestation encode/decode + signature (forged/tampered/expired),
accept-policy gates, batch cap, trust-level derivation incl. voucher
invalidation, persistence compat, and coordinator exchange/accept
policies. Full macOS suite: 1088 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance

Two fixes for PR #1380 review findings:

1. CI "Run Swift Tests (app)" hang (exit 137): the new
   SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift
   Testing ran its tests in parallel on the Swift Concurrency cooperative
   pool. Each test enqueues a queue.async(.barrier) write (setVerified)
   and immediately blocks in queue.sync / queue.sync(.barrier)
   (recordVouch / effectiveTrustLevel). On CI's few-core runners every
   cooperative-pool thread ended up parked behind a pending barrier that
   never got a dispatch worker, deadlocking the whole test process until
   the watchdog SIGKILLed it. The suite is now @MainActor, matching the
   production isolation of the vouch API (ChatVouchCoordinator is
   @MainActor) and keeping blocking syncs off the cooperative pool.

2. Codex P2: an open fingerprint sheet did not refresh its vouched badge
   when a vouch batch was accepted - VerificationModel.bind() never
   observed the trust-change signal. It now subscribes to the
   "peerStatusUpdated" notification that
   ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source
   PeerListModel uses) and forwards it to objectWillChange. Added a
   regression test that pins VerificationModel's own subscription
   (verified to fail without the fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Skip media-wipe detached tasks under tests (shared-filesystem race)

panicClearAllData and clearCurrentPublicTimeline delete the real
~/Library/Application Support/files tree in detached utility-priority
tasks. The SPM test process shares that tree and ChatViewModelTests
invoke both methods, so under parallel scheduling the wipe lands at a
nondeterministic time — deleting media a concurrently running test just
wrote (and the developer's real app data with it). Guard both with the
existing TestEnvironment.isRunningTests pattern, mirroring the same fix
on feat/mesh-diagnostics (#1377).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Port vouch capability-race fix to feat/vouching (ports b8adcbe9)

Ports the on-device-confirmed fix from the integration test branch
(commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct.
On-device testing confirmed the transitive vouch propagated once the
send was triggered on verify / announce arrival rather than auth alone.

Vouch attestations only ever sent from peerAuthenticated, gated on the
peer's .vouch capability. That capability arrives via the peer's announce,
processed independently of the Noise handshake, so at auth time the set was
usually empty -> gate failed -> vouch silently skipped and never retried.

- Refactor the send path into a reusable attemptVouch(to:fingerprint:now:).
- Trigger on peer-list updates (peersUpdated): fired after every verified
  announce, so the batch goes out once the .vouch bit actually arrives.
- Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a
  peer runs a vouch pass over connected verified peers, covering the
  verify-while-connected case and propagating the new identity onward.
- Relax the capability gate: treat an empty/unknown set as eligible (the
  Noise 0x12 payload is ignored by non-supporting peers); only skip when a
  non-empty set explicitly lacks .vouch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:48:37 +02:00
60be88a4f5 Geohash bulletin board: persistent signed notices over mesh sync (#1379)
* Add geohash bulletin board: persistent signed notices over mesh sync

New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.

Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.

UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Board: bound orphan tombstones and reject future-dated posts at ingest

Two hardening fixes from Codex review of the geohash bulletin board:

- Orphan tombstones (P1): retention was derived solely from the
  sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
  with far-future deletedAt persisted and re-entered sync unboundedly.
  Retention is now also clamped to receive time (now + 7d + 1h skew --
  no post can outlive that), and orphans are capped at 100 globally and
  5 per author key with oldest-received evicted first. Matched
  tombstones and disk restores keep their existing behavior.

- Future-dated posts (P2): ingest only checked expiresAt > now, letting
  posts dated years ahead sort above honest posts and squat the 200
  global slots without ever pruning. The single ingest chokepoint
  (radio, sync, and disk restore all funnel through it) now rejects
  createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
  decoder's span rule is unchanged.

Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:13:25 +02:00
7341696280 Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372)
Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:

- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
  held only in the Keychain (no plaintext at rest); queued private
  messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
  courier connects, tracked per message so the same courier is never
  double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
  (2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
  (5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
  wire-compatible with old clients); couriers split half their remaining
  budget with each newly encountered courier so mail diffuses through a
  moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
  the multi-hop recipient (directed-relay treatment, 10-min per-envelope
  cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
  from 15 min to 6 h, matched on the receive-acceptance side, and the
  message store persists to disk so devices bridge partitions and
  restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
  store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
  counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
  actual flood control, courier system, gossip sync, Nostr path); the old
  document described a bloom filter, three fragment types, and a
  MessageRetryService that don't exist.

1037 macOS tests pass (17 new); iOS builds.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:33:16 +02:00
a66c591f8e Courier: deposit in parallel when the only route is a send queue (#1368)
* Deposit with couriers in parallel when the only route is a send queue

The courier path was nearly unreachable: NostrTransport claims any
favorite with a known npub as "reachable" regardless of connectivity,
and the mesh favorite exchange shares npubs, so for essentially every
courier-eligible recipient the router picked Nostr's reachable branch.
With no internet the message just sat in the relay send queue — in the
flagship scenario (internet shutdown, mutual friend standing right
there) the courier walked away carrying nothing.

Add Transport.canDeliverPromptly(to:), defaulting to reachability for
radio-backed transports; NostrTransport answers honestly by mirroring
the relay manager's connection state (fail-closed behind Tor). When the
chosen transport can't hand the message off promptly, the router now
also deposits a sealed copy with connected couriers. Double delivery is
harmless: receivers dedup by message ID, and delivered/read acks never
downgrade the carried status. When relays are up, sends are trusted and
no courier quota is spent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Track DM-relay connectivity, not any-relay, for prompt delivery

Codex review: NostrRelayManager.isConnected is true when any relay is
up, including geohash/custom relays — but private messages target the
default (gift-wrap-capable) relay set and queue when none of those are
connected. A lone geohash relay would have suppressed the parallel
courier deposit while the DM sat in the queue. Publish a DM-scoped
connectivity flag and drive canDeliverPromptly from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:45:57 +02:00
75da63c9d7 Fix favorites end-to-end: peer-list duplicates, Nostr sync, /fav key corruption (v1.5.4) (#1367)
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers

When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix courier handoff verification and directed sends

* Authenticate courier deposits by ingress peer

* Gate courier handover on direct announces and isolate store test

Envelopes are removed from the courier store optimistically, so releasing
them on a relayed (multi-hop) announce risks losing carried mail to a
speculative flood that never reaches the recipient. Handover now also
requires the announce to have arrived directly (full TTL), i.e. an actual
encounter with a live link; regression test builds a relayed copy of a
genuinely signed announce (TTL is excluded from announce signatures).

Also make CourierStore's on-disk location injectable so the persistence
test round-trips through a temp directory instead of wiping the real
Application Support store, and reattach BLEAnnounceHandler's doc comment
to the class it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use Xcode-bundled Swift in CI instead of a standalone toolchain

The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop couriered mail from blocked senders at envelope open

The UI-layer block check (isPeerBlocked in the transport event
coordinator) resolves a fingerprint from the live session or peer list,
but a couriered message arrives precisely when its sender is absent —
no session, no registry entry — so the check failed open and a blocked
identity's mail was delivered anyway. Gate in openCourierEnvelope,
where the sealed sender's full static key is in hand.

End-to-end test ferries a full deposit→carry→handover round and
verifies the envelope from a blocked sender never reaches the delegate
(confirmed failing without the gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption

- UnifiedPeerService: dedup offline favorites against mesh peers by noise
  key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs
  (never equal), leaving only a nickname+isConnected heuristic — a mutual
  favorite that was reachable-but-not-connected or renamed rendered twice,
  and a same-nick stranger could suppress a favorite entirely.
- Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live
  PM handler so they update theyFavoritedUs instead of rendering as chat
  text; mutual favorites can now form over Nostr. Delete the dead
  favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification
  (unwired, parsed a stale FAVORITE:TRUE|… format no sender emits).
- NostrTransport.isPeerReachable: match short form regardless of incoming
  ID width — toggling an offline favorite (addressed by 64-hex noise key)
  was silently dropped with no reachable transport.
- BLEService.sendPrivateMessage: normalize recipient to the short ID like
  sendFilePrivate, so a 64-hex target hits the existing Noise session
  instead of initiating a handshake with a 32-byte wire recipient ID.
- /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte
  routing ID for mesh peers — into the favorites store as a "noise key",
  and stop double-sending the favorite notification; delegate to
  toggleFavorite with a proper state check.
- FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored
  nickname when the caller passes the "Unknown" placeholder.
- Bump marketing version to 1.5.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline

Field-tested on device: with a DM window opened while the peer was on
mesh (conversation keyed by the short 16-hex ID), walking out of range
and sending failed instantly with "peer not reachable" even though the
header showed the peer as Nostr-reachable (mutual favorite, npub known).

sendPrivateMessage derived the favorites key as Data(hexString:
peerID.id) — for a short mesh ID that is the 8-byte routing ID, never
the noise key — so the mutual-favorite/Nostr-key checks always came up
empty and the send failed before reaching MessageRouter. Conversations
keyed by the full 64-hex noise-key ID (opened from the offline favorite
row) were unaffected, which is why later tests appeared to work.

Resolve the noise key properly (peerID.noiseKey, then the unified peer
row, then the favorites store by derived short ID) and add a regression
test for the mesh-keyed-peer-goes-offline case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Label Nostr DMs from favorites with their stored nickname

Field-tested: a DM delivered over the Nostr fallback rendered as
"anon#678e" instead of the sender's name. The inbound handler named the
sender via displayNameForNostrPubkey, which only knows geohash-scoped
names — even though the pipeline had already resolved the sender's
noise key (the conversation is keyed by it).

When the conversation key carries a noise key, prefer the favorite's
stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This
also stops an inbound Nostr [FAVORITED] from overwriting the stored
nickname with the anon fallback, since the same name feeds
updatePeerFavoritedUs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix courier path for offline favorites addressed by noise-key IDs

Two Codex review findings, both the same ID-width confusion this PR
targets, in the courier flow:

- CourierDirectory.favoritesBacked resolved recipients only via
  getFavoriteStatus(forPeerID:), which requires a short 16-hex ID —
  offline favorites are addressed by the full 64-hex noise-key ID, so
  attemptCourierDeposit silently bailed for exactly the peers couriers
  exist to serve. The 64-hex ID now yields its own key directly.
- openCourierEnvelope emitted the derived short mesh ID even when the
  sender has no live mesh identity, landing couriered mail in an
  unresolvable short-ID thread labeled "Unknown". Absent senders now
  emit the full noise-key ID so the message joins the stable favorite
  conversation; present senders keep the live short-ID thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:17:21 +02:00
jackandClaude Fable 5 8a867a17a1 Remove noise service exposure; single-owner selection state
Transport callers no longer reach the raw NoiseEncryptionService:
getNoiseService() is deleted in favor of narrow purpose-named Transport
methods (session public key, identity fingerprint, static/signing keys,
sign/verify, callback installation). VerificationService now reaches
crypto through the transport, so it can no longer pin a stale service
across a panic reset. myPeerID/myNickname become private(set); the
existing setNickname mutator is the sole nickname path.

ConversationStore is now the sole owner of private-chat selection:
PrivateChatManager.selectedPeer is a published read-only mirror, and
startChat/endChat mutate through the store intent. The bridge method
and its five call sites are deleted, removing a latent bug where a
stale manager selection pushed back into the store could resurrect a
just-removed conversation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:29:07 +02:00
ab0da61533 [codex] Refactor BLE transport event handling (#1266)
* Refactor BLE transport event handling

* Make image output paths unique

* Keep queued Nostr read receipts alive

* Allow self-authored RSR ingress replies

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-05-31 13:58:27 +02:00
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00
jackandClaude Opus 4.5 4b3077169a fix BCH-01-002: prevent DoS via file storage exhaustion
Incoming files are now held in memory via PendingFileManager instead of
being auto-saved to disk. Users must explicitly accept files before they
are written. This prevents attackers from exhausting device storage.

Key changes:
- Add PendingFileManager with configurable limits (max 10 files, 5MB total)
- Files auto-expire after 5 minutes if not accepted
- LRU eviction when limits are exceeded
- Pending files cleared during panic mode (emergencyDisconnectAll)
- Add didReceivePendingFileTransfer delegate method
- Add acceptPendingFile/declinePendingFile to Transport protocol

Security audit reference: Cure53 BCH-01-002 (Medium severity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:39:19 -10:00
83779240ae Prevent mesh self-sync duplicates (#856)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-23 10:41:28 +02:00
40e54a5120 Add voice notes and images over BLE mesh (audio + image only) (#823)
* Add BLE file transfer support and media UX

* Gracefully disable mac attachment pickers in sandbox

* Tighten spacing above media message bubbles

* Reduce vertical padding between chat rows

* Restore iOS file importer for attachments

* Copy imported files before sending to preserve access

* Allow file transfers from connected but unverified peers

* Raise BLE notification buffer cap for large file transfers

* Revert "Raise BLE notification buffer cap for large file transfers"

This reverts commit b624523af843475db84e4a846db8dcbe824ae408.

* Add guard to drop oversized BLE notification assemblies

* Let BLE assembler accept large frames up to hard cap

* Add detailed logging for BLE fragment assembly

* Log incomplete BLE frames for debugging

* Stop dropping partial BLE frames while assembling notifications

* Fix compressed BLE file transfers

* Enable mac attachment importers

* Allow mac microphone access

* Permit mac media library access

* Describe microphone usage

* Harden attachment transfer bookkeeping

* Display recording milliseconds

* Restore mac photo picker access

* Use Photos picker on mac

* Allow long-press reblur on images

* Reblur images via swipe

* Lowercase image preview buttons

* Keep processed images for outgoing messages

* Use save panel for mac image export

* Fix image attachment detection

* Allow user-selected write access

* Align mac image JPEG encoding

* Revert unsupported JPEG option

* Strip metadata in mac image encoding

* Normalize mac JPEG color space

* Target image byte size across platforms

* Fix CFMutableData handling

* Preserve packet version when signing

* Use unique transfer identifiers

* Stub file transfer methods in mock

* Stub file transfer methods in mock

* Hide absolute paths in media messages

* Resolve image/voice path handling

* Fix cleanupLocalFile lookup

* Restore BLE broadcasts when notify buffer is saturated

* Guard peer map reads on BLE message path

* Drop attachment ceilings to 1 MiB and bump release version

* Reset BLE assembler on stalled fragment trains

* Fix binary protocol test fixtures

* Fix critical issues from PR #681 review

Critical fixes:
- BinaryProtocol: Return nil for unknown versions (prevents buffer underflows)
- Add BinaryProtocol.Offsets struct to centralize magic numbers
- Replace magic offset calculations with named constants

Security/Privacy:
- FileAttachmentView: Use url.lastPathComponent instead of url.path
  (prevents exposing full system paths)

Documentation:
- Fix compression algorithm documentation (zlib, not LZ4)

All tests passing.

* Fix UI freeze when receiving voice notes

Problem: AVAudioPlayer initialization in VoiceNotePlaybackController.init()
was running synchronously on main thread during view creation, blocking
UI for 50-200ms per voice note.

Solution:
- Remove eager preparePlayer() call from init
- Load duration asynchronously on background queue
- Player is only prepared when playback is actually requested via ensurePlayerReady()

This prevents UI freezes when voice notes appear in the chat.

* Fix memory leaks and post-playback freeze

Fixes:
1. Post-playback freeze: audioPlayerDidFinishPlaying now dispatches to main
   thread before updating @Published properties (Swift concurrency violation)

2. Unbounded waveform cache: Implement LRU eviction with 20-entry limit
   - Track last access time for each cached waveform
   - Evict oldest entry when cache is full
   - Prevents unlimited memory growth as voice notes accumulate

3. Audio buffer memory leaks: Wrap computeWaveform in autoreleasepool
   - AVAudioPCMBuffer allocations are autoreleased
   - Pool ensures buffers are freed promptly

4. Image processing memory: Add autoreleasepool around compression loops
   - Each jpegData() call creates temporary objects
   - Inner pool per iteration prevents memory spikes during quality search

Memory should now remain stable during extended use.

* Eliminate disk I/O from SwiftUI view rendering path

Critical performance fix for UI freezes when receiving media:

Problem: mediaAttachment(for:) was called during every SwiftUI render,
performing synchronous disk I/O on main thread:
- FileManager.fileExists() called 2-6x per message (checking subdirs)
- applicationFilesDirectory() creating directories on every call
- With multiple media messages, this meant 20-100+ disk ops per render

Solution:
1. Remove fileExists checks - construct URLs directly
   - Files are validated during playback/display (fail gracefully if missing)
   - Sender determines subdirectory (outgoing vs incoming)

2. Cache applicationFilesDirectory() result
   - Static cache prevents repeated FileManager.url() calls
   - Directory created only once

3. Remove redundant playback.replaceURL() in VoiceNoteView.onAppear
   - Controller already initialized with correct URL

This eliminates ALL disk I/O from the view rendering hot path.

* Cache Nostr identity derivation to prevent crypto during view rendering

Critical performance fix:

Problem: formatMessageHeader() called deriveIdentity(forGeohash:) during
every SwiftUI render for every media message. Each call performed:
- Keychain I/O (getOrCreateDeviceSeed)
- HMAC-SHA256 computation
- Up to 10 secp256k1 key validations (elliptic curve crypto)

With multiple media messages, this resulted in 100s of milliseconds of
blocking crypto on main thread per render cycle.

Solution: Add thread-safe cache for derived identities
- Check cache before expensive crypto operations
- NSLock protects concurrent access
- Identity is deterministic per geohash, so caching is safe

This eliminates crypto from the hot rendering path.

* Cache geohash identity in ChatViewModel to prevent crypto during rendering

Additional optimization for location channels (voice notes are mesh-only,
but this helps with text message rendering in geohash channels):

- Add cachedGeohashIdentity to avoid deriveIdentity calls during rendering
- Check cache before falling back to crypto derivation
- Reduces main thread crypto work in location channels

* Make voice note loading completely lazy with deferred initialization

Aggressive performance optimization to prevent UI freezes:

Problem: Even with async loading, creating 10+ VoiceNotePlaybackController
instances simultaneously (when scrolling past multiple voice notes) spawned
20+ concurrent background tasks, potentially starving main thread.

Solution - Ultra-lazy loading:
1. VoiceNotePlaybackController.init() now does ZERO work
   - No duration loading
   - No player creation
   - Instant initialization

2. Duration loaded on-demand via public loadDuration() method
   - Called from VoiceNoteView.onAppear after 150ms delay
   - Reduced priority: .utility instead of .userInitiated
   - Guard prevents duplicate loading

3. Waveform loading also deferred 150ms
   - Gives UI time to settle after message appears
   - Prevents task storms when multiple voice notes appear

This spreads the work over time instead of all at once.

* Ensure /clear and panic triple-tap delete media files

Fix: /clear command and panicClearAllData() now properly delete media files

1. /clear (triple-tap on chat):
   - Deletes outgoing media (voice notes, images, files)
   - Conservative: only our sent media, preserves received media
   - Runs in background to avoid UI freeze

2. panicClearAllData() (triple-tap on bitchat/ header):
   - Deletes ALL media files (incoming + outgoing)
   - Removes entire files directory and recreates structure
   - Ensures complete data wipe for emergency scenarios

Both operations run async on .utility queue to prevent blocking UI.

* Fix infinite render loop and apply all security fixes

CRITICAL BUG FIX - Infinite Render Loop:

Root Cause: Duplicate view identity in ContentView.swift:368
  ForEach(messageItems) { item in  // Already uses item.id via Identifiable
      messageRow(...)
          .id(item.id)  //  REDUNDANT modifier caused identity re-evaluation loop
  }

When @Published properties updated, SwiftUI re-evaluated .id() → appeared as
'new' identity → triggered re-render → infinite loop. Caused UI freezes,
keyboard failures, and 100% CPU usage.

Fix: Remove redundant .id() modifier - ForEach already has stable identity.

PERFORMANCE FIXES:

1. Waveform Cache Deadlock (Waveform.swift)
   - Removed nested queue.async(barrier) on cache hits
   - Was causing task saturation and potential deadlocks

2. Async Send Pattern (ContentView.swift)
   - Clear input immediately, defer actual send to next runloop
   - Prevents blocking current event handler

3. Proper Swift Concurrency (VoiceNoteView.swift)
   - Switch from .onAppear + DispatchQueue to .task
   - Cleaner async/await pattern for loading

4. Remove Redundant objectWillChange (ChatViewModel.swift)
   - @Published already triggers updates automatically
   - Explicit send() was causing double update cycles

SECURITY FIXES (C1-C5, H1-H2):

C1. Path Traversal Protection (BLEService.swift)
    - Unicode normalization, null byte removal
    - Replace ALL path separators, reject dotfiles
    - Validate paths don't escape directory

C2. Integer Overflow (BitchatFilePacket.swift)
    - Use UInt64 for TLV parsing, safe Int conversion

C3. MIME Validation (BLEService.swift)
    - Whitelist: JPEG, PNG, GIF, WebP, M4A, MP3, WAV, OGG, PDF
    - Magic byte validation for all types
    - Lenient on M4A (platform variations)

C4. Compression Bomb (BinaryProtocol.swift)
    - Ratio validation <= 50,000:1
    - Defense-in-depth with 1MB size cap

C5. TOCTOU Race (ChatViewModel.swift)
    - Direct removeItem without fileExists check

H1. File Size Validation (ChatViewModel, ImageUtils)
    - Check attributes BEFORE Data(contentsOf:)
    - Prevents memory exhaustion

H2. Metadata Stripping (ImageUtils.swift)
    - Remove ALL metadata keys from JPEG encoding
    - Only compression quality set
    - Protects GPS/EXIF/device info privacy

RESULT:
 No render loops
 Works with Xcode debugger
 Voice notes display properly
 All security vulnerabilities fixed
 164 tests passing

Production ready.

* Complete all translations to 100% and fix auto-extraction

- Mark non-localizable strings with Text(verbatim:) to prevent extraction
- Update UI strings to lowercase per style guide (open, save, close, recording)
- Add complete translations for all 29 languages (194/194 strings at 100%)
- Remove empty/duplicate entries (@, bitchat/, Open, Recording %@)
- Add proper localization comments for all user-facing strings

* macOS: Focus message input on launch instead of nickname field

* Remove debug print statements from sendMessage

* Optimize voice note codec to 16 kHz / 20 kbps for smaller file sizes

- Reduce sample rate from 44.1 kHz to 16 kHz (telephony standard)
- Lower bitrate from 32 kbps to 20 kbps
- Results in ~37% file size reduction (~150 KB/min vs 240 KB/min)
- Increases max voice note length from 4.4 to 7 minutes over 1 MiB BLE limit
- Maintains excellent voice quality using native AAC-LC codec

* Fix critical security issues in fragment reassembly and file cleanup

Fragment Reassembly Race Condition (CRITICAL):
- Wrap all incomingFragments/fragmentMetadata access in collectionsQueue.sync
- Prevents concurrent modification crashes from multi-threaded access
- Minimizes lock contention by doing heavy work (reassembly/decode) outside locks
- Add upper bound check: reject fragments with total > 10,000 (DoS prevention)
- Add cumulative size validation before storing fragments (memory DoS prevention)

File Cleanup Path Traversal (CRITICAL):
- Use NSString.lastPathComponent to extract filename safely
- Prevents directory traversal attacks via malicious filenames
- Add path prefix validation before file deletion
- Now checks both incoming and outgoing directories (fixes disk leak)

Additional Protections:
- Fragment assemblies now limited by both count (128) and cumulative bytes (1MB)
- Explicit checks for "." and ".." filenames in cleanup
- Defense-in-depth: multiple validation layers

* Fix post-rebase compilation errors

- Remove duplicate NostrIdentityBridge and Bech32 from NostrIdentity.swift (now in separate files)
- Add caching to NostrIdentityBridge.deriveIdentity() for performance
- Remove duplicate NotificationStreamAssembler from BLEService.swift
- Remove duplicate function declarations in BLEService.swift
- Remove duplicate DeliveryStatusView and PaymentChipView from ContentView.swift
- Fix PeerID type conversions throughout (use .id for String, PeerID(str:) for wrapping)
- Update ContentView body to use main's simple VStack structure
- Fix NostrIdentityBridge instance method calls
- Remove privateChatView (replaced with sheet-based UI in main)

Build and tests passing (137/139 tests pass).

* Fix remaining compilation issues after rebase

- Fix PhotosUI import order (must be after platform imports)
- Fix Data.WritingOptions.atomic reference
- Add identity derivation caching to NostrIdentityBridge
- Fix all remaining PeerID type conversions in ChatViewModel
- Fix ContentView body structure to use main's VStack layout
- Fix PaymentChipView API usage (now uses PaymentType enum)

Build and tests now passing.

* Add proper availability checks for PhotosPickerItem

PhotosPickerItem requires iOS 16+ / macOS 13+ but canImport(PhotosUI)
succeeds on older macOS versions. Add compiler version check to ensure
PhotosPicker code only compiles when actually available.

This fixes CI build failures on older macOS environments.

* Limit PhotosPicker to iOS only to fix CI

PhotosPickerItem has SDK availability issues on macOS in CI.
Change PhotosPicker from canImport(PhotosUI) to os(iOS) only.

macOS users can still import images via file importer (.fileImporter).
This is actually cleaner as macOS file picker is more familiar to users.

Fixes CI build failures.

* Convert new tests to Swift Testing

* Fix compilation issue

* Add the missing `fileTransfer` case

* Explicitly list all Enum cases to get compile-time errors

* Revive lost `NotificationStreamAssembler` changes

* Allow file fragments to account for protocol overhead

* Simplify PR: Focus on audio+image, fix EXIF stripping, remove file transfers

- Fix critical EXIF privacy issue in iOS image processing
  - Both iOS and macOS now use CGImageDestination for metadata stripping
  - Shared encodeJPEG function ensures no GPS, camera, or metadata leaks

- Remove file transfer functionality to simplify PR scope
  - Deleted FileAttachmentView
  - Removed sendFileAttachment from ChatViewModel
  - Removed file picker UI from ContentView
  - Simplified attachment dialog to image only (iOS) or voice only

- Keep focused media features:
  - Voice recording and playback
  - Image sending with progressive reveal
  - Binary protocol for media transfer

* Improve camera UX: Direct camera access with camera icon

- Change paperclip icon to camera icon for clearer affordance
- Open camera directly on tap (no confirmation dialog)
- Add CameraPickerView wrapper for UIImagePickerController
- Remove PhotosPicker in favor of direct camera access
- Images still processed through ImageUtils with EXIF stripping
- Accessibility: Added 'Take photo' label

* Full-screen camera with photo library option

- Change to fullScreenCover for immersive camera experience
- Add action sheet with 'Take Photo' and 'Choose from Library' options
- Renamed ImagePickerView to support both camera and library sources
- Both options open full-screen for better UX
- Updated accessibility label to 'Add photo' (more accurate)

* Fix camera white bars with overFullScreen presentation

- Changed modalPresentationStyle from .fullScreen to .overFullScreen
- This should eliminate white bars at top/bottom on notched devices
- Explicitly set showsCameraControls and cameraOverlayView for camera mode

* Gesture-based photo access: Tap for library, long-press for camera

UX improvements:
- Tap camera icon → Photo library (common use case)
- Long press camera icon (0.3s) → Direct camera (quick photos)
- Removed action sheet entirely for cleaner flow
- Power users can long-press for instant camera access

This is more discoverable and eliminates an extra step in the UI.

* Simplify camera presentation to reduce frame errors

- Changed back to standard .fullScreen presentation
- Removed overFullScreen which was causing frame dimension errors
- Let iOS handle safe areas automatically (white bars are intentional)
- Reduces gesture gate timeout warnings

Note: White bars on notched devices are iOS default behavior for
UIImagePickerController. This respects safe areas for status bar
and home indicator. True edge-to-edge would require custom AVFoundation
camera implementation.

* Force dark mode on camera/picker for black safe area bars

- Set overrideUserInterfaceStyle = .dark on UIImagePickerController
- Changes white bars to black (much better looking)
- Camera controls and photo library also appear in dark mode
- Consistent dark appearance regardless of system settings

* Optimize sheet presentation for camera UI

- Force .large detent for maximum height
- Hide drag indicator for cleaner look
- Use ignoresSafeArea to give camera full space
- Should show complete flash button and controls

* Fix P1: Add DoS protections to PeerID fragment handler

Critical security fix addressing Codex review feedback:

The PeerID overload of handleFragment (which is actually called by
CoreBluetooth) was missing key safety checks that existed in the
String overload:

1. Added total <= 10000 check to prevent unbounded fragment counts
2. Added cumulative size check against FileTransferLimits before
   storing each fragment
3. Prevents memory exhaustion DoS attacks via malicious fragment streams

This ensures the actually-used code path has proper bounds checking.

* Fix decompression size limit to support max-sized file transfers

Root cause: BinaryProtocol.decode() was rejecting decompressed payloads
larger than maxPayloadBytes (1 MB), but TLV-encoded file transfers are
slightly larger due to metadata overhead.

Fixes:
- Changed decompression limit from maxPayloadBytes to maxFramedFileBytes
- This accounts for TLV overhead (~50 bytes) + binary protocol headers
- Now allows ~1.12 MB decompressed payloads (1 MB + overhead budget)

The failing test was:
- Creating 1 MB file content
- TLV encoding adds ~50 bytes (1,048,627 total)
- Compression reduces to ~1,084 bytes (highly repetitive data)
- During decode, decompression was rejecting the 1,048,627 byte output
- Now correctly allows it since 1,048,627 < 1,179,760 (maxFramedFileBytes)

All 154 tests now pass including 'Max-sized file transfer survives reassembly'

* Fix critical thread-safety crash in PeerID fragment handler

CRITICAL: The PeerID version of _handleFragment was accessing
incomingFragments dictionary without collectionsQueue synchronization,
causing crashes when multiple BLE threads processed fragments concurrently.

Crash stack trace pointed to line 3431 (dictionary subscript) with:
'doesNotRecognizeSelector' - classic concurrent mutation crash.

Fix:
- Wrapped ALL incomingFragments/fragmentMetadata access in
  collectionsQueue.sync(flags: .barrier)
- Matches the thread-safe pattern used in String version
- Separate cleanup into its own barrier block after reassembly
- Prevents concurrent dictionary mutations from multiple BLE threads

This is the same pattern as the String version (line 1128) which didn't crash.

* Remove Localizable.xcstrings formatting noise

The Localizable.xcstrings file had massive formatting-only changes
(spacing: 'key' vs 'key :') that added 50K+ lines to the PR diff.

This was just Xcode reformatting with no actual string changes.

Reverted to main's version to keep PR focused on actual code changes.

* Add macOS photo picker support

- Added MacImagePickerView with NSOpenPanel for macOS
- macOS shows photo.circle.fill icon (no camera hardware)
- Opens native file picker for images (.png, .jpeg, .heic)
- Images processed through ImageUtils with EXIF stripping
- Simple sheet with Select/Cancel buttons

Cross-platform photo sharing now works:
- iOS: Tap for library, long-press for camera
- macOS: Tap for file picker

* Add Localizable strings for camera and voice features

Xcode auto-generated localization strings for new UI elements:
- Camera/photo picker labels
- Voice recording UI strings
- Media attachment descriptions

These are legitimate new strings needed for the audio+image feature,
not just formatting changes.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: islam <2553451+qalandarov@users.noreply.github.com>
2025-10-18 20:55:33 +02:00
IslamandGitHub 10e3f574ab PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer (#755)
* PeerID 15/n: Bitchat Message & Packet accept in init

* PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer
2025-10-06 17:17:57 +02:00
IslamandGitHub f86ae5e2ea PeerID 14/n: Transport and its dependents (#753)
* Rearrange `Transport`’s properties and functions

* `NostrTransport`: group Transport-related code together

* `BLEService`: group Transport-related code together

* Extract `NotificationStreamAssembler` into a file

* Move private functions to a dedicated extension

* PeerID 14/n: `Transport` and its dependents
2025-10-06 11:02:20 +02:00
96c6fc0c0d Improve mesh relaying, presence, and DM robustness; signed public msgs; reachability UI; quicker announces; store-and-forward (#527)
* Sign public broadcasts; verify relayed messages via persisted signing keys; keep scheduled relays in sparse graphs and speed their jitter; persist announce signing key for offline auth; add short backoff after disconnect errors to reduce reconnect thrash

* Add connected vs reachable model: retain peers after link drop, expire after reachability window; expose all peers in snapshots; compute isReachable in UI; add meshReachable state and sorting; avoid removing peers on link events; notify UI on stale removals

* ContentView: handle new .meshReachable connection state in header icon switch (exhaustive switch fix)

* Logs: tag relayed announces as 'Reachable via mesh' and annotate public message logs with (direct|mesh) path for easier field analysis

* Fix syntax error: remove stray else/log inserted into writeOrEnqueue; keep logs clean

* UI: use 'point.3.connected.trianglepath.dotted' for mesh-reachable; change people count to include connected+reachable (exclude Nostr-only)

* UI: switch to 'point.3.filled.connected.trianglepath.dotted' for mesh-reachable icons in list and header

* Reachability: reduce retention to 21s for all peers (verified and unverified) to minimize stale presence

* mesh DMs/acks: route to reachable peers; queue READ/DELIVERED until handshake; add Transport.isPeerReachable; UI: hide offline non-mutuals; DM header: better name fallback + show transport + encryption icons; fix NostrTransport conformance

* Verification sheet: compute encryption status and fingerprint using short mesh ID mapping (fix 'not encrypted/handshake' for DMs with stable key)

* Announce cadence: faster discovery (4s), sparse 15±4s, dense 30±8s; initial 0.6s; post-subscribe 50ms; min-force 150ms; maintenance 5s; proactive announces on handshake + recent-traffic nudge

* Relay: increase broadcast TTL cap in sparse graphs to 6; tighten jitter for handshake (10–35ms) and directed (20–60ms) relays

* Range/robustness: store-and-forward for directed packets (15s) with flush on new links + periodic; announces: no subset + afterglow re-announce on first-seen; adaptive scanning: force ON when <=2 neighbors or recent traffic

* Fix warnings: remove unused msgID and unused mutable var in directed spool flush

* Announces: TTL 7 (sparse only) via RelayController; no fanout subset for announces; neighbor-change rebroadcast of last 2–3 announces. Fragments: faster pacing (5ms global, 4ms directed).

* Peer list: real-time icon updates by publishing snapshots on connectivity checks; add unread message indicator (envelope) next to peers with unread DMs

* UI: unread envelope uses orange; hasUnreadMessages checks Nostr conv key for peers with known Nostr pubkeys (geohash DM consistency)

* Logs/robustness: debounce disconnect notifications (1.5s), debounce 'reconnected' logs (2s), add weak-link cooldown after timeouts on very weak RSSI (<= -90)

* Peer icons: faster, accurate reachability\n\n- Run connectivity checks every maintenance tick (5s)\n- Publish peer snapshots on central unsubscribe for instant UI refresh\n- Lower inactivity timeout to 8s and disconnect debounce to 0.9s\n- Gate reachability on mesh-attached (>=1 direct link); no links => no reachable peers\n- Keep 21s retention for verified/unverified, but only when attached to mesh\n\nImproves list responsiveness when walking out of range and prevents stale 'reachable' states when isolated.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-26 17:28:08 +02:00
a7d5b2d7d9 Centralize remaining magic numbers; finalize logging hygiene across UI/BLE/Nostr (#522)
* docs(plans): add refactor plan; chore(config): introduce TransportConfig and use in BLEService/ChatViewModel/PrivateChatManager; feat: add PeerDisplayNameResolver and apply in BLEService; chore: make TransportPeerSnapshot Equatable/Hashable; perf: simplify read-receipt persistence (no synchronize)

* project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen)

* chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM)

* project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources

* chore(BLE): move connect/duty/announce constants to TransportConfig and reference them

* chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication

* chore(BLE): trim raw hex dump on central decode failure to length+prefix

* project: remove duplicate TransportConfig.swift entries from Sources build phases

* chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport

* chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel

* chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules

* chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays

* refactor(config): centralize remaining magic numbers and finalize log hygiene

Add comprehensive TransportConfig constants for UI, Nostr, and BLE; adopt across ChatViewModel, ContentView, BLEService, ShareViewController, and BitchatApp to remove scattered literals. Standardize Nostr lookbacks/limits, UI delays/animations, and BLE announce/duty-cycle/candidate caps. Preserve behavior while making tuning explicit and safe.

Highlights:\n- UI: animations, scroll throttle, long-message thresholds, batch stagger, color hue tuning, rate-limit buckets, read-receipt debounce, startup delays, share accept/dismiss windows, migration cutoff.\n- Nostr: short display length (8), conv-key prefix length (16), DM lookback (24h), geohash sample lookback/limits; consistent use throughout ChatViewModel.\n- BLE: dynamic RSSI defaults, announce intervals/base+jitter, duty cycles (dense/sparse), fragment/ingress lifetimes, expected write timings/spacing, recent packet window (30s/100), peer inactivity timeout; unified candidate caps (100).\n- Share: use constant dismiss delay; App: use constant share accept window.\n\nRisk/impact: behavior-equivalent with centralized knobs; easier to tune without code edits.

* project: add TransportConfig.swift to Share Extension target to fix build

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 21:18:51 +02:00
5fd9140ffa QR verification: live challenge/response over Noise; persistence, offline badges, and UX/perf polish (#510)
* QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header

* QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly

* QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now

* QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage

* QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist

* QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder)

* QR verification: speed + persistence + UX

- Inject live Noise into VerificationService; prewarm QR on app start
- Keep camera active; remove intermediate responder toast
- One-shot/dupe guards and deferred send on handshake
- Persist verified status immediately; standardize fingerprint (SHA-256)
- Show verified badge for offline favorites; mutual verification toast
- VERIFY sheet styling to match peer sheet; UI polish
- Logs to diagnose verified load + favorites mapping

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 23:19:58 +02:00
Mateusz MatoszkoandGitHub 7836daa6d3 Adds O(1) method for peer nickname retrieval (#450)
* Adds O(1) method for peerNickname retrieval

* Uses peerNickname method where possible
2025-08-17 21:29:15 +02:00
6fbf7eee25 Refactor/ble nostr boundaries (#449)
* Refactor: move Nostr embedding and TLVs out of BLE; add NostrEmbeddedBitChat, Packets, PeerIDUtils; centralize MessageDeduplicator; update ChatViewModel to use new helpers; remove AI_CONTEXT.md and CLAUDE.md

* Rename class to BLEService with compatibility alias; move mention parsing out of BLE; emit low-level BLE events to delegate; unify hex helpers; accept 64-hex in isPeerConnected; add PeerIDResolver

* Project: rename file to BLEService.swift and update Xcode project; keep typealias SimplifiedBluetoothService = BLEService for compatibility

* Remove SimplifiedBluetoothService alias; update app code to use BLEService explicitly

* Tests: rename MockSimplifiedBluetoothService to MockBLEService; update typealiases and Xcode project

* Docs: update comments to refer to BLEService (tests, protocol, noise service)

* Tests: rename SimplifiedBluetoothServiceTests to BLEServiceTests; update project references and class names

* Introduce Transport protocol; BLEService conforms; document delegate-only event pattern in BLEService; keep publishers internal for UnifiedPeerService

* Adopt Transport end-to-end: add TransportPeerSnapshot + publishers; BLEService maps to Transport snapshots; UnifiedPeerService consumes Transport; ChatViewModel holds Transport

* Fix Transport integration: replace getPeerFingerprint with getFingerprint(for:); update PrivateChatManager and CommandProcessor to use Transport; add BLEService.getFingerprint(for:); update PeerManager to use Transport

* Refactor transport and BLE/Nostr layers; unify UI events; fix MainActor isolation

- Rename SimplifiedBluetoothService to BLEService and slim responsibilities
- Introduce Transport protocol and peerEventsDelegate for UI updates
- Add NostrTransport and MessageRouter to route PM/read/favorite via BLE or Nostr
- Centralize TLVs, PeerID utils, and MessageDeduplicator outside BLE
- Update UnifiedPeerService and ChatViewModel to use Transport and delegate events
- Fix MainActor isolation: route delegate calls via Task on MainActor; update notifyUI helper
- Adjust related files and tests accordingly

* BLEService: remove internal publishers; switch to delegate-only events

- Drop legacy messages/peers/fullPeers publishers
- Provide lightweight peerSnapshotSubject only to satisfy Transport
- Rework publishFullPeerData to build snapshots from internal state and notify delegate + subject
- Remove all peersPublisher.send call sites
- Keep UnifiedPeerService on delegate updates exclusively

* Remove inlined Nostr send helpers from ChatViewModel; route via MessageRouter

- Replace direct Nostr sends (PM, ACKs, favorites) with MessageRouter
- Add router method for delivery ACKs and implement NostrTransport.sendDeliveryAck
- Simplify ChatViewModel favorite notification path to use router
- Keep Nostr receive handling intact; reduce duplication

* Fix ReadReceipt initializer usage in ChatViewModel (readerID + readerNickname)

* Fix unused variable warning: replace shadowed 'nostrPubkey' bind with boolean check in ChatViewModel

* Fix queued PM format: use TLV for pending messages after Noise handshake

- Pending messages (including first-time favorite notifications) now use the same TLV encoding as normal sends
- Ensures ChatViewModel can decode on first send, even if handshake completes after queuing

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-17 15:17:04 +02:00