mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
eacd8f0750
commit
78a291ab77
@@ -12,6 +12,9 @@ final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@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 privateConversationModel: PrivateConversationModel
|
||||
@@ -186,6 +189,10 @@ final class ConversationUIModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
|
||||
chatViewModel.$activePublicVoiceTalker
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$activeLiveVoiceTalker)
|
||||
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
|
||||
@@ -35,6 +35,9 @@ final class PTTCaptureEngine {
|
||||
private var encodedFrameCount = 0
|
||||
private var running = false
|
||||
private var captureStart = Date()
|
||||
/// Whether `engine.start()` succeeded for the current capture (main-actor
|
||||
/// callers only; see `stopEngineIfStarted`).
|
||||
private var engineStarted = false
|
||||
|
||||
/// Called on the capture queue with each batch of encoded AAC frames.
|
||||
var onFrames: (([Data]) -> Void)?
|
||||
@@ -91,14 +94,14 @@ final class PTTCaptureEngine {
|
||||
queue.sync { self.teardown(deleteFile: true) }
|
||||
throw error
|
||||
}
|
||||
engineStarted = true
|
||||
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
|
||||
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
||||
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
stopEngineIfStarted()
|
||||
let result: (URL?, Int) = queue.sync {
|
||||
let url = fileURL
|
||||
let frames = encodedFrameCount
|
||||
@@ -112,14 +115,23 @@ final class PTTCaptureEngine {
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
stopEngineIfStarted()
|
||||
queue.sync { teardown(deleteFile: true) }
|
||||
#if os(iOS)
|
||||
Self.deactivateAudioSession()
|
||||
#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
|
||||
|
||||
private func process(_ buffer: AVAudioPCMBuffer) {
|
||||
|
||||
+237
-58
@@ -4310,175 +4310,175 @@
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• اضغط أيقونة الأشخاص لفتح الشريط الجانبي"
|
||||
"value" : "• اضغط أيقونة الأشخاص لفتح القائمة"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• সাইডবার খুলতে মানুষ আইকনে ট্যাপ করুন"
|
||||
"value" : "• তালিকা খুলতে মানুষ আইকনে ট্যাপ করুন"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• tippe auf das personen-icon, um die seitenleiste zu öffnen"
|
||||
"value" : "• tippe auf das personen-icon für die liste"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• tap people icon for sidebar"
|
||||
"value" : "• tap people icon for list"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• toca el ícono de personas para abrir la barra lateral"
|
||||
"value" : "• toca el ícono de personas para ver la lista"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• i-tap ang icon ng tao para buksan ang sidebar"
|
||||
"value" : "• i-tap ang icon ng tao para sa listahan"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• הקש על אייקון האנשים כדי לפתוח סרגל צד"
|
||||
"value" : "• הקש על אייקון האנשים כדי לפתוח את הרשימה"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• साइडबार खोलने के लिए लोगों वाले आइकन पर टैप करें"
|
||||
"value" : "• सूची खोलने के लिए लोगों वाले आइकन पर टैप करें"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• ketuk ikon orang untuk membuka sidebar"
|
||||
"value" : "• ketuk ikon orang untuk membuka daftar"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• tocca l'icona persone per aprire la barra laterale"
|
||||
"value" : "• tocca l'icona persone per aprire la lista"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• 人アイコンをタップしてサイドバーを開く"
|
||||
"value" : "• 人アイコンをタップして一覧を開く"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• 사람 아이콘을 탭하여 사이드바를 엽니다"
|
||||
"value" : "• 사람 아이콘을 탭하여 목록을 엽니다"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• ketuk ikon orang untuk membuka sidebar"
|
||||
"value" : "• ketuk ikon orang untuk membuka senarai"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• साइडबार खोल्न मान्छे आइकन ट्याप गर"
|
||||
"value" : "• सूची खोल्न मान्छे आइकन ट्याप गर"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• tik op het personen-icoon om de zijbalk te openen"
|
||||
"value" : "• tik op het personen-icoon voor de lijst"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• stuknij ikonę osób, aby otworzyć panel boczny"
|
||||
"value" : "• stuknij ikonę osób, aby otworzyć listę"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• toque o ícone de pessoas para abrir a barra lateral"
|
||||
"value" : "• toque o ícone de pessoas para abrir a lista"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• нажми на иконку людей, чтобы открыть боковое меню"
|
||||
"value" : "• нажми на иконку людей, чтобы открыть список"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• tryck på personikonen för att öppna sidomenyn"
|
||||
"value" : "• tryck på personikonen för att öppna listan"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• பக்கப்பட்டியைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்"
|
||||
"value" : "• பட்டியலைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• แตะไอคอนคนเพื่อเปิดแถบด้านข้าง"
|
||||
"value" : "• แตะไอคอนคนเพื่อเปิดรายชื่อ"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• kenar çubuğunu açmak için insan simgesine dokunun"
|
||||
"value" : "• listeyi açmak için insan simgesine dokunun"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• торкни піктограму людей, щоб відкрити бічну панель"
|
||||
"value" : "• торкни піктограму людей, щоб відкрити список"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• سائیڈ بار کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں"
|
||||
"value" : "• فہرست کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• 轻点人物图标打开侧栏"
|
||||
"value" : "• 轻点人物图标打开列表"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• 輕點人物圖示打開側欄"
|
||||
"value" : "• 輕點人物圖示打開列表"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4668,175 +4668,175 @@
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• اضغط اسم القرين لبدء رسائل خاصة"
|
||||
"value" : "• اضغط اسم شخص لبدء رسائل خاصة"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• ডিএম শুরু করতে পিয়ারের নাম ট্যাপ করুন"
|
||||
"value" : "• ডিএম শুরু করতে কোনো ব্যক্তির নাম ট্যাপ করুন"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• tap a peer's name to start a DM"
|
||||
"value" : "• tap a person's name to start a DM"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• הקש על שם עמית כדי להתחיל הודעה פרטית"
|
||||
"value" : "• הקש על שם של אדם כדי להתחיל הודעה פרטית"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• किसी पीयर का नाम टैप करके DM शुरू करें"
|
||||
"value" : "• किसी व्यक्ति का नाम टैप करके DM शुरू करें"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• ketuk nama peer untuk mulai dm"
|
||||
"value" : "• ketuk nama seseorang untuk mulai dm"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• ピアの名前をタップしてdm開始"
|
||||
"value" : "• 人の名前をタップしてdm開始"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• 피어의 이름을 탭하여 DM을 시작합니다"
|
||||
"value" : "• 상대방의 이름을 탭하여 DM을 시작합니다"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• ketuk nama peer untuk mulai dm"
|
||||
"value" : "• ketuk nama seseorang untuk mulai dm"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर"
|
||||
"value" : "• dm सुरु गर्न कुनै व्यक्तिको नाम ट्याप गर"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• stuknij nazwę peera, aby rozpocząć DM"
|
||||
"value" : "• stuknij czyjeś imię, aby rozpocząć DM"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• нажми имя пользователя, чтобы начать лс"
|
||||
"value" : "• нажми имя человека, чтобы начать лс"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• peer பெயரைத் தட்டி DM தொடங்கவும்"
|
||||
"value" : "• ஒருவரின் பெயரைத் தட்டி DM தொடங்கவும்"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• แตะชื่อเพียร์เพื่อเริ่ม DM"
|
||||
"value" : "• แตะชื่อบุคคลเพื่อเริ่ม DM"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• bir eşin adına dokunarak DM başlatın"
|
||||
"value" : "• bir kişinin adına dokunarak DM başlatın"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• торкни ім'я піра, щоб почати приватний чат"
|
||||
"value" : "• торкни ім'я людини, щоб почати приватний чат"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• DM شروع کرنے کیلئے کسی ہم منصب کے نام پر ٹیپ کریں"
|
||||
"value" : "• DM شروع کرنے کیلئے کسی شخص کے نام پر ٹیپ کریں"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "• 轻点同伴名字开始 dm"
|
||||
"value" : "• 轻点某人名字开始 dm"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"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" : {
|
||||
"comment" : "Accessibility action name for taking a photo with the camera",
|
||||
"extractionState" : "manual",
|
||||
|
||||
@@ -130,6 +130,9 @@ protocol BitchatDelegate: AnyObject {
|
||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||
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
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
@@ -153,6 +156,10 @@ extension BitchatDelegate {
|
||||
// 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?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ struct BLEReceivePipeline {
|
||||
// announce-class TTL headroom so alerts travel the extra hop.
|
||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||
&& BoardWire.urgentFlag(in: packet.payload),
|
||||
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
// Appends to the encryption service's handler array, so this never
|
||||
// displaces the callbacks installed by installNoiseSessionCallbacks.
|
||||
@@ -3985,6 +4013,11 @@ extension BLEService {
|
||||
case .nostrCarrier:
|
||||
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:
|
||||
// Rate limiting must key on the ingress link (`peerID`), not the
|
||||
// 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) {
|
||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ struct RelayController {
|
||||
isAnnounce: Bool,
|
||||
isRequestSync: Bool = false,
|
||||
isUrgentBoardPost: Bool = false,
|
||||
isVoiceFrame: Bool = false,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
@@ -46,7 +47,11 @@ struct RelayController {
|
||||
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;
|
||||
// sparse graphs get full depth so media reaches as far as text.
|
||||
let fragmentCap = degree >= highDegreeThreshold
|
||||
|
||||
@@ -72,6 +72,9 @@ enum TransportEvent: @unchecked Sendable {
|
||||
/// Encrypted group broadcast (MessageType 0x25). Opaque here — the group
|
||||
/// coordinator decrypts and authenticates against the roster.
|
||||
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 peerDisconnected(PeerID)
|
||||
case peerListUpdated([PeerID])
|
||||
@@ -160,6 +163,8 @@ protocol Transport: AnyObject {
|
||||
// only useful now — transports drop them (never queue) when no
|
||||
// established session exists.
|
||||
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
|
||||
// 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 sendBoardPayload(_ payload: Data) {}
|
||||
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {}
|
||||
func sendVoiceFrameBroadcast(_ burstContent: Data) {}
|
||||
|
||||
// Mesh diagnostics are mesh-transport-only; other transports report
|
||||
// "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)
|
||||
case let .groupMessageReceived(payload, 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):
|
||||
didConnectToPeer(peerID)
|
||||
case .peerDisconnected(let peerID):
|
||||
|
||||
@@ -34,6 +34,9 @@ enum TransportConfig {
|
||||
// Inbound flood guard: a real burst arrives at ~2KB/s; allow 3x plus a
|
||||
// small settling allowance before dropping a sender's frames.
|
||||
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)
|
||||
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
|
||||
|
||||
@@ -54,6 +54,9 @@ struct SyncTypeFlags: OptionSet {
|
||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||
// would waste airtime and extend their lifetime.
|
||||
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
|
||||
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
|
||||
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
|
||||
|
||||
@@ -11,19 +11,58 @@ import Foundation
|
||||
protocol ChatLiveVoiceContext: AnyObject {
|
||||
var nickname: String { 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 resolveNickname(for peerID: PeerID) -> String
|
||||
/// Routes an inbound private message through the full pipeline
|
||||
/// (store append, unread state, notification, read receipt).
|
||||
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.
|
||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID)
|
||||
/// Replace-or-append by message ID in the public mesh timeline.
|
||||
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
||||
@discardableResult
|
||||
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()
|
||||
}
|
||||
|
||||
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`):
|
||||
/// orders packets behind a jitter window, persists frames progressively as an
|
||||
@@ -36,6 +75,8 @@ final class ChatLiveVoiceCoordinator {
|
||||
private final class Assembly {
|
||||
let burstID: Data
|
||||
let peerID: PeerID
|
||||
let scope: VoiceBurstScope
|
||||
let nickname: String
|
||||
let message: BitchatMessage
|
||||
var messageID: String { message.id }
|
||||
var messageTimestamp: Date { message.timestamp }
|
||||
@@ -56,9 +97,11 @@ final class ChatLiveVoiceCoordinator {
|
||||
var idleTimeout: 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.peerID = peerID
|
||||
self.scope = scope
|
||||
self.nickname = nickname
|
||||
self.message = message
|
||||
self.fileURL = fileURL
|
||||
self.fileHandle = fileHandle
|
||||
@@ -69,6 +112,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
private struct FinishedBurst {
|
||||
let messageID: String
|
||||
let peerID: PeerID
|
||||
let scope: VoiceBurstScope
|
||||
let fileURL: URL
|
||||
let messageTimestamp: Date
|
||||
let expiresAt: Date
|
||||
@@ -88,11 +132,25 @@ final class ChatLiveVoiceCoordinator {
|
||||
|
||||
// MARK: - Inbound frames
|
||||
|
||||
/// Inbound DM burst packet (`NoisePayloadType.voiceFrame`).
|
||||
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
|
||||
// bubble, no partial file, no early notification — the finalized
|
||||
// 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 {
|
||||
SecureLogger.warning("PTT: undecodable voice frame from \(peerID.id.prefix(8))… (\(payload.count) bytes: \(payload.prefix(16).hexEncodedString())…)", category: .session)
|
||||
return
|
||||
@@ -103,23 +161,25 @@ final class ChatLiveVoiceCoordinator {
|
||||
}
|
||||
|
||||
if let assembly = assemblies[packet.burstID] {
|
||||
// The sender is Noise-authenticated; a different peer reusing the
|
||||
// same burst ID is a collision or a replay — drop it.
|
||||
guard assembly.peerID == peerID else { return }
|
||||
// The sender is authenticated (Noise session or packet
|
||||
// signature); a different peer or scope reusing the same burst
|
||||
// ID is a collision or a replay — drop it.
|
||||
guard assembly.peerID == peerID, assembly.scope == scope else { return }
|
||||
apply(packet, to: assembly)
|
||||
return
|
||||
}
|
||||
|
||||
switch packet.kind {
|
||||
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.
|
||||
guard assemblies.count < TransportConfig.pttMaxConcurrentAssemblies else {
|
||||
SecureLogger.debug("PTT: dropping burst from \(peerID.id.prefix(8))… — assembly cap reached", category: .session)
|
||||
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
|
||||
updatePublicTalkerIndicator()
|
||||
apply(packet, to: assembly)
|
||||
case .end, .canceled:
|
||||
// Control packet for a burst we never saw — nothing to do.
|
||||
@@ -151,8 +211,9 @@ final class ChatLiveVoiceCoordinator {
|
||||
|
||||
pruneFinishedBursts()
|
||||
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.isPrivate == (finished.scope == .directMessage) else { return false }
|
||||
|
||||
let replacement = BitchatMessage(
|
||||
id: finished.messageID,
|
||||
@@ -161,13 +222,18 @@ final class ChatLiveVoiceCoordinator {
|
||||
timestamp: finished.messageTimestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: context.nickname,
|
||||
isPrivate: finished.scope == .directMessage,
|
||||
recipientNickname: finished.scope == .directMessage ? context.nickname : nil,
|
||||
senderPeerID: finished.peerID,
|
||||
mentions: nil,
|
||||
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.
|
||||
WaveformCache.shared.purge(url: finished.fileURL)
|
||||
@@ -181,7 +247,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
|
||||
// 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 {
|
||||
SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session)
|
||||
return nil
|
||||
@@ -193,33 +259,46 @@ final class ChatLiveVoiceCoordinator {
|
||||
return nil
|
||||
}
|
||||
|
||||
let isPrivate = scope == .directMessage
|
||||
let message = BitchatMessage(
|
||||
sender: context.resolveNickname(for: peerID),
|
||||
sender: nickname,
|
||||
content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)",
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: context.nickname,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: isPrivate ? context.nickname : nil,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
|
||||
let assembly = Assembly(
|
||||
burstID: burstID,
|
||||
peerID: peerID,
|
||||
scope: scope,
|
||||
nickname: nickname,
|
||||
message: message,
|
||||
fileURL: fileURL,
|
||||
fileHandle: handle
|
||||
)
|
||||
|
||||
// Full inbound pipeline: store append, unread, notification.
|
||||
context.handlePrivateMessage(message)
|
||||
// DM bubbles ride the full inbound pipeline (store append, unread,
|
||||
// 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
|
||||
// with the app frontmost and live voice enabled.
|
||||
if PTTSettings.liveVoiceEnabled,
|
||||
PTTSettings.isAppActive,
|
||||
context.selectedPrivateChatPeer == peerID {
|
||||
let isViewing = switch scope {
|
||||
case .directMessage: context.selectedPrivateChatPeer == peerID
|
||||
case .publicMesh: context.isViewingPublicMeshTimeline
|
||||
}
|
||||
if PTTSettings.liveVoiceEnabled, PTTSettings.isAppActive, isViewing {
|
||||
assembly.player = PTTBurstPlayer()
|
||||
}
|
||||
|
||||
@@ -227,6 +306,13 @@ final class ChatLiveVoiceCoordinator {
|
||||
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) {
|
||||
assembly.receivedBytes += packet.encode().count
|
||||
let elapsed = Date().timeIntervalSince(assembly.firstPacketAt)
|
||||
@@ -328,10 +414,11 @@ final class ChatLiveVoiceCoordinator {
|
||||
try? assembly.fileHandle?.close()
|
||||
assembly.fileHandle = nil
|
||||
assemblies.removeValue(forKey: assembly.burstID)
|
||||
updatePublicTalkerIndicator()
|
||||
|
||||
guard assembly.deliveredFrames > 0 else {
|
||||
// Nothing audible ever arrived — drop the empty bubble.
|
||||
context.removePrivateMessage(withID: assembly.messageID)
|
||||
removeBubble(of: assembly)
|
||||
try? FileManager.default.removeItem(at: assembly.fileURL)
|
||||
context.notifyUIChanged()
|
||||
return
|
||||
@@ -342,12 +429,13 @@ final class ChatLiveVoiceCoordinator {
|
||||
WaveformCache.shared.purge(url: assembly.fileURL)
|
||||
// Republish so the row re-renders without its LIVE treatment even if
|
||||
// no finalized note ever arrives to swap in.
|
||||
context.upsertPrivateMessage(assembly.message, in: assembly.peerID)
|
||||
republishBubble(of: assembly)
|
||||
|
||||
pruneFinishedBursts()
|
||||
finishedBursts[assembly.burstID] = FinishedBurst(
|
||||
messageID: assembly.messageID,
|
||||
peerID: assembly.peerID,
|
||||
scope: assembly.scope,
|
||||
fileURL: assembly.fileURL,
|
||||
messageTimestamp: assembly.messageTimestamp,
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
assembly.idleTimeout?.cancel()
|
||||
assembly.gapRedrain?.cancel()
|
||||
@@ -363,7 +469,8 @@ final class ChatLiveVoiceCoordinator {
|
||||
try? assembly.fileHandle?.close()
|
||||
assembly.fileHandle = nil
|
||||
assemblies.removeValue(forKey: assembly.burstID)
|
||||
context.removePrivateMessage(withID: assembly.messageID)
|
||||
updatePublicTalkerIndicator()
|
||||
removeBubble(of: assembly)
|
||||
WaveformCache.shared.purge(url: assembly.fileURL)
|
||||
try? FileManager.default.removeItem(at: assembly.fileURL)
|
||||
context.notifyUIChanged()
|
||||
|
||||
@@ -187,6 +187,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
@MainActor
|
||||
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
|
||||
@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
|
||||
/// `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
|
||||
@MainActor
|
||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||
@@ -1776,6 +1790,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
/// Handle incoming public message
|
||||
@MainActor
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,33 +70,56 @@ extension ChatViewModel {
|
||||
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:
|
||||
/// live push-to-talk when the selected DM peer can hear it now (mesh
|
||||
/// reachable + established Noise session), otherwise the classic
|
||||
/// record-then-send voice note. Either way the release delivers a normal
|
||||
/// voice note through `sendVoiceNote(at:)`.
|
||||
/// live push-to-talk when the audience can hear it now — a DM peer that
|
||||
/// is mesh-reachable with an established Noise session, or the public
|
||||
/// mesh channel — otherwise the classic record-then-send voice note.
|
||||
/// Either way the release delivers a normal voice note through
|
||||
/// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble.
|
||||
@MainActor
|
||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||
guard PTTSettings.liveVoiceEnabled,
|
||||
let selectedPeer = selectedPrivateChatPeer,
|
||||
!selectedPeer.isGeoDM, !selectedPeer.isGeoChat, !selectedPeer.isGroup
|
||||
else {
|
||||
switch liveVoiceTarget() {
|
||||
case .peer(let peerID):
|
||||
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
|
||||
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()
|
||||
}
|
||||
// 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`.
|
||||
|
||||
@@ -76,6 +76,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
func start(shouldShow: Bool) {
|
||||
guard shouldShow, state == .idle else { return }
|
||||
let session = sessionProvider()
|
||||
SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session)
|
||||
activeSession = session
|
||||
state = .requestingPermission
|
||||
Task {
|
||||
@@ -144,6 +145,10 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
activeSession = nil
|
||||
|
||||
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() }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,14 +120,21 @@ struct AppInfoView: View {
|
||||
|
||||
enum HowToUse {
|
||||
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
||||
static let instructions: [LocalizedStringKey] = [
|
||||
"app_info.how_to_use.set_nickname",
|
||||
"app_info.how_to_use.change_channels",
|
||||
"app_info.how_to_use.open_sidebar",
|
||||
"app_info.how_to_use.start_dm",
|
||||
"app_info.how_to_use.clear_chat",
|
||||
"app_info.how_to_use.commands"
|
||||
]
|
||||
/// The instruction strings flowed into one comma-separated
|
||||
/// paragraph. The translations carry their legacy bullet-list
|
||||
/// prefix ("• "), so it is stripped here.
|
||||
static var paragraph: String {
|
||||
[
|
||||
String(localized: "app_info.how_to_use.set_nickname"),
|
||||
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)
|
||||
.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
|
||||
HStack(spacing: 12) {
|
||||
SectionHeader(Strings.appearanceTitle)
|
||||
@@ -220,17 +237,19 @@ struct AppInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// How to Use
|
||||
// Voice
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.HowToUse.title)
|
||||
SectionHeader(Strings.Voice.title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in
|
||||
Text(instruction)
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
FeatureRow(info: Strings.Voice.live)
|
||||
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
|
||||
@@ -286,6 +305,17 @@ struct AppInfoView: View {
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionHeader(Strings.Legend.title)
|
||||
@@ -307,17 +337,6 @@ struct AppInfoView: View {
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.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)
|
||||
.contentShape(Circle())
|
||||
.overlay(
|
||||
@@ -266,7 +284,13 @@ private extension ContentComposerView {
|
||||
.accessibilityValue(
|
||||
voiceRecordingVM.state.isActive
|
||||
? 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(
|
||||
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 {
|
||||
var nickname = "me"
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var isViewingPublicMeshTimeline = false
|
||||
var blockedPeers: Set<PeerID> = []
|
||||
|
||||
private(set) var handledPrivateMessages: [BitchatMessage] = []
|
||||
private(set) var appendedPublicMessages: [BitchatMessage] = []
|
||||
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
||||
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
||||
private(set) var removedMessageIDs: [String] = []
|
||||
private(set) var talkerUpdates: [String?] = []
|
||||
|
||||
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
||||
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
||||
func handlePrivateMessage(_ message: BitchatMessage) { handledPrivateMessages.append(message) }
|
||||
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
||||
upsertedMessages.append((message, peerID))
|
||||
}
|
||||
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
||||
upsertedPublicMessages.append(message)
|
||||
}
|
||||
@discardableResult
|
||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
||||
removedMessageIDs.append(messageID)
|
||||
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() {}
|
||||
}
|
||||
|
||||
@@ -230,6 +246,62 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
#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() {
|
||||
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff.m4a") == Data(hexString: "00112233445566ff"))
|
||||
// Uniquified copies keep the leading hex run.
|
||||
|
||||
@@ -134,6 +134,46 @@ struct RelayControllerTests {
|
||||
#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
|
||||
func requestSync_neverRelaysEvenWithTTLHeadroom() async {
|
||||
let decision = RelayController.decide(
|
||||
|
||||
@@ -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 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) |
|
||||
|
||||
*(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.
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
## 6. Playback policy — when does it actually make sound?
|
||||
|
||||
@@ -36,6 +36,10 @@ public enum MessageType: UInt8 {
|
||||
// an internet gateway peer.
|
||||
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 {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
@@ -53,6 +57,7 @@ public enum MessageType: UInt8 {
|
||||
case .ping: return "ping"
|
||||
case .pong: return "pong"
|
||||
case .nostrCarrier: return "nostrCarrier"
|
||||
case .voiceFrame: return "voiceFrame"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user