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>
This commit is contained in:
jack
2026-07-08 09:23:16 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent eacd8f0750
commit 78a291ab77
21 changed files with 762 additions and 141 deletions
+7
View File
@@ -12,6 +12,9 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String @Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false @Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true @Published private(set) var canSendMediaInCurrentContext = true
/// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel private let privateConversationModel: PrivateConversationModel
@@ -186,6 +189,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic) .assign(to: &$isBatchingPublic)
chatViewModel.$activePublicVoiceTalker
.receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker)
conversations.$activeChannel conversations.$activeChannel
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] channel in .sink { [weak self] channel in
+16 -4
View File
@@ -35,6 +35,9 @@ final class PTTCaptureEngine {
private var encodedFrameCount = 0 private var encodedFrameCount = 0
private var running = false private var running = false
private var captureStart = Date() private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture (main-actor
/// callers only; see `stopEngineIfStarted`).
private var engineStarted = false
/// Called on the capture queue with each batch of encoded AAC frames. /// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)? var onFrames: (([Data]) -> Void)?
@@ -91,14 +94,14 @@ final class PTTCaptureEngine {
queue.sync { self.teardown(deleteFile: true) } queue.sync { self.teardown(deleteFile: true) }
throw error throw error
} }
engineStarted = true
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session) SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
} }
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the /// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long). /// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
func stop() -> (url: URL?, encodedFrames: Int) { func stop() -> (url: URL?, encodedFrames: Int) {
engine.inputNode.removeTap(onBus: 0) stopEngineIfStarted()
engine.stop()
let result: (URL?, Int) = queue.sync { let result: (URL?, Int) = queue.sync {
let url = fileURL let url = fileURL
let frames = encodedFrameCount let frames = encodedFrameCount
@@ -112,14 +115,23 @@ final class PTTCaptureEngine {
} }
func cancel() { func cancel() {
engine.inputNode.removeTap(onBus: 0) stopEngineIfStarted()
engine.stop()
queue.sync { teardown(deleteFile: true) } queue.sync { teardown(deleteFile: true) }
#if os(iOS) #if os(iOS)
Self.deactivateAudioSession() Self.deactivateAudioSession()
#endif #endif
} }
/// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine.
private func stopEngineIfStarted() {
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
// MARK: - Capture queue // MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer) { private func process(_ buffer: AVAudioPCMBuffer) {
+237 -58
View File
@@ -4310,175 +4310,175 @@
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• اضغط أيقونة الأشخاص لفتح الشريط الجانبي" "value" : "• اضغط أيقونة الأشخاص لفتح القائمة"
} }
}, },
"bn" : { "bn" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• সাইডবার খুলতে মানুষ আইকনে ট্যাপ করুন" "value" : "• তালিকা খুলতে মানুষ আইকনে ট্যাপ করুন"
} }
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tippe auf das personen-icon, um die seitenleiste zu öffnen" "value" : "• tippe auf das personen-icon für die liste"
} }
}, },
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tap people icon for sidebar" "value" : "• tap people icon for list"
} }
}, },
"es" : { "es" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• toca el ícono de personas para abrir la barra lateral" "value" : "• toca el ícono de personas para ver la lista"
} }
}, },
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• i-tap ang icon ng tao para buksan ang sidebar" "value" : "• i-tap ang icon ng tao para sa listahan"
} }
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tape sur l'icône personnes pour ouvrir la barre latérale" "value" : "• tape sur l'icône personnes pour la liste"
} }
}, },
"he" : { "he" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• הקש על אייקון האנשים כדי לפתוח סרגל צד" "value" : "• הקש על אייקון האנשים כדי לפתוח את הרשימה"
} }
}, },
"hi" : { "hi" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• साइडबार खोलने के लिए लोगों वाले आइकन पर टैप करें" "value" : "• सूची खोलने के लिए लोगों वाले आइकन पर टैप करें"
} }
}, },
"id" : { "id" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• ketuk ikon orang untuk membuka sidebar" "value" : "• ketuk ikon orang untuk membuka daftar"
} }
}, },
"it" : { "it" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tocca l'icona persone per aprire la barra laterale" "value" : "• tocca l'icona persone per aprire la lista"
} }
}, },
"ja" : { "ja" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• 人アイコンをタップしてサイドバーを開く" "value" : "• 人アイコンをタップして一覧を開く"
} }
}, },
"ko" : { "ko" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• 사람 아이콘을 탭하여 사이드바를 엽니다" "value" : "• 사람 아이콘을 탭하여 목록을 엽니다"
} }
}, },
"ms" : { "ms" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• ketuk ikon orang untuk membuka sidebar" "value" : "• ketuk ikon orang untuk membuka senarai"
} }
}, },
"ne" : { "ne" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• साइडबार खोल्न मान्छे आइकन ट्याप गर" "value" : "• सूची खोल्न मान्छे आइकन ट्याप गर"
} }
}, },
"nl" : { "nl" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tik op het personen-icoon om de zijbalk te openen" "value" : "• tik op het personen-icoon voor de lijst"
} }
}, },
"pl" : { "pl" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• stuknij ikonę osób, aby otworzyć panel boczny" "value" : "• stuknij ikonę osób, aby otworzyć listę"
} }
}, },
"pt" : { "pt" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• toca no ícone das pessoas para abrir a barra lateral" "value" : "• toca no ícone das pessoas para abrir a lista"
} }
}, },
"pt-BR" : { "pt-BR" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• toque o ícone de pessoas para abrir a barra lateral" "value" : "• toque o ícone de pessoas para abrir a lista"
} }
}, },
"ru" : { "ru" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• нажми на иконку людей, чтобы открыть боковое меню" "value" : "• нажми на иконку людей, чтобы открыть список"
} }
}, },
"sv" : { "sv" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tryck på personikonen för att öppna sidomenyn" "value" : "• tryck på personikonen för att öppna listan"
} }
}, },
"ta" : { "ta" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• பக்கப்பட்டியைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்" "value" : "• பட்டியைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்"
} }
}, },
"th" : { "th" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• แตะไอคอนคนเพื่อเปิดแถบด้านข้าง" "value" : "• แตะไอคอนคนเพื่อเปิดรายชื่อ"
} }
}, },
"tr" : { "tr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• kenar çubuğunu açmak için insan simgesine dokunun" "value" : "• listeyi açmak için insan simgesine dokunun"
} }
}, },
"uk" : { "uk" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• торкни піктограму людей, щоб відкрити бічну панель" "value" : "• торкни піктограму людей, щоб відкрити список"
} }
}, },
"ur" : { "ur" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• سائیڈ بار کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں" "value" : "• فہرست کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں"
} }
}, },
"vi" : { "vi" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• chạm biểu tượng người để mở thanh bên" "value" : "• chạm biểu tượng người để mở danh sách"
} }
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• 轻点人物图标打开侧栏" "value" : "• 轻点人物图标打开列表"
} }
}, },
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• 輕點人物圖示打開側欄" "value" : "• 輕點人物圖示打開列表"
} }
} }
} }
@@ -4668,175 +4668,175 @@
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• اضغط اسم القرين لبدء رسائل خاصة" "value" : "• اضغط اسم شخص لبدء رسائل خاصة"
} }
}, },
"bn" : { "bn" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• ডিএম শুরু করতে পিয়ারের নাম ট্যাপ করুন" "value" : "• ডিএম শুরু করতে কোনো ব্যক্তির নাম ট্যাপ করুন"
} }
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tippe auf den namen eines peers, um eine pn zu starten" "value" : "• tippe auf den namen einer person, um eine pn zu starten"
} }
}, },
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tap a peer's name to start a DM" "value" : "• tap a person's name to start a DM"
} }
}, },
"es" : { "es" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• toca el nombre de un participante para iniciar un MD" "value" : "• toca el nombre de una persona para iniciar un MD"
} }
}, },
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• i-tap ang pangalan ng peer para magsimula ng DM" "value" : "• i-tap ang pangalan ng isang tao para magsimula ng DM"
} }
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tape sur le nom d'un pair pour démarrer un mp" "value" : "• tape sur le nom d'une personne pour démarrer un mp"
} }
}, },
"he" : { "he" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• הקש על שם עמית כדי להתחיל הודעה פרטית" "value" : "• הקש על שם של אדם כדי להתחיל הודעה פרטית"
} }
}, },
"hi" : { "hi" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• किसी पीयर का नाम टैप करके DM शुरू करें" "value" : "• किसी व्यक्ति का नाम टैप करके DM शुरू करें"
} }
}, },
"id" : { "id" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• ketuk nama peer untuk mulai dm" "value" : "• ketuk nama seseorang untuk mulai dm"
} }
}, },
"it" : { "it" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tocca il nome di un peer per avviare un dm" "value" : "• tocca il nome di una persona per avviare un dm"
} }
}, },
"ja" : { "ja" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• ピアの名前をタップしてdm開始" "value" : "• の名前をタップしてdm開始"
} }
}, },
"ko" : { "ko" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• 피어의 이름을 탭하여 DM을 시작합니다" "value" : "• 상대방의 이름을 탭하여 DM을 시작합니다"
} }
}, },
"ms" : { "ms" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• ketuk nama peer untuk mulai dm" "value" : "• ketuk nama seseorang untuk mulai dm"
} }
}, },
"ne" : { "ne" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर" "value" : "• dm सुरु गर्न कुनै व्यक्तिको नाम ट्याप गर"
} }
}, },
"nl" : { "nl" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tik op de naam van een peer om een DM te starten" "value" : "• tik op de naam van een persoon om een DM te starten"
} }
}, },
"pl" : { "pl" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• stuknij nazwę peera, aby rozpocząć DM" "value" : "• stuknij czyjeś imię, aby rozpocząć DM"
} }
}, },
"pt" : { "pt" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• toca no nome de um par para iniciar um DM" "value" : "• toca no nome de uma pessoa para iniciar um DM"
} }
}, },
"pt-BR" : { "pt-BR" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• toque o nome de um par para iniciar um dm" "value" : "• toque o nome de uma pessoa para iniciar um dm"
} }
}, },
"ru" : { "ru" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• нажми имя пользователя, чтобы начать лс" "value" : "• нажми имя человека, чтобы начать лс"
} }
}, },
"sv" : { "sv" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• tryck på en peers namn för att starta ett DM" "value" : "• tryck på en persons namn för att starta ett DM"
} }
}, },
"ta" : { "ta" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• peer பெயரைத் தட்டி DM தொடங்கவும்" "value" : "• ஒருவரின் பெயரைத் தட்டி DM தொடங்கவும்"
} }
}, },
"th" : { "th" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• แตะชื่อเพียร์เพื่อเริ่ม DM" "value" : "• แตะชื่อบุคคลเพื่อเริ่ม DM"
} }
}, },
"tr" : { "tr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• bir in adına dokunarak DM başlatın" "value" : "• bir kişinin adına dokunarak DM başlatın"
} }
}, },
"uk" : { "uk" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• торкни ім'я піра, щоб почати приватний чат" "value" : "• торкни ім'я людини, щоб почати приватний чат"
} }
}, },
"ur" : { "ur" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• DM شروع کرنے کیلئے کسی ہم منصب کے نام پر ٹیپ کریں" "value" : "• DM شروع کرنے کیلئے کسی شخص کے نام پر ٹیپ کریں"
} }
}, },
"vi" : { "vi" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• chạm tên một nút ngang hàng để mở DM" "value" : "• chạm tên một người để mở DM"
} }
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• 轻点同伴名字开始 dm" "value" : "• 轻点某人名字开始 dm"
} }
}, },
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "• 輕點同伴名字開始 dm" "value" : "• 輕點某人名字開始 dm"
} }
} }
} }
@@ -17967,6 +17967,185 @@
} }
} }
}, },
"content.accessibility.someone_speaking" : {
"comment" : "Accessibility value on the mic button naming who is talking live in the public channel",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ يتحدث الآن"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ কথা বলছেন"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ spricht gerade"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ is speaking"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ está hablando"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "nagsasalita si %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ est en train de parler"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ מדבר כעת"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ बोल रहे हैं"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ sedang berbicara"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ sta parlando"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@が話しています"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ 님이 말하는 중"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ sedang bercakap"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ बोल्दै हुनुहुन्छ"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ is aan het spreken"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ mówi"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ está a falar"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ está falando"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ говорит"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ pratar"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ பேசுகிறார்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ กำลังพูด"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ konuşuyor"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ говорить"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ بول رہے ہیں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ đang nói"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ 正在讲话"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ 正在說話"
}
}
}
},
"content.accessibility.take_photo" : { "content.accessibility.take_photo" : {
"comment" : "Accessibility action name for taking a photo with the camera", "comment" : "Accessibility action name for taking a photo with the camera",
"extractionState" : "manual", "extractionState" : "manual",
+7
View File
@@ -130,6 +130,9 @@ protocol BitchatDelegate: AnyObject {
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator) // Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
func didReceiveGroupMessage(payload: Data, timestamp: Date) func didReceiveGroupMessage(payload: Data, timestamp: Date)
// Public live-voice burst packet (signature-verified by the transport)
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications // Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState) func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -153,6 +156,10 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation // Default empty implementation
} }
@@ -12,7 +12,10 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage: // voiceFrame is deliberately unpadded: padding to the 512 block would
// push every ~490-byte signed voice packet over the MTU into the
// fragment path.
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
return false return false
} }
} }
@@ -70,6 +70,7 @@ struct BLEReceivePipeline {
// announce-class TTL headroom so alerts travel the extra hop. // announce-class TTL headroom so alerts travel the extra hop.
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
&& BoardWire.urgentFlag(in: packet.payload), && BoardWire.urgentFlag(in: packet.payload),
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
degree: degree, degree: degree,
highDegreeThreshold: highDegreeThreshold highDegreeThreshold: highDegreeThreshold
) )
+79
View File
@@ -1477,6 +1477,34 @@ final class BLEService: NSObject {
} }
} }
/// Broadcasts one live voice-burst packet to the public mesh, signed like
/// a public message so receivers can authenticate the talker. Ephemeral:
/// never tracked for gossip sync (stale audio is worthless to replay).
func sendVoiceFrameBroadcast(_ burstContent: Data) {
guard !burstContent.isEmpty else { return }
messageQueue.async { [weak self] in
guard let self else { return }
let packet = BitchatPacket(
type: MessageType.voiceFrame.rawValue,
senderID: self.myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: burstContent,
signature: nil,
ttl: self.messageTTL
)
guard let signedPacket = self.noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling a
// relayed self copy.
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
self.messageDeduplicator.markProcessed(dedupID)
self.broadcastPacket(signedPacket)
}
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
// Appends to the encryption service's handler array, so this never // Appends to the encryption service's handler array, so this never
// displaces the callbacks installed by installNoiseSessionCallbacks. // displaces the callbacks installed by installNoiseSessionCallbacks.
@@ -3985,6 +4013,11 @@ extension BLEService {
case .nostrCarrier: case .nostrCarrier:
handleNostrCarrier(packet, from: peerID) handleNostrCarrier(packet, from: peerID)
case .voiceFrame:
// Rejected frames (unsigned/stale/spoofed) must not spread; skip
// the relay step below, like invalid board posts.
guard handleVoiceFrame(packet, from: senderID) else { return }
case .ping: case .ping:
// Rate limiting must key on the ingress link (`peerID`), not the // Rate limiting must key on the ingress link (`peerID`), not the
// packet-claimed sender: pings are unsigned, so `senderID` is // packet-claimed sender: pings are unsigned, so `senderID` is
@@ -4333,6 +4366,52 @@ extension BLEService {
} }
} }
/// Inbound public live-voice packet: broadcast-only, freshness-gated, and
/// signature-verified against the claimed sender's announce (mirrors the
/// public-message identity gate `senderID` is attacker-controlled, so a
/// valid packet signature is required before any audio reaches the UI).
/// Returns whether the packet was accepted; rejected packets must not be
/// relayed either, or spoofed 0x29 floods would still amplify.
private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
guard peerID != myPeerID else { return false }
guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false }
guard !BLEPacketFreshnessPolicy.isStale(
timestampMilliseconds: packet.timestamp,
now: Date(),
maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds
) else { return false }
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false
let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID)
guard verifiedViaRegistry || signedDisplayName != nil else {
SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))", category: .security)
return false
}
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? signedDisplayName else {
return false
}
let payload = packet.payload
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.publicVoiceFrameReceived(
peerID: peerID,
nickname: senderNickname,
payload: payload,
timestamp: timestamp
))
}
return true
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
noisePacketHandler.handleHandshake(packet, from: peerID) noisePacketHandler.handleHandshake(packet, from: peerID)
} }
+6 -1
View File
@@ -20,6 +20,7 @@ struct RelayController {
isAnnounce: Bool, isAnnounce: Bool,
isRequestSync: Bool = false, isRequestSync: Bool = false,
isUrgentBoardPost: Bool = false, isUrgentBoardPost: Bool = false,
isVoiceFrame: Bool = false,
degree: Int, degree: Int,
highDegreeThreshold: Int) -> RelayDecision { highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault) let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
@@ -46,7 +47,11 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
if isFragment { // Live voice floods with the fragment policy: the dense clamp
// contains the sustained ~15 pkt/s per-talker stream, and the tight
// jitter window keeps per-hop latency inside the receiver's ~350 ms
// jitter buffer across multi-hop paths.
if isFragment || isVoiceFrame {
// Dense graphs clamp harder to contain full-fanout fragment floods; // Dense graphs clamp harder to contain full-fanout fragment floods;
// sparse graphs get full depth so media reaches as far as text. // sparse graphs get full depth so media reaches as far as text.
let fragmentCap = degree >= highDegreeThreshold let fragmentCap = degree >= highDegreeThreshold
+8
View File
@@ -72,6 +72,9 @@ enum TransportEvent: @unchecked Sendable {
/// Encrypted group broadcast (MessageType 0x25). Opaque here the group /// Encrypted group broadcast (MessageType 0x25). Opaque here the group
/// coordinator decrypts and authenticates against the roster. /// coordinator decrypts and authenticates against the roster.
case groupMessageReceived(payload: Data, timestamp: Date) case groupMessageReceived(payload: Data, timestamp: Date)
/// Public live-voice burst packet (MessageType 0x29), already
/// signature-verified against the claimed sender.
case publicVoiceFrameReceived(peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
case peerConnected(PeerID) case peerConnected(PeerID)
case peerDisconnected(PeerID) case peerDisconnected(PeerID)
case peerListUpdated([PeerID]) case peerListUpdated([PeerID])
@@ -160,6 +163,8 @@ protocol Transport: AnyObject {
// only useful now transports drop them (never queue) when no // only useful now transports drop them (never queue) when no
// established session exists. // established session exists.
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
// Public-mesh counterpart: signed ephemeral broadcast, never synced.
func sendVoiceFrameBroadcast(_ burstContent: Data)
// Courier store-and-forward (mesh transports only): seal a message to the // Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical // recipient's static key and hand it to connected couriers for physical
@@ -238,6 +243,7 @@ extension Transport {
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {} func sendBoardPayload(_ payload: Data) {}
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {} func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {}
func sendVoiceFrameBroadcast(_ burstContent: Data) {}
// Mesh diagnostics are mesh-transport-only; other transports report // Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything. // "no reply"/"no path" rather than pretending to measure anything.
@@ -280,6 +286,8 @@ extension BitchatDelegate {
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp) didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
case let .groupMessageReceived(payload, timestamp): case let .groupMessageReceived(payload, timestamp):
didReceiveGroupMessage(payload: payload, timestamp: timestamp) didReceiveGroupMessage(payload: payload, timestamp: timestamp)
case let .publicVoiceFrameReceived(peerID, nickname, payload, timestamp):
didReceivePublicVoiceFrame(from: peerID, nickname: nickname, payload: payload, timestamp: timestamp)
case .peerConnected(let peerID): case .peerConnected(let peerID):
didConnectToPeer(peerID) didConnectToPeer(peerID)
case .peerDisconnected(let peerID): case .peerDisconnected(let peerID):
+3
View File
@@ -34,6 +34,9 @@ enum TransportConfig {
// Inbound flood guard: a real burst arrives at ~2KB/s; allow 3x plus a // Inbound flood guard: a real burst arrives at ~2KB/s; allow 3x plus a
// small settling allowance before dropping a sender's frames. // small settling allowance before dropping a sender's frames.
static let pttInboundMaxBytesPerSecond: Int = 6_000 static let pttInboundMaxBytesPerSecond: Int = 6_000
// Public bursts are live-only traffic: frames older than this are relay
// stragglers or replays, not audio anyone should start hearing.
static let pttPublicFrameMaxAgeSeconds: TimeInterval = 30
// Mesh diagnostics (/ping) // Mesh diagnostics (/ping)
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
+3
View File
@@ -54,6 +54,9 @@ struct SyncTypeFlags: OptionSet {
// downlinks are rate-budgeted rebroadcasts); replaying them via sync // downlinks are rate-budgeted rebroadcasts); replaying them via sync
// would waste airtime and extend their lifetime. // would waste airtime and extend their lifetime.
case .nostrCarrier: return nil case .nostrCarrier: return nil
// Live voice is only useful now; replaying stale audio frames via
// sync would waste airtime (receivers drop them as stale anyway).
case .voiceFrame: return nil
// Prekey bundles gossip like board posts. The bitfield is a // Prekey bundles gossip like board posts. The bitfield is a
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
// ignored by `type(forBit:)`), so bits 8+ need no format change: old // ignored by `type(forBit:)`), so bits 8+ need no format change: old
+131 -24
View File
@@ -11,19 +11,58 @@ import Foundation
protocol ChatLiveVoiceContext: AnyObject { protocol ChatLiveVoiceContext: AnyObject {
var nickname: String { get } var nickname: String { get }
var selectedPrivateChatPeer: PeerID? { get } var selectedPrivateChatPeer: PeerID? { get }
/// Whether the public mesh timeline is what's on screen (autoplay gate
/// for public bursts).
var isViewingPublicMeshTimeline: Bool { get }
func isPeerBlocked(_ peerID: PeerID) -> Bool func isPeerBlocked(_ peerID: PeerID) -> Bool
func resolveNickname(for peerID: PeerID) -> String func resolveNickname(for peerID: PeerID) -> String
/// Routes an inbound private message through the full pipeline /// Routes an inbound private message through the full pipeline
/// (store append, unread state, notification, read receipt). /// (store append, unread state, notification, read receipt).
func handlePrivateMessage(_ message: BitchatMessage) func handlePrivateMessage(_ message: BitchatMessage)
/// Appends directly to the public mesh timeline, bypassing the batched
/// public pipeline: a live bubble must be removable when its burst is
/// canceled or empty, which a pipeline-buffered entry is not (it would
/// re-commit at the next flush).
func appendPublicMeshMessage(_ message: BitchatMessage)
/// Replace-or-append by message ID via the single-writer store intent. /// Replace-or-append by message ID via the single-writer store intent.
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID)
/// Replace-or-append by message ID in the public mesh timeline.
func upsertPublicMeshMessage(_ message: BitchatMessage)
@discardableResult @discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? func removePrivateMessage(withID messageID: String) -> BitchatMessage?
/// Removes a message from whichever conversation holds it.
func removeMessage(withID messageID: String, cleanupFile: Bool)
/// Publishes who is currently talking live in the public mesh channel
/// (floor-courtesy indicator on the composer mic), nil when nobody is.
func setActivePublicVoiceTalker(_ nickname: String?)
func notifyUIChanged() func notifyUIChanged()
} }
extension ChatViewModel: ChatLiveVoiceContext {} extension ChatViewModel: ChatLiveVoiceContext {
var isViewingPublicMeshTimeline: Bool {
selectedPrivateChatPeer == nil && activeChannel == .mesh
}
func appendPublicMeshMessage(_ message: BitchatMessage) {
_ = appendPublicMessage(message, to: ConversationID(channelID: .mesh))
}
func upsertPublicMeshMessage(_ message: BitchatMessage) {
conversations.upsertByID(message, in: ConversationID(channelID: .mesh))
}
func setActivePublicVoiceTalker(_ nickname: String?) {
if activePublicVoiceTalker != nickname {
activePublicVoiceTalker = nickname
}
}
}
/// Where a live voice burst lives: a Noise DM or the public mesh timeline.
enum VoiceBurstScope: Equatable {
case directMessage
case publicMesh
}
/// Assembles inbound live push-to-talk bursts (`NoisePayloadType.voiceFrame`): /// Assembles inbound live push-to-talk bursts (`NoisePayloadType.voiceFrame`):
/// orders packets behind a jitter window, persists frames progressively as an /// orders packets behind a jitter window, persists frames progressively as an
@@ -36,6 +75,8 @@ final class ChatLiveVoiceCoordinator {
private final class Assembly { private final class Assembly {
let burstID: Data let burstID: Data
let peerID: PeerID let peerID: PeerID
let scope: VoiceBurstScope
let nickname: String
let message: BitchatMessage let message: BitchatMessage
var messageID: String { message.id } var messageID: String { message.id }
var messageTimestamp: Date { message.timestamp } var messageTimestamp: Date { message.timestamp }
@@ -56,9 +97,11 @@ final class ChatLiveVoiceCoordinator {
var idleTimeout: Task<Void, Never>? var idleTimeout: Task<Void, Never>?
var gapRedrain: Task<Void, Never>? var gapRedrain: Task<Void, Never>?
init(burstID: Data, peerID: PeerID, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) { init(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) {
self.burstID = burstID self.burstID = burstID
self.peerID = peerID self.peerID = peerID
self.scope = scope
self.nickname = nickname
self.message = message self.message = message
self.fileURL = fileURL self.fileURL = fileURL
self.fileHandle = fileHandle self.fileHandle = fileHandle
@@ -69,6 +112,7 @@ final class ChatLiveVoiceCoordinator {
private struct FinishedBurst { private struct FinishedBurst {
let messageID: String let messageID: String
let peerID: PeerID let peerID: PeerID
let scope: VoiceBurstScope
let fileURL: URL let fileURL: URL
let messageTimestamp: Date let messageTimestamp: Date
let expiresAt: Date let expiresAt: Date
@@ -88,11 +132,25 @@ final class ChatLiveVoiceCoordinator {
// MARK: - Inbound frames // MARK: - Inbound frames
/// Inbound DM burst packet (`NoisePayloadType.voiceFrame`).
func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) {
handle(payload, from: peerID, scope: .directMessage, nickname: context.resolveNickname(for: peerID), timestamp: timestamp)
}
/// Inbound public burst packet (`MessageType.voiceFrame`), already
/// signature-verified by the transport, which resolved the nickname.
func handlePublicVoiceFramePayload(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
handle(payload, from: peerID, scope: .publicMesh, nickname: nickname, timestamp: timestamp)
}
private func handle(_ payload: Data, from peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) {
// Live voice off means classic-notes-only in both directions: no live // Live voice off means classic-notes-only in both directions: no live
// bubble, no partial file, no early notification the finalized // bubble, no partial file, no early notification the finalized
// voice note still arrives through the normal pipeline. // voice note still arrives through the normal pipeline.
guard PTTSettings.liveVoiceEnabled else { return } guard PTTSettings.liveVoiceEnabled else {
SecureLogger.debug("PTT: dropping inbound voice frame — live voice is toggled off", category: .session)
return
}
guard let packet = VoiceBurstPacket.decode(payload) else { guard let packet = VoiceBurstPacket.decode(payload) else {
SecureLogger.warning("PTT: undecodable voice frame from \(peerID.id.prefix(8))… (\(payload.count) bytes: \(payload.prefix(16).hexEncodedString())…)", category: .session) SecureLogger.warning("PTT: undecodable voice frame from \(peerID.id.prefix(8))… (\(payload.count) bytes: \(payload.prefix(16).hexEncodedString())…)", category: .session)
return return
@@ -103,23 +161,25 @@ final class ChatLiveVoiceCoordinator {
} }
if let assembly = assemblies[packet.burstID] { if let assembly = assemblies[packet.burstID] {
// The sender is Noise-authenticated; a different peer reusing the // The sender is authenticated (Noise session or packet
// same burst ID is a collision or a replay drop it. // signature); a different peer or scope reusing the same burst
guard assembly.peerID == peerID else { return } // ID is a collision or a replay drop it.
guard assembly.peerID == peerID, assembly.scope == scope else { return }
apply(packet, to: assembly) apply(packet, to: assembly)
return return
} }
switch packet.kind { switch packet.kind {
case .start, .frames: case .start, .frames:
// A data packet with no prior START (lost or mid-burst state) // A data packet with no prior START (lost or mid-burst join)
// still opens the assembly with the default codec. // still opens the assembly with the default codec.
guard assemblies.count < TransportConfig.pttMaxConcurrentAssemblies else { guard assemblies.count < TransportConfig.pttMaxConcurrentAssemblies else {
SecureLogger.debug("PTT: dropping burst from \(peerID.id.prefix(8))… — assembly cap reached", category: .session) SecureLogger.debug("PTT: dropping burst from \(peerID.id.prefix(8))… — assembly cap reached", category: .session)
return return
} }
guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, timestamp: timestamp) else { return } guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return }
assemblies[packet.burstID] = assembly assemblies[packet.burstID] = assembly
updatePublicTalkerIndicator()
apply(packet, to: assembly) apply(packet, to: assembly)
case .end, .canceled: case .end, .canceled:
// Control packet for a burst we never saw nothing to do. // Control packet for a burst we never saw nothing to do.
@@ -151,8 +211,9 @@ final class ChatLiveVoiceCoordinator {
pruneFinishedBursts() pruneFinishedBursts()
guard let finished = finishedBursts[burstID] else { return false } guard let finished = finishedBursts[burstID] else { return false }
// Bind the note to the burst's authenticated sender. // Bind the note to the burst's authenticated sender and scope.
guard message.senderPeerID == nil || message.senderPeerID == finished.peerID else { return false } guard message.senderPeerID == nil || message.senderPeerID == finished.peerID else { return false }
guard message.isPrivate == (finished.scope == .directMessage) else { return false }
let replacement = BitchatMessage( let replacement = BitchatMessage(
id: finished.messageID, id: finished.messageID,
@@ -161,13 +222,18 @@ final class ChatLiveVoiceCoordinator {
timestamp: finished.messageTimestamp, timestamp: finished.messageTimestamp,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: finished.scope == .directMessage,
recipientNickname: context.nickname, recipientNickname: finished.scope == .directMessage ? context.nickname : nil,
senderPeerID: finished.peerID, senderPeerID: finished.peerID,
mentions: nil, mentions: nil,
deliveryStatus: message.deliveryStatus deliveryStatus: message.deliveryStatus
) )
context.upsertPrivateMessage(replacement, in: finished.peerID) switch finished.scope {
case .directMessage:
context.upsertPrivateMessage(replacement, in: finished.peerID)
case .publicMesh:
context.upsertPublicMeshMessage(replacement)
}
// The complete .m4a replaces the partial live capture. // The complete .m4a replaces the partial live capture.
WaveformCache.shared.purge(url: finished.fileURL) WaveformCache.shared.purge(url: finished.fileURL)
@@ -181,7 +247,7 @@ final class ChatLiveVoiceCoordinator {
// MARK: - Assembly lifecycle // MARK: - Assembly lifecycle
private func makeAssembly(burstID: Data, peerID: PeerID, timestamp: Date) -> Assembly? { private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? {
guard let fileURL = Self.makeIncomingURL(burstID: burstID) else { guard let fileURL = Self.makeIncomingURL(burstID: burstID) else {
SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session) SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session)
return nil return nil
@@ -193,33 +259,46 @@ final class ChatLiveVoiceCoordinator {
return nil return nil
} }
let isPrivate = scope == .directMessage
let message = BitchatMessage( let message = BitchatMessage(
sender: context.resolveNickname(for: peerID), sender: nickname,
content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)", content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)",
timestamp: timestamp, timestamp: timestamp,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: isPrivate,
recipientNickname: context.nickname, recipientNickname: isPrivate ? context.nickname : nil,
senderPeerID: peerID senderPeerID: peerID
) )
let assembly = Assembly( let assembly = Assembly(
burstID: burstID, burstID: burstID,
peerID: peerID, peerID: peerID,
scope: scope,
nickname: nickname,
message: message, message: message,
fileURL: fileURL, fileURL: fileURL,
fileHandle: handle fileHandle: handle
) )
// Full inbound pipeline: store append, unread, notification. // DM bubbles ride the full inbound pipeline (store append, unread,
context.handlePrivateMessage(message) // notification). Public bubbles append directly to the store: the
// batched public pipeline can't purge a buffered entry if the burst
// is canceled before the flush.
switch scope {
case .directMessage:
context.handlePrivateMessage(message)
case .publicMesh:
context.appendPublicMeshMessage(message)
}
// Live playback only when the user is looking at this conversation // Live playback only when the user is looking at this conversation
// with the app frontmost and live voice enabled. // with the app frontmost and live voice enabled.
if PTTSettings.liveVoiceEnabled, let isViewing = switch scope {
PTTSettings.isAppActive, case .directMessage: context.selectedPrivateChatPeer == peerID
context.selectedPrivateChatPeer == peerID { case .publicMesh: context.isViewingPublicMeshTimeline
}
if PTTSettings.liveVoiceEnabled, PTTSettings.isAppActive, isViewing {
assembly.player = PTTBurstPlayer() assembly.player = PTTBurstPlayer()
} }
@@ -227,6 +306,13 @@ final class ChatLiveVoiceCoordinator {
return assembly return assembly
} }
/// Keeps the composer's floor-courtesy indicator pointing at whoever is
/// currently talking live in the public mesh channel.
private func updatePublicTalkerIndicator() {
let talker = assemblies.values.first { $0.scope == .publicMesh }?.nickname
context.setActivePublicVoiceTalker(talker)
}
private func apply(_ packet: VoiceBurstPacket, to assembly: Assembly) { private func apply(_ packet: VoiceBurstPacket, to assembly: Assembly) {
assembly.receivedBytes += packet.encode().count assembly.receivedBytes += packet.encode().count
let elapsed = Date().timeIntervalSince(assembly.firstPacketAt) let elapsed = Date().timeIntervalSince(assembly.firstPacketAt)
@@ -328,10 +414,11 @@ final class ChatLiveVoiceCoordinator {
try? assembly.fileHandle?.close() try? assembly.fileHandle?.close()
assembly.fileHandle = nil assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID) assemblies.removeValue(forKey: assembly.burstID)
updatePublicTalkerIndicator()
guard assembly.deliveredFrames > 0 else { guard assembly.deliveredFrames > 0 else {
// Nothing audible ever arrived drop the empty bubble. // Nothing audible ever arrived drop the empty bubble.
context.removePrivateMessage(withID: assembly.messageID) removeBubble(of: assembly)
try? FileManager.default.removeItem(at: assembly.fileURL) try? FileManager.default.removeItem(at: assembly.fileURL)
context.notifyUIChanged() context.notifyUIChanged()
return return
@@ -342,12 +429,13 @@ final class ChatLiveVoiceCoordinator {
WaveformCache.shared.purge(url: assembly.fileURL) WaveformCache.shared.purge(url: assembly.fileURL)
// Republish so the row re-renders without its LIVE treatment even if // Republish so the row re-renders without its LIVE treatment even if
// no finalized note ever arrives to swap in. // no finalized note ever arrives to swap in.
context.upsertPrivateMessage(assembly.message, in: assembly.peerID) republishBubble(of: assembly)
pruneFinishedBursts() pruneFinishedBursts()
finishedBursts[assembly.burstID] = FinishedBurst( finishedBursts[assembly.burstID] = FinishedBurst(
messageID: assembly.messageID, messageID: assembly.messageID,
peerID: assembly.peerID, peerID: assembly.peerID,
scope: assembly.scope,
fileURL: assembly.fileURL, fileURL: assembly.fileURL,
messageTimestamp: assembly.messageTimestamp, messageTimestamp: assembly.messageTimestamp,
expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds) expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds)
@@ -356,6 +444,24 @@ final class ChatLiveVoiceCoordinator {
SecureLogger.debug("PTT: burst \(assembly.burstID.hexEncodedString()) finalized (\(assembly.deliveredFrames) frames)", category: .session) SecureLogger.debug("PTT: burst \(assembly.burstID.hexEncodedString()) finalized (\(assembly.deliveredFrames) frames)", category: .session)
} }
private func removeBubble(of assembly: Assembly) {
switch assembly.scope {
case .directMessage:
context.removePrivateMessage(withID: assembly.messageID)
case .publicMesh:
context.removeMessage(withID: assembly.messageID, cleanupFile: false)
}
}
private func republishBubble(of assembly: Assembly) {
switch assembly.scope {
case .directMessage:
context.upsertPrivateMessage(assembly.message, in: assembly.peerID)
case .publicMesh:
context.upsertPublicMeshMessage(assembly.message)
}
}
private func cancelAssembly(_ assembly: Assembly) { private func cancelAssembly(_ assembly: Assembly) {
assembly.idleTimeout?.cancel() assembly.idleTimeout?.cancel()
assembly.gapRedrain?.cancel() assembly.gapRedrain?.cancel()
@@ -363,7 +469,8 @@ final class ChatLiveVoiceCoordinator {
try? assembly.fileHandle?.close() try? assembly.fileHandle?.close()
assembly.fileHandle = nil assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID) assemblies.removeValue(forKey: assembly.burstID)
context.removePrivateMessage(withID: assembly.messageID) updatePublicTalkerIndicator()
removeBubble(of: assembly)
WaveformCache.shared.purge(url: assembly.fileURL) WaveformCache.shared.purge(url: assembly.fileURL)
try? FileManager.default.removeItem(at: assembly.fileURL) try? FileManager.default.removeItem(at: assembly.fileURL)
context.notifyUIChanged() context.notifyUIChanged()
+17
View File
@@ -187,6 +187,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor @MainActor
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs } var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
@Published var allPeers: [BitchatPeer] = [] @Published var allPeers: [BitchatPeer] = []
/// Nickname of whoever is talking live in the public mesh channel right
/// now (floor-courtesy indicator on the composer mic), nil when nobody.
@Published var activePublicVoiceTalker: String?
/// Read-only derived view of all direct conversations in the /// Read-only derived view of all direct conversations in the
/// `ConversationStore`, keyed by routing peer ID. Serves the coordinator /// `ConversationStore`, keyed by routing peer ID. Serves the coordinator
@@ -1624,6 +1627,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
} }
} }
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
Task { @MainActor [weak self] in
self?.liveVoiceCoordinator.handlePublicVoiceFramePayload(
from: peerID,
nickname: nickname,
payload: payload,
timestamp: timestamp
)
}
}
// MARK: - QR Verification API // MARK: - QR Verification API
@MainActor @MainActor
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
@@ -1776,6 +1790,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Handle incoming public message /// Handle incoming public message
@MainActor @MainActor
func handlePublicMessage(_ message: BitchatMessage) { func handlePublicMessage(_ message: BitchatMessage) {
// A finalized voice note whose burst already streamed in live swaps
// into the existing bubble instead of appearing twice.
if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return }
publicConversationCoordinator.handlePublicMessage(message) publicConversationCoordinator.handlePublicMessage(message)
} }
@@ -70,33 +70,56 @@ extension ChatViewModel {
mediaTransferCoordinator.sendVoiceNote(at: url) mediaTransferCoordinator.sendVoiceNote(at: url)
} }
/// Where a live burst would stream right now, or nil when the hold would
/// fall back to a classic voice note.
private enum LiveVoiceTarget {
case peer(PeerID)
case publicMesh
}
@MainActor
private func liveVoiceTarget() -> LiveVoiceTarget? {
guard PTTSettings.liveVoiceEnabled else { return nil }
if let selectedPeer = selectedPrivateChatPeer {
guard !selectedPeer.isGeoDM, !selectedPeer.isGeoChat, !selectedPeer.isGroup else { return nil }
// A conversation can be selected under the stable 64-hex Noise key
// (e.g. after migration on disconnect), but Noise sessions are keyed
// by the 16-hex routing ID normalize once and send to that same
// short ID, like the private-message/file paths do.
let peerID = selectedPeer.toShort()
guard meshService.isPeerReachable(peerID),
case .established = meshService.getNoiseSessionState(for: peerID)
else { return nil }
return .peer(peerID)
}
// Public mesh timeline: signed live broadcast. Geohash channels never
// reach here (the composer hides media affordances there).
return activeChannel == .mesh ? .publicMesh : nil
}
/// Picks the capture backend for the composer's hold-to-record gesture: /// Picks the capture backend for the composer's hold-to-record gesture:
/// live push-to-talk when the selected DM peer can hear it now (mesh /// live push-to-talk when the audience can hear it now a DM peer that
/// reachable + established Noise session), otherwise the classic /// is mesh-reachable with an established Noise session, or the public
/// record-then-send voice note. Either way the release delivers a normal /// mesh channel otherwise the classic record-then-send voice note.
/// voice note through `sendVoiceNote(at:)`. /// Either way the release delivers a normal voice note through
/// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble.
@MainActor @MainActor
func makeVoiceCaptureSession() -> VoiceCaptureSession { func makeVoiceCaptureSession() -> VoiceCaptureSession {
guard PTTSettings.liveVoiceEnabled, switch liveVoiceTarget() {
let selectedPeer = selectedPrivateChatPeer, case .peer(let peerID):
!selectedPeer.isGeoDM, !selectedPeer.isGeoChat, !selectedPeer.isGroup return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
else { meshService.sendVoiceFrame(packet, to: peerID)
})
case .publicMesh:
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrameBroadcast(packet)
})
case nil:
SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session)
return VoiceNoteCaptureSession() return VoiceNoteCaptureSession()
} }
// A conversation can be selected under the stable 64-hex Noise key
// (e.g. after migration on disconnect), but Noise sessions are keyed
// by the 16-hex routing ID normalize once and send to that same
// short ID, like the private-message/file paths do.
let peerID = selectedPeer.toShort()
guard meshService.isPeerReachable(peerID),
case .established = meshService.getNoiseSessionState(for: peerID)
else {
SecureLogger.debug("PTT: live unavailable for \(peerID.id.prefix(8))… (reachable=\(meshService.isPeerReachable(peerID))) — using classic voice note", category: .session)
return VoiceNoteCaptureSession()
}
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrame(packet, to: peerID)
})
} }
/// Inbound handler for `NoisePayloadType.voiceFrame`. /// Inbound handler for `NoisePayloadType.voiceFrame`.
@@ -76,6 +76,7 @@ final class VoiceRecordingViewModel: ObservableObject {
func start(shouldShow: Bool) { func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return } guard shouldShow, state == .idle else { return }
let session = sessionProvider() let session = sessionProvider()
SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session)
activeSession = session activeSession = session
state = .requestingPermission state = .requestingPermission
Task { Task {
@@ -144,6 +145,10 @@ final class VoiceRecordingViewModel: ObservableObject {
activeSession = nil activeSession = nil
guard case .recording(let startDate) = previousState, let completion, let session else { guard case .recording(let startDate) = previousState, let completion, let session else {
// A quick press releases before the recorder spins up; that has
// always been a silent no-op for voice notes log it so field
// tests can tell "tapped" apart from "capture broke".
SecureLogger.info("PTT: mic released before recording started (state was \(previousState)) — hold longer to record", category: .session)
Task { await session?.cancel() } Task { await session?.cancel() }
return return
} }
+46 -27
View File
@@ -120,14 +120,21 @@ struct AppInfoView: View {
enum HowToUse { enum HowToUse {
static let title: LocalizedStringKey = "app_info.how_to_use.title" static let title: LocalizedStringKey = "app_info.how_to_use.title"
static let instructions: [LocalizedStringKey] = [ /// The instruction strings flowed into one comma-separated
"app_info.how_to_use.set_nickname", /// paragraph. The translations carry their legacy bullet-list
"app_info.how_to_use.change_channels", /// prefix (" "), so it is stripped here.
"app_info.how_to_use.open_sidebar", static var paragraph: String {
"app_info.how_to_use.start_dm", [
"app_info.how_to_use.clear_chat", String(localized: "app_info.how_to_use.set_nickname"),
"app_info.how_to_use.commands" String(localized: "app_info.how_to_use.change_channels"),
] String(localized: "app_info.how_to_use.open_sidebar"),
String(localized: "app_info.how_to_use.start_dm"),
String(localized: "app_info.how_to_use.clear_chat"),
String(localized: "app_info.how_to_use.commands")
]
.map { $0.hasPrefix("") ? String($0.dropFirst(2)) : $0 }
.joined(separator: ", ")
}
} }
} }
@@ -196,6 +203,16 @@ struct AppInfoView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical) .padding(.vertical)
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
Text(verbatim: Strings.HowToUse.paragraph)
.bitchatFont(size: 14)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
}
// Appearance single row: label left, theme chips right // Appearance single row: label left, theme chips right
HStack(spacing: 12) { HStack(spacing: 12) {
SectionHeader(Strings.appearanceTitle) SectionHeader(Strings.appearanceTitle)
@@ -220,17 +237,19 @@ struct AppInfoView: View {
} }
} }
// How to Use // Voice
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title) SectionHeader(Strings.Voice.title)
VStack(alignment: .leading, spacing: 8) { HStack(spacing: 0) {
ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in FeatureRow(info: Strings.Voice.live)
Text(instruction) Toggle(Strings.Voice.live.title, isOn: $liveVoiceEnabled)
} .labelsHidden()
.tint(palette.accent)
.onChange(of: liveVoiceEnabled) { newValue in
PTTSettings.liveVoiceEnabled = newValue
}
} }
.bitchatFont(size: 14)
.foregroundColor(textColor)
} }
// Voice // Voice
@@ -286,6 +305,17 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Features.mentions) FeatureRow(info: Strings.Features.mentions)
} }
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
FeatureRow(info: Strings.Privacy.noTracking)
FeatureRow(info: Strings.Privacy.ephemeral)
FeatureRow(info: Strings.Privacy.panic)
}
// Symbols legend // Symbols legend
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
SectionHeader(Strings.Legend.title) SectionHeader(Strings.Legend.title)
@@ -307,17 +337,6 @@ struct AppInfoView: View {
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
} }
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
FeatureRow(info: Strings.Privacy.noTracking)
FeatureRow(info: Strings.Privacy.ephemeral)
FeatureRow(info: Strings.Privacy.panic)
}
} }
.padding() .padding()
} }
+26 -2
View File
@@ -241,10 +241,28 @@ private extension ContentComposerView {
} }
} }
/// Floor courtesy: someone else is talking live in the public channel.
/// Only advisory a decentralized mesh has no floor arbiter, so holding
/// the mic still works; the tint just discourages talk-over.
var busyTalker: String? {
guard privateConversationModel.selectedPeerID == nil else { return nil }
return conversationUIModel.activeLiveVoiceTalker
}
/// Recording > floor-busy > default accent. Whether the hold streams
/// live or records a classic note is signaled by the recording HUD's
/// LIVE treatment, not the idle button color.
var micColor: Color {
if voiceRecordingVM.state.isActive { return .red }
if busyTalker != nil { return Color.red.opacity(0.6) }
return composerAccentColor
}
var micButtonView: some View { var micButtonView: some View {
Image(systemName: "mic.circle.fill") Image(systemName: "mic.circle.fill")
.font(.bitchatSystem(size: 24)) .font(.bitchatSystem(size: 24))
.foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor) .foregroundColor(micColor)
.modifier(PulsingOpacityModifier(active: busyTalker != nil && !voiceRecordingVM.state.isActive))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
.contentShape(Circle()) .contentShape(Circle())
.overlay( .overlay(
@@ -266,7 +284,13 @@ private extension ContentComposerView {
.accessibilityValue( .accessibilityValue(
voiceRecordingVM.state.isActive voiceRecordingVM.state.isActive
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording") ? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
: "" : busyTalker.map {
String(
format: String(localized: "content.accessibility.someone_speaking", comment: "Accessibility value on the mic button naming who is talking live in the public channel"),
locale: .current,
$0
)
} ?? ""
) )
.accessibilityHint( .accessibilityHint(
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording") String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
@@ -15,23 +15,39 @@ import BitFoundation
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
var nickname = "me" var nickname = "me"
var selectedPrivateChatPeer: PeerID? var selectedPrivateChatPeer: PeerID?
var isViewingPublicMeshTimeline = false
var blockedPeers: Set<PeerID> = [] var blockedPeers: Set<PeerID> = []
private(set) var handledPrivateMessages: [BitchatMessage] = [] private(set) var handledPrivateMessages: [BitchatMessage] = []
private(set) var appendedPublicMessages: [BitchatMessage] = []
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = [] private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
private(set) var upsertedPublicMessages: [BitchatMessage] = []
private(set) var removedMessageIDs: [String] = [] private(set) var removedMessageIDs: [String] = []
private(set) var talkerUpdates: [String?] = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func resolveNickname(for peerID: PeerID) -> String { "alice" } func resolveNickname(for peerID: PeerID) -> String { "alice" }
func handlePrivateMessage(_ message: BitchatMessage) { handledPrivateMessages.append(message) } func handlePrivateMessage(_ message: BitchatMessage) { handledPrivateMessages.append(message) }
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message, peerID)) upsertedMessages.append((message, peerID))
} }
func upsertPublicMeshMessage(_ message: BitchatMessage) {
upsertedPublicMessages.append(message)
}
@discardableResult @discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? { func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
removedMessageIDs.append(messageID) removedMessageIDs.append(messageID)
return nil return nil
} }
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessageIDs.append(messageID)
}
func setActivePublicVoiceTalker(_ nickname: String?) {
if talkerUpdates.last ?? nil != nickname {
talkerUpdates.append(nickname)
}
}
func notifyUIChanged() {} func notifyUIChanged() {}
} }
@@ -230,6 +246,62 @@ struct ChatLiveVoiceCoordinatorTests {
#expect(!FileManager.default.fileExists(atPath: url.path)) #expect(!FileManager.default.fileExists(atPath: url.path))
} }
@Test func publicBurstCreatesMeshBubbleAndTracksTalker() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0x71)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
func sendPublic(_ packet: VoiceBurstPacket) {
coordinator.handlePublicVoiceFramePayload(from: peer, nickname: "bob", payload: packet.encode(), timestamp: Date())
}
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))))
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 3, count: 50)]))))
// Bubble appends straight to the mesh store with the verified
// nickname, and the floor-courtesy indicator names the talker.
#expect(context.handledPrivateMessages.isEmpty)
let bubble = try #require(context.appendedPublicMessages.first)
#expect(!bubble.isPrivate)
#expect(bubble.sender == "bob")
#expect(context.talkerUpdates.last == "bob")
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))))
// Burst over: talker cleared, bubble republished into the mesh store.
#expect(context.talkerUpdates.last == .some(nil))
#expect(context.upsertedPublicMessages.contains { $0.id == bubble.id })
// The broadcast finalized note absorbs into the same bubble.
let note = BitchatMessage(
sender: "bob", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: false, senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedPublicMessages.last)
#expect(replacement.id == bubble.id)
#expect(replacement.content == note.content)
#expect(!replacement.isPrivate)
}
@Test func absorbEnforcesScopeBinding() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0x72)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
// A DM burst...
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 4, count: 40)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
// ...must not be replaced by a *public* note claiming the same burst.
let publicNote = BitchatMessage(
sender: "alice", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: false, senderPeerID: peer
)
#expect(!coordinator.absorbFinalizedVoiceNote(publicNote))
}
@Test func burstIDParsingFromFileNames() { @Test func burstIDParsingFromFileNames() {
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff.m4a") == Data(hexString: "00112233445566ff")) #expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff.m4a") == Data(hexString: "00112233445566ff"))
// Uniquified copies keep the leading hex run. // Uniquified copies keep the leading hex run.
@@ -134,6 +134,46 @@ struct RelayControllerTests {
#expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) #expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1)
} }
@Test
func voiceFrame_relaysWithFragmentPolicy() async {
// Sparse graph: fragment cap, tight jitter (multi-hop latency must
// stay inside the receiver's jitter buffer).
let sparse = RelayController.decide(
ttl: 7,
senderIsSelf: false,
isEncrypted: false,
isDirectedEncrypted: false,
isFragment: false,
isDirectedFragment: false,
isHandshake: false,
isAnnounce: false,
isVoiceFrame: true,
degree: 3,
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
)
#expect(sparse.shouldRelay)
#expect(sparse.newTTL == min(UInt8(7), TransportConfig.bleFragmentRelayTtlCap) &- 1)
#expect(sparse.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs)
#expect(sparse.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs)
// Dense graph: harder clamp contains the sustained per-talker stream.
let dense = RelayController.decide(
ttl: 7,
senderIsSelf: false,
isEncrypted: false,
isDirectedEncrypted: false,
isFragment: false,
isDirectedFragment: false,
isHandshake: false,
isAnnounce: false,
isVoiceFrame: true,
degree: TransportConfig.bleHighDegreeThreshold,
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
)
#expect(dense.shouldRelay)
#expect(dense.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1)
}
@Test @Test
func requestSync_neverRelaysEvenWithTTLHeadroom() async { func requestSync_neverRelaysEvenWithTTLHeadroom() async {
let decision = RelayController.decide( let decision = RelayController.decide(
+4 -2
View File
@@ -13,9 +13,11 @@ The "smart" part is that PTT is not a separate mode the user must choose. It is
|---|---| |---|---|
| DM, peer connected/reachable on mesh | **Live stream** (Noise-encrypted frames) + finalized voice note for reliability | | DM, peer connected/reachable on mesh | **Live stream** (Noise-encrypted frames) + finalized voice note for reliability |
| DM, peer only reachable via Nostr | Existing voice-note recording only (no live; media doesn't ride Nostr today) | | DM, peer only reachable via Nostr | Existing voice-note recording only (no live; media doesn't ride Nostr today) |
| Public mesh chat | **Live broadcast stream**, best-effort, no file follow-up | | Public mesh chat | **Live broadcast stream** (signed) + finalized voice note, same dedup as DMs |
| Geohash (Nostr) channels | PTT unavailable (matches existing `canSendMediaInCurrentContext` media policy) | | Geohash (Nostr) channels | PTT unavailable (matches existing `canSendMediaInCurrentContext` media policy) |
*(Public originally speced as live-only to avoid doubling bandwidth, but dropping the note broadcast would regress mixed-version meshes — old clients that can't decode live bursts would stop receiving public voice entirely — and late joiners/out-of-range peers would get nothing. The note stays; live receivers absorb it silently.)*
One gesture (hold mic), one mental model ("talk"), and the system degrades from live → reliable-note → unavailable based on what the transport can actually do. One gesture (hold mic), one mental model ("talk"), and the system degrades from live → reliable-note → unavailable based on what the transport can actually do.
**Bandwidth reality check:** the mesh moves ~15 KB/s per link (469 B fragments at 30 ms spacing); our voice codec needs ~2 KB/s. Live voice fits with a wide margin, even relayed. **Bandwidth reality check:** the mesh moves ~15 KB/s per link (469 B fragments at 30 ms spacing); our voice codec needs ~2 KB/s. Live voice fits with a wide margin, even relayed.
@@ -91,7 +93,7 @@ New `VoiceBurstAssembler` (keyed by sender + burstID) feeding a `PTTBurstPlayer`
- **Loss handling:** gap in seq → insert silence for the missing frames (64 ms each) and keep going. No PLC in v1; at these frame sizes brief dropouts are acceptable. - **Loss handling:** gap in seq → insert silence for the missing frames (64 ms each) and keep going. No PLC in v1; at these frame sizes brief dropouts are acceptable.
- **Burst end:** on `END`, or 3 s with no frames (talker walked out of range). - **Burst end:** on `END`, or 3 s with no frames (talker walked out of range).
- **Persistence:** frames append to an incoming ADTS `.aac` file (already an allowed `MimeType`), so every burst becomes a replayable voice-note bubble containing whatever was captured — even a partial one. - **Persistence:** frames append to an incoming ADTS `.aac` file (already an allowed `MimeType`), so every burst becomes a replayable voice-note bubble containing whatever was captured — even a partial one.
- **Dedup with the finalized note (DM):** when a `fileTransfer` arrives whose fileName carries a burstID we already assembled, it silently *replaces* the partial file behind the existing bubble (no new message row, no second notification). Receivers that heard everything live just get a lossless copy. - **Dedup with the finalized note:** when a `fileTransfer` arrives whose fileName carries a burstID we already assembled (DM or public), it silently *replaces* the partial file behind the existing bubble (no new message row, no second notification). Receivers that heard everything live just get a lossless copy.
- **Resource caps:** ≤ 8 concurrent assemblies, ≤ 256 KB per burst (60 s × 2 KB/s + slack), 30 s stale cleanup, and drop inbound frames beyond ~2× realtime per sender (spam/flood guard). - **Resource caps:** ≤ 8 concurrent assemblies, ≤ 256 KB per burst (60 s × 2 KB/s + slack), 30 s stale cleanup, and drop inbound frames beyond ~2× realtime per sender (spam/flood guard).
## 6. Playback policy — when does it actually make sound? ## 6. Playback policy — when does it actually make sound?
@@ -36,6 +36,10 @@ public enum MessageType: UInt8 {
// an internet gateway peer. // an internet gateway peer.
case nostrCarrier = 0x28 case nostrCarrier = 0x28
// Live voice: one signed push-to-talk burst packet (ephemeral broadcast,
// never gossip-synced). Private bursts ride noiseEncrypted instead.
case voiceFrame = 0x29
public var description: String { public var description: String {
switch self { switch self {
case .announce: return "announce" case .announce: return "announce"
@@ -53,6 +57,7 @@ public enum MessageType: UInt8 {
case .ping: return "ping" case .ping: return "ping"
case .pong: return "pong" case .pong: return "pong"
case .nostrCarrier: return "nostrCarrier" case .nostrCarrier: return "nostrCarrier"
case .voiceFrame: return "voiceFrame"
} }
} }
} }