diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 95412b58..683a9dee 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -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 diff --git a/bitchat/Features/voice/PTTCaptureEngine.swift b/bitchat/Features/voice/PTTCaptureEngine.swift index 8150f7bf..e98f2458 100644 --- a/bitchat/Features/voice/PTTCaptureEngine.swift +++ b/bitchat/Features/voice/PTTCaptureEngine.swift @@ -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) { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 99a7e5ac..12e3f978 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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", diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 9ac7ebe9..6c259f5f 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -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 } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index 64cfa73e..9449dfa9 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -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 } } diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 92e3bec8..e81fabd5 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -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 ) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2657dcb8..412e4e32 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -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) } diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index 10b4f2c6..dc87fabf 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -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 diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 1ce72534..82bcc166 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -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): diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 967abbda..bf8c0270 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -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 diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index f5c0566a..c2c96a1c 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -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 diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index 1a45723e..62f13653 100644 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -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? var gapRedrain: Task? - 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() diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8c195162..38985d62 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -187,6 +187,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor var connectedPeers: Set { 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) } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 397c8f72..2d40cfea 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -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`. diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift index 0f3f4349..97d54ad2 100644 --- a/bitchat/ViewModels/VoiceRecordingViewModel.swift +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -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 } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 324f74d2..1b696234 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -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() } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index f8598e0e..6ebcd756 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -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") diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift index 4c2929e6..52f5172b 100644 --- a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift +++ b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift @@ -15,23 +15,39 @@ import BitFoundation private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { var nickname = "me" var selectedPrivateChatPeer: PeerID? + var isViewingPublicMeshTimeline = false var blockedPeers: Set = [] 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. diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift index 7c04fab6..cb18ec64 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -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( diff --git a/docs/PUSH-TO-TALK-DESIGN.md b/docs/PUSH-TO-TALK-DESIGN.md index 8c72db62..fc3a69fd 100644 --- a/docs/PUSH-TO-TALK-DESIGN.md +++ b/docs/PUSH-TO-TALK-DESIGN.md @@ -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? diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 4ddf5ab1..0ba2730f 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -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" } } }