feat(voice): add live push-to-talk

This commit is contained in:
callebtc 2026-07-31 17:40:37 +02:00
parent 9b69c9f08e
commit cbc59aaf8b
46 changed files with 2715 additions and 71 deletions

View File

@ -0,0 +1,142 @@
package com.bitchat.android.testhook
import android.media.MediaCodec
import android.media.MediaExtractor
import java.io.File
import java.nio.ByteOrder
import kotlin.math.sqrt
internal data class PttTestAudioAnalysis(
val decodedSamples: Long,
val rms: Double,
val silentBlockFraction: Double,
val longestSilentBlockRun: Int,
val zeroCrossingsPerSecond: Double
)
/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */
internal object PttTestAudioAnalyzer {
private const val BLOCK_SAMPLES = 1_024
private const val SILENT_BLOCK_RMS = 0.015
private const val SAMPLE_RATE = 16_000.0
fun analyze(file: File): PttTestAudioAnalysis {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
try {
extractor.setDataSource(file.absolutePath)
val track = (0 until extractor.trackCount).firstOrNull { index ->
extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true
} ?: error("received live stream has no audio track")
extractor.selectTrack(track)
val format = extractor.getTrackFormat(track)
val mime = format.getString("mime") ?: error("received live stream has no audio MIME")
val activeDecoder = MediaCodec.createDecoderByType(mime).apply {
configure(format, null, null, 0)
start()
}
decoder = activeDecoder
val info = MediaCodec.BufferInfo()
var inputEnded = false
var outputEnded = false
var idlePolls = 0
var decodedSamples = 0L
var sumSquares = 0.0
var zeroCrossings = 0L
var previousSample: Short? = null
var blockSquares = 0.0
var blockSamples = 0
var blocks = 0
var silentBlocks = 0
var silentRun = 0
var longestSilentRun = 0
while (!outputEnded && idlePolls < 500) {
if (!inputEnded) {
val inputIndex = activeDecoder.dequeueInputBuffer(10_000L)
if (inputIndex >= 0) {
val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input")
input.clear()
val size = extractor.readSampleData(input, 0)
if (size < 0) {
activeDecoder.queueInputBuffer(
inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
} else {
activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0
else -> if (outputIndex >= 0) {
idlePolls = 0
activeDecoder.getOutputBuffer(outputIndex)?.let { output ->
output.position(info.offset)
output.limit(info.offset + info.size)
output.order(ByteOrder.LITTLE_ENDIAN)
val pcm = output.asShortBuffer()
while (pcm.hasRemaining()) {
val sample = pcm.get()
val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble()
val square = normalized * normalized
sumSquares += square
blockSquares += square
decodedSamples++
blockSamples++
previousSample?.let { previous ->
if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) {
zeroCrossings++
}
}
previousSample = sample
if (blockSamples == BLOCK_SAMPLES) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
} else {
silentRun = 0
}
blockSquares = 0.0
blockSamples = 0
}
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
activeDecoder.releaseOutputBuffer(outputIndex, false)
}
}
}
if (!outputEnded) error("received live stream decoder did not finish")
if (decodedSamples == 0L) error("received live stream decoded to no PCM")
if (blockSamples > 0) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
}
}
return PttTestAudioAnalysis(
decodedSamples = decodedSamples,
rms = sqrt(sumSquares / decodedSamples),
silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks,
longestSilentBlockRun = longestSilentRun,
zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples
)
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
extractor.release()
}
}
}

View File

@ -5,6 +5,12 @@ import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.features.voice.LiveVoiceEvent
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceScope
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.LiveVoiceCapture
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
@ -80,6 +86,8 @@ object TestHookDriver {
"file_send" -> fileSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
"ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(context, intent)
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
@ -491,6 +499,131 @@ object TestHookDriver {
return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled)
}
// MARK: - Live push-to-talk
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
val requestedPeer = intent.getStringExtra("peer")
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val mesh = mesh(context)
LiveVoicePreferences.setEnabled(context, true)
val recipient = requestedPeer?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)
?: return err("ptt_send", "no active mesh route for private conversation")
}
if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) {
val handshake = handshake(context, recipient.meshPeerID, intent)
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
}
val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) }
val recorder = LiveVoiceCapture(
File(context.filesDir, "voicenotes/outgoing"),
target,
syntheticPcm = true
)
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
delay(durationMs)
val finalFile = recorder.stop(canceled = false)
?: return err("ptt_send", "capture did not produce a finalized note")
val captureStats = recorder.stats()
if (finalFile != pendingFile || !finalFile.isFile) {
return err("ptt_send", "finalized note is unavailable")
}
val content = withContext(Dispatchers.IO) { finalFile.readBytes() }
val packet = BitchatFilePacket(
fileName = finalFile.name,
fileSize = content.size.toLong(),
mimeType = "audio/mp4",
content = content
)
val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note")
val transferId = sha256Hex(encoded)
return coroutineScope {
val completion = async(Dispatchers.Default) {
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
}
delay(50)
val sendError = dispatchFileSend(
context,
intent,
mesh,
recipient?.meshPeerID,
packet,
transferId
)
if (sendError != null) {
completion.cancel()
return@coroutineScope sendError.put("cmd", "ptt_send")
}
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer")
if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed")
ok("ptt_send")
.put("live", true)
.put("scope", if (recipient == null) "public" else "dm")
.put("duration_ms", durationMs)
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
.put("bytes", content.size)
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
.put("encoded_frames", captureStats.encodedFrames)
.put("data_packets", captureStats.dataPackets)
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
.put("outbound_packets", captureStats.outboundPackets)
.put("delivered_packets", captureStats.deliveredPackets)
}
}
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val expectedScope = if (intent.getStringExtra("scope") == "public") {
LiveVoiceScope.PUBLIC_MESH
} else {
LiveVoiceScope.DIRECT_MESSAGE
}
LiveVoicePreferences.setEnabled(context, true)
var finished: LiveVoiceEvent.Finished? = null
var liveSnapshot: File? = null
val absorbed = withTimeoutOrNull(timeoutMs) {
LiveVoiceManager.getInstance(context).events.first { event ->
val matches = event.scope == expectedScope &&
(fromPeer == null || event.peerID == fromPeer)
if (matches && event is LiveVoiceEvent.Finished) {
finished = event
liveSnapshot = runCatching {
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
snapshot.parentFile?.mkdirs()
File(event.path).copyTo(snapshot, overwrite = true)
}
}.getOrNull()
}
matches && event is LiveVoiceEvent.Absorbed
} as LiveVoiceEvent.Absorbed
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
val analysis = liveSnapshot?.let { snapshot ->
try {
withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) }
} finally {
snapshot.delete()
}
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
return ok("ptt_recv")
.put("live_observed", finished != null)
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
.put("from", absorbed.peerID)
.put("burst_id", absorbed.burstID)
.put("frames", finished?.frames ?: 0)
.put("data_packets", finished?.dataPackets ?: 0)
.put("bytes", finished?.bytes ?: 0)
.put("expected_packets", finished?.expectedPackets ?: 0)
.put("missing_packets", finished?.missingPackets ?: 0)
.put("decoded_samples", analysis.decodedSamples)
.put("rms", analysis.rms)
.put("silent_block_fraction", analysis.silentBlockFraction)
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
}
// MARK: - Raw packet injection
private fun rawSend(context: Context, intent: Intent): JSONObject {

View File

@ -0,0 +1,362 @@
package com.bitchat.android.features.voice
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.media.MediaRecorder
import android.util.Log
import java.io.File
import java.nio.ByteOrder
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin
/** Destination selected for one hold gesture. Calls must be safe from a capture thread. */
fun interface LiveVoiceTarget {
fun send(packet: ByteArray)
}
internal data class LiveVoiceCaptureStats(
val queuedPcmFrames: Int,
val encodedFrames: Int,
val dataPackets: Int,
val droppedOversizeFrames: Int,
val outboundPackets: Int,
val deliveredPackets: Int
)
/**
* AudioRecord + MediaCodec capture used only when a live mesh route is available.
*
* Encoded access units are streamed as iOS-compatible burst packets while the same units are
* muxed into an ordinary `.m4a`, which the existing voice-note path sends on release.
*/
internal class LiveVoiceCapture(
private val outputDirectory: File,
private val target: LiveVoiceTarget,
private val burstID: ByteArray = VoiceBurstPacket.makeBurstID(),
/** Debug Mesh Lab uses a deterministic tone so physical tests never capture ambient audio. */
private val syntheticPcm: Boolean = false
) {
companion object {
private const val TAG = "LiveVoiceCapture"
private const val SAMPLE_RATE = 16_000
private const val CHANNEL_COUNT = 1
private const val BIT_RATE = 16_000
private const val SAMPLES_PER_AAC_FRAME = 1_024
private const val MIN_VALID_DURATION_MS = 600L
private const val CODEC_TIMEOUT_US = 10_000L
private const val CODEC_INPUT_DEADLINE_MS = 1_000L
private const val SYNTHETIC_TONE_HZ = 440.0
private const val SYNTHETIC_TONE_AMPLITUDE = 8_000
private const val AAC_FRAME_DURATION_NS = 64_000_000L
private const val OUTBOUND_QUEUE_CAPACITY = 256
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 10_000L
}
private val running = AtomicBoolean(false)
private val amplitude = AtomicInteger(0)
private val queuedPcmFrames = AtomicInteger(0)
private val encodedFrames = AtomicInteger(0)
private val outboundPackets = AtomicInteger(0)
private val deliveredPackets = AtomicInteger(0)
private val outboundQueue = LinkedBlockingQueue<ByteArray>(OUTBOUND_QUEUE_CAPACITY)
private val senderRunning = AtomicBoolean(false)
private val packetizer = VoiceBurstPacketizer(burstID)
private var streamStarted = false
private var startedAtMs = 0L
private var totalSamples = 0L
private var audioRecord: AudioRecord? = null
private var codec: MediaCodec? = null
private var muxer: MediaMuxer? = null
private var muxerTrack = -1
private var muxerStarted = false
private var outputFile: File? = null
private var captureThread: Thread? = null
private var senderThread: Thread? = null
@SuppressLint("MissingPermission")
fun start(): File? {
if (running.get()) return outputFile
return try {
outputDirectory.mkdirs()
val burstHex = VoiceBurstPacket.burstIDHex(burstID)
val file = File(outputDirectory, "voice_$burstHex.m4a")
if (file.exists()) file.delete()
outputFile = file
val format = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC,
SAMPLE_RATE,
CHANNEL_COUNT
).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, SAMPLES_PER_AAC_FRAME * 4)
}
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
codec = encoder
val mediaMuxer = MediaMuxer(file.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
muxer = mediaMuxer
val record = if (syntheticPcm) {
null
} else {
val minBuffer = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
if (minBuffer <= 0) error("AudioRecord buffer unavailable: $minBuffer")
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
max(minBuffer * 4, SAMPLES_PER_AAC_FRAME * 16)
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
it.release()
error("AudioRecord did not initialize")
}
it.startRecording()
if (it.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
it.release()
error("AudioRecord did not start")
}
}
}
audioRecord = record
startedAtMs = System.currentTimeMillis()
senderRunning.set(true)
senderThread = Thread(::senderLoop, "bitchat-ptt-sender").also(Thread::start)
running.set(true)
captureThread = Thread(::captureLoop, "bitchat-ptt-capture").also(Thread::start)
file
} catch (error: Exception) {
Log.w(TAG, "Live capture unavailable; caller will fall back to a voice note: ${error.message}")
releaseResources()
outputFile?.delete()
outputFile = null
null
}
}
fun pollAmplitude(): Int = amplitude.get()
fun stats(): LiveVoiceCaptureStats = LiveVoiceCaptureStats(
queuedPcmFrames = queuedPcmFrames.get(),
encodedFrames = encodedFrames.get(),
dataPackets = packetizer.dataPacketCount,
droppedOversizeFrames = packetizer.droppedFrameCount,
outboundPackets = outboundPackets.get(),
deliveredPackets = deliveredPackets.get()
)
fun stop(canceled: Boolean): File? {
val wasRunning = running.getAndSet(false)
if (wasRunning) {
runCatching { audioRecord?.stop() }
runCatching { captureThread?.join(3_000L) }
}
captureThread = null
val elapsedMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
val durationMs = (encodedFrames.get().toLong() * SAMPLES_PER_AAC_FRAME * 1_000L) / SAMPLE_RATE
val valid = !canceled && elapsedMs >= MIN_VALID_DURATION_MS &&
durationMs >= MIN_VALID_DURATION_MS && encodedFrames.get() > 0 &&
(outputFile?.length() ?: 0L) > 0L
packetizer.flush().forEach(::queueOutbound)
val controlKind = if (valid) {
VoiceBurstPacket.Kind.End(packetizer.dataPacketCount, durationMs.coerceAtMost(0xFFFF_FFFFL))
} else {
VoiceBurstPacket.Kind.Canceled
}
VoiceBurstPacket.create(burstID, packetizer.nextSequence, controlKind)
?.encode()
?.let(::queueOutbound)
senderRunning.set(false)
runCatching { senderThread?.join(OUTBOUND_DRAIN_TIMEOUT_MS) }
if (senderThread?.isAlive == true) {
Log.w(TAG, "Live voice sender did not drain before timeout")
senderThread?.interrupt()
}
senderThread = null
val file = outputFile
outputFile = null
if (!valid) {
file?.delete()
return null
}
return file
}
private fun captureLoop() {
val pcm = ShortArray(SAMPLES_PER_AAC_FRAME)
var nextSyntheticFrameNs = System.nanoTime() + AAC_FRAME_DURATION_NS
try {
while (running.get()) {
val read = if (syntheticPcm) {
val waitNs = nextSyntheticFrameNs - System.nanoTime()
if (waitNs > 0L) {
Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt())
}
nextSyntheticFrameNs += AAC_FRAME_DURATION_NS
val firstSample = totalSamples
pcm.indices.forEach { index ->
val phase = 2.0 * Math.PI * SYNTHETIC_TONE_HZ *
(firstSample + index).toDouble() / SAMPLE_RATE.toDouble()
pcm[index] = (sin(phase) * SYNTHETIC_TONE_AMPLITUDE).roundToInt().toShort()
}
pcm.size
} else {
audioRecord?.read(pcm, 0, pcm.size, AudioRecord.READ_BLOCKING) ?: break
}
if (read <= 0) continue
amplitude.set(pcm.take(read).maxOfOrNull { abs(it.toInt()) } ?: 0)
queuePcm(pcm, read, endOfStream = false)
drainEncoder(endOfStream = false)
}
queuePcm(pcm, 0, endOfStream = true)
drainEncoder(endOfStream = true)
} catch (error: Exception) {
Log.w(TAG, "Live capture stopped after codec/audio failure: ${error.message}")
} finally {
releaseResources()
}
}
private fun queuePcm(samples: ShortArray, count: Int, endOfStream: Boolean) {
val encoder = codec ?: return
val deadlineNs = System.nanoTime() + CODEC_INPUT_DEADLINE_MS * 1_000_000L
while (true) {
val index = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
val input = encoder.getInputBuffer(index)
?: error("AAC encoder returned a null input buffer")
input.clear()
input.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, count)
val presentationUs = (totalSamples * 1_000_000L) / SAMPLE_RATE
totalSamples += count
encoder.queueInputBuffer(
index,
0,
count * 2,
presentationUs,
if (endOfStream) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
)
if (!endOfStream) queuedPcmFrames.incrementAndGet()
return
}
// Pull encoded output before retrying so transient codec backpressure cannot discard
// the already-read 64 ms microphone block.
drainEncoder(endOfStream = false)
if (System.nanoTime() >= deadlineNs) {
error("AAC encoder input remained unavailable")
}
}
}
private fun drainEncoder(endOfStream: Boolean) {
val encoder = codec ?: return
val info = MediaCodec.BufferInfo()
var sawEnd = false
var idlePolls = 0
while (!sawEnd && (!endOfStream || idlePolls < 100)) {
when (val index = encoder.dequeueOutputBuffer(info, if (endOfStream) CODEC_TIMEOUT_US else 0L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> {
idlePolls++
if (!endOfStream) return
}
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (muxerStarted) error("AAC output format changed twice")
muxerTrack = muxer?.addTrack(encoder.outputFormat) ?: -1
muxer?.start()
muxerStarted = true
}
else -> if (index >= 0) {
idlePolls = 0
val output = encoder.getOutputBuffer(index)
if (output != null && info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
output.position(info.offset)
output.limit(info.offset + info.size)
if (muxerStarted && muxerTrack >= 0) {
muxer?.writeSampleData(muxerTrack, output.duplicate(), info)
}
val accessUnit = ByteArray(info.size)
output.get(accessUnit)
emitEncodedFrame(accessUnit)
}
sawEnd = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
encoder.releaseOutputBuffer(index, false)
}
}
}
}
private fun emitEncodedFrame(frame: ByteArray) {
if (frame.isEmpty()) return
if (!streamStarted) {
streamStarted = true
VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)?.encode()?.let(::queueOutbound)
}
encodedFrames.incrementAndGet()
packetizer.add(frame).forEach(::queueOutbound)
// At the target bitrate a packet fits one frame; flushing immediately avoids latency.
packetizer.flush().forEach(::queueOutbound)
}
private fun queueOutbound(packet: ByteArray) {
outboundQueue.put(packet.copyOf())
outboundPackets.incrementAndGet()
}
private fun senderLoop() {
try {
while (senderRunning.get() || outboundQueue.isNotEmpty()) {
val packet = outboundQueue.poll(100L, TimeUnit.MILLISECONDS) ?: continue
target.send(packet)
deliveredPackets.incrementAndGet()
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: Exception) {
Log.w(TAG, "Live voice network sender stopped: ${error.message}")
} finally {
senderRunning.set(false)
}
}
private fun releaseResources() {
runCatching { audioRecord?.stop() }
runCatching { audioRecord?.release() }
audioRecord = null
runCatching { codec?.stop() }
runCatching { codec?.release() }
codec = null
if (muxerStarted) runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
muxerStarted = false
muxerTrack = -1
}
}

View File

@ -0,0 +1,478 @@
package com.bitchat.android.features.voice
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.AppStateStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.Date
import java.util.TreeMap
import java.util.UUID
enum class LiveVoiceScope { DIRECT_MESSAGE, PUBLIC_MESH }
sealed interface LiveVoiceEvent {
val peerID: String
val burstID: String
val scope: LiveVoiceScope
data class Started(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope
) : LiveVoiceEvent
data class Finished(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope,
val dataPackets: Int,
val frames: Int,
val bytes: Int,
val expectedPackets: Int?,
val missingPackets: Int,
val path: String
) : LiveVoiceEvent
data class Canceled(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope
) : LiveVoiceEvent
data class Absorbed(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope,
val finalizedPath: String
) : LiveVoiceEvent
}
/** Shared phone/Wear receiver: bounded assembly, live playback, bubble state and note absorption. */
class LiveVoiceManager private constructor(private val context: Context) {
companion object {
private const val TAG = "LiveVoiceManager"
private const val MAX_CONCURRENT_ASSEMBLIES = 8
private const val MAX_BURST_BYTES = 384 * 1_024
private const val INBOUND_BYTES_PER_SECOND = 6_000
private const val MAX_BUFFERED_PACKETS = 128
private const val GAP_SKIP_MS = 550L
private const val IDLE_TIMEOUT_MS = 3_000L
private const val FINISHED_TTL_MS = 10 * 60 * 1_000L
private const val FINISHED_CAP = 32
// The manager constructor immediately narrows this to context.applicationContext.
@SuppressLint("StaticFieldLeak")
@Volatile private var instance: LiveVoiceManager? = null
fun getInstance(context: Context): LiveVoiceManager = instance ?: synchronized(this) {
instance ?: LiveVoiceManager(context.applicationContext).also { instance = it }
}
fun burstIDFromVoiceFileName(fileName: String): String? {
if (!fileName.startsWith("voice_")) return null
val id = fileName.removePrefix("voice_").take(16)
return id.takeIf { value ->
value.length == 16 && value.all { it.digitToIntOrNull(16) != null }
}?.lowercase()
}
}
private data class AssemblyKey(
val peerID: String,
val scope: LiveVoiceScope,
val burstID: String
)
private class Assembly(
val key: AssemblyKey,
val nickname: String,
val message: BitchatMessage,
var file: File,
val output: FileOutputStream,
val startedAtMs: Long,
val player: PttAudioPlayer?
) {
val buffered = TreeMap<Int, List<ByteArray>>()
var nextSequence = 1
var deliveredFrames = 0
var deliveredPackets = 0
var missingPackets = 0
var receivedBytes = 0
var endTotalPackets: Int? = null
var idleJob: Job? = null
var gapJob: Job? = null
}
private data class FinishedBurst(
val key: AssemblyKey,
val messageID: String,
val nickname: String,
val file: File,
val timestamp: Date,
val expiresAtMs: Long,
val dataPackets: Int,
val frames: Int,
val bytes: Int
)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val assemblies = linkedMapOf<AssemblyKey, Assembly>()
private val finishedBursts = linkedMapOf<AssemblyKey, FinishedBurst>()
private val _liveMessageIDs = MutableStateFlow<Set<String>>(emptySet())
val liveMessageIDs: StateFlow<Set<String>> = _liveMessageIDs.asStateFlow()
private val _activePublicTalker = MutableStateFlow<String?>(null)
val activePublicTalker: StateFlow<String?> = _activePublicTalker.asStateFlow()
private val _events = MutableSharedFlow<LiveVoiceEvent>(extraBufferCapacity = 64)
val events: SharedFlow<LiveVoiceEvent> = _events.asSharedFlow()
@Volatile private var appForeground = false
@Volatile private var visibleScope: LiveVoiceScope? = null
@Volatile private var visiblePeerID: String? = null
private var activePlayer: PttAudioPlayer? = null
init {
liveDirectory().listFiles()
?.filter { it.name.startsWith("voice_live_") }
?.forEach { it.delete() }
}
fun setAppForeground(foreground: Boolean) {
appForeground = foreground
if (!foreground) {
synchronized(this) {
activePlayer?.stop()
activePlayer = null
assemblies.values.forEach { assembly -> assembly.player?.stop() }
}
}
}
fun showPublicMesh() {
visibleScope = LiveVoiceScope.PUBLIC_MESH
visiblePeerID = null
}
fun showDirectMessage(peerID: String) {
visibleScope = LiveVoiceScope.DIRECT_MESSAGE
visiblePeerID = peerID
}
fun clearVisibleConversation() {
visibleScope = null
visiblePeerID = null
}
/** Returns false only when the frame itself violates the live-voice wire/resource contract. */
@Synchronized
fun handleFrame(
peerID: String,
nickname: String,
scope: LiveVoiceScope,
payload: ByteArray,
timestampMs: Long
): Boolean {
val packet = VoiceBurstPacket.decode(payload) ?: return false
if (!LiveVoicePreferences.isEnabled(context)) return true
val burstHex = VoiceBurstPacket.burstIDHex(packet.burstID)
val key = AssemblyKey(peerID, scope, burstHex)
var assembly = assemblies[key]
if (assembly == null) {
if (packet.kind is VoiceBurstPacket.Kind.End || packet.kind == VoiceBurstPacket.Kind.Canceled) {
return true
}
if (assemblies.size >= MAX_CONCURRENT_ASSEMBLIES) return false
assembly = createAssembly(key, nickname, timestampMs) ?: return false
assemblies[key] = assembly
publishLiveState()
_events.tryEmit(LiveVoiceEvent.Started(peerID, burstHex, scope))
}
assembly.receivedBytes += payload.size
val elapsedSeconds = ((System.currentTimeMillis() - assembly.startedAtMs).coerceAtLeast(0L) / 1_000.0) + 2.0
if (
assembly.receivedBytes > MAX_BURST_BYTES ||
assembly.receivedBytes > (INBOUND_BYTES_PER_SECOND * elapsedSeconds).toInt()
) {
Log.w(TAG, "Dropping over-quota live voice burst")
finalizeAssembly(assembly)
return false
}
rescheduleIdle(assembly)
when (val kind = packet.kind) {
is VoiceBurstPacket.Kind.Start -> if (kind.codec != VoiceBurstCodec.AAC_LC_16K_MONO) {
cancelAssembly(assembly)
return false
}
is VoiceBurstPacket.Kind.Frames -> {
if (packet.sequence < assembly.nextSequence || packet.sequence in assembly.buffered) return true
if (assembly.buffered.size >= MAX_BUFFERED_PACKETS) return false
assembly.buffered[packet.sequence] = kind.frames
drainInOrder(assembly)
}
is VoiceBurstPacket.Kind.End -> {
assembly.endTotalPackets = kind.totalDataPackets
drainInOrder(assembly)
finalizeIfComplete(assembly)
}
VoiceBurstPacket.Kind.Canceled -> cancelAssembly(assembly)
}
return true
}
/** Swaps a finalized `voice_<burstID>.m4a` into its existing live row. */
@Synchronized
fun absorbFinalizedVoiceNote(message: BitchatMessage): Boolean {
if (message.type != BitchatMessageType.Audio) return false
val burstID = burstIDFromVoiceFileName(File(message.content).name) ?: return false
val messageScope = if (message.isPrivate) LiveVoiceScope.DIRECT_MESSAGE else LiveVoiceScope.PUBLIC_MESH
val peerID = message.senderPeerID ?: return false
assemblies.entries.firstOrNull {
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
}?.value?.let(::finalizeAssembly)
pruneFinished()
val entry = finishedBursts.entries.firstOrNull {
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
} ?: return false
val finished = entry.value
val replacement = message.copy(
id = finished.messageID,
timestamp = finished.timestamp,
sender = finished.nickname,
senderPeerID = peerID,
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
)
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
} else {
AppStateStore.upsertPublicMessage(replacement)
}
finished.file.delete()
finishedBursts.remove(entry.key)
_events.tryEmit(LiveVoiceEvent.Absorbed(peerID, burstID, messageScope, message.content))
return true
}
@Synchronized
fun reset() {
assemblies.values.toList().forEach(::cancelAssembly)
activePlayer?.stop()
activePlayer = null
finishedBursts.clear()
publishLiveState()
}
private fun createAssembly(key: AssemblyKey, nickname: String, timestampMs: Long): Assembly? {
val file = File(
liveDirectory(),
"voice_live_${key.burstID}_${key.peerID}_${if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
)
file.parentFile?.mkdirs()
file.delete()
val output = runCatching { FileOutputStream(file) }.getOrNull() ?: return null
val message = BitchatMessage(
id = UUID.randomUUID().toString().uppercase(),
sender = nickname,
content = file.absolutePath,
type = BitchatMessageType.Audio,
timestamp = Date(timestampMs),
isPrivate = key.scope == LiveVoiceScope.DIRECT_MESSAGE,
recipientNickname = AppStateStore.nickname.value.takeIf { key.scope == LiveVoiceScope.DIRECT_MESSAGE },
senderPeerID = key.peerID
)
if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.addPrivateMessage(key.peerID, message, isVisible(key.scope, key.peerID))
} else {
AppStateStore.addPublicMessage(message)
}
val player = if (canAutoplay(key)) {
activePlayer?.stop()
PttAudioPlayer().also { activePlayer = it }
} else null
return Assembly(key, nickname, message, file, output, System.currentTimeMillis(), player)
}
private fun drainInOrder(assembly: Assembly) {
while (true) {
val frames = assembly.buffered.remove(assembly.nextSequence)
if (frames != null) {
frames.forEach { frame ->
runCatching { assembly.output.write(AdtsFramer.frame(frame)) }
}
runCatching { assembly.output.flush() }
assembly.deliveredFrames += frames.size
assembly.deliveredPackets++
assembly.player?.enqueue(frames)
assembly.nextSequence = (assembly.nextSequence + 1) and 0xFFFF
assembly.gapJob?.cancel()
assembly.gapJob = null
continue
}
if (assembly.buffered.isNotEmpty() && assembly.gapJob == null) {
val key = assembly.key
assembly.gapJob = scope.launch {
delay(GAP_SKIP_MS)
synchronized(this@LiveVoiceManager) {
val current = assemblies[key] ?: return@synchronized
current.buffered.firstKey()?.let { skipGap(current, it) }
current.gapJob = null
drainInOrder(current)
finalizeIfComplete(current)
}
}
}
return
}
}
private fun finalizeIfComplete(assembly: Assembly) {
val total = assembly.endTotalPackets ?: return
if (assembly.nextSequence > total) finalizeAssembly(assembly)
}
private fun finalizeAssembly(assembly: Assembly) {
if (assemblies.remove(assembly.key) == null) return
assembly.idleJob?.cancel()
assembly.gapJob?.cancel()
while (assembly.buffered.isNotEmpty()) {
skipGap(assembly, assembly.buffered.firstKey())
drainInOrder(assembly)
if (assembly.gapJob != null) {
assembly.gapJob?.cancel()
assembly.gapJob = null
}
}
runCatching { assembly.output.close() }
assembly.player?.finishAfterDrain()
val missingPackets = assembly.endTotalPackets
?.let { total -> (total - assembly.deliveredPackets).coerceAtLeast(assembly.missingPackets) }
?: assembly.missingPackets
if (assembly.deliveredFrames == 0) {
removeBubble(assembly)
assembly.file.delete()
publishLiveState()
return
}
val fallback = File(
assembly.file.parentFile,
"voice_${assembly.key.burstID}_${assembly.key.peerID}_${if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
)
fallback.delete()
if (assembly.file.renameTo(fallback)) assembly.file = fallback
val finalizedMessage = assembly.message.copy(content = assembly.file.absolutePath)
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.upsertPrivateMessage(
assembly.key.peerID,
finalizedMessage,
isVisible(assembly.key.scope, assembly.key.peerID)
)
} else {
AppStateStore.upsertPublicMessage(finalizedMessage)
}
pruneFinished()
finishedBursts[assembly.key] = FinishedBurst(
key = assembly.key,
messageID = assembly.message.id,
nickname = assembly.nickname,
file = assembly.file,
timestamp = assembly.message.timestamp,
expiresAtMs = System.currentTimeMillis() + FINISHED_TTL_MS,
dataPackets = assembly.deliveredPackets,
frames = assembly.deliveredFrames,
bytes = assembly.receivedBytes
)
_events.tryEmit(
LiveVoiceEvent.Finished(
assembly.key.peerID,
assembly.key.burstID,
assembly.key.scope,
assembly.deliveredPackets,
assembly.deliveredFrames,
assembly.receivedBytes,
assembly.endTotalPackets,
missingPackets,
assembly.file.absolutePath
)
)
publishLiveState()
}
private fun cancelAssembly(assembly: Assembly) {
if (assemblies.remove(assembly.key) == null) return
assembly.idleJob?.cancel()
assembly.gapJob?.cancel()
assembly.player?.stop()
runCatching { assembly.output.close() }
removeBubble(assembly)
assembly.file.delete()
_events.tryEmit(
LiveVoiceEvent.Canceled(assembly.key.peerID, assembly.key.burstID, assembly.key.scope)
)
publishLiveState()
}
private fun skipGap(assembly: Assembly, nextAvailableSequence: Int) {
val distance = (nextAvailableSequence - assembly.nextSequence) and 0xFFFF
if (distance in 1..0x7FFF) assembly.missingPackets += distance
assembly.nextSequence = nextAvailableSequence
}
private fun removeBubble(assembly: Assembly) {
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.removePrivateMessage(assembly.message.id)
} else {
AppStateStore.removePublicMessage(assembly.message.id)
}
}
private fun rescheduleIdle(assembly: Assembly) {
assembly.idleJob?.cancel()
val key = assembly.key
assembly.idleJob = scope.launch {
delay(IDLE_TIMEOUT_MS)
synchronized(this@LiveVoiceManager) {
assemblies[key]?.let(::finalizeAssembly)
}
}
}
private fun publishLiveState() {
_liveMessageIDs.value = assemblies.values.mapTo(linkedSetOf()) { it.message.id }
_activePublicTalker.value = assemblies.values
.firstOrNull { it.key.scope == LiveVoiceScope.PUBLIC_MESH }
?.nickname
}
private fun pruneFinished() {
val now = System.currentTimeMillis()
finishedBursts.entries.removeAll { it.value.expiresAtMs <= now }
while (finishedBursts.size >= FINISHED_CAP) {
val oldest = finishedBursts.minByOrNull { it.value.expiresAtMs }?.key ?: break
finishedBursts.remove(oldest)
}
}
private fun canAutoplay(key: AssemblyKey): Boolean =
LiveVoicePreferences.isEnabled(context) && appForeground && isVisible(key.scope, key.peerID)
private fun isVisible(scope: LiveVoiceScope, peerID: String): Boolean =
visibleScope == scope && (scope == LiveVoiceScope.PUBLIC_MESH || visiblePeerID == peerID)
private fun liveDirectory(): File = File(context.cacheDir, "files/incoming").apply { mkdirs() }
}

View File

@ -0,0 +1,22 @@
package com.bitchat.android.features.voice
import android.content.Context
/** One preference gates live PTT sending and playback; finalized voice notes remain available. */
object LiveVoicePreferences {
private const val PREFERENCES = "bitchat_settings"
private const val ENABLED = "ptt.liveVoiceEnabled"
fun isEnabled(context: Context): Boolean =
context.applicationContext
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getBoolean(ENABLED, true)
fun setEnabled(context: Context, enabled: Boolean) {
context.applicationContext
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putBoolean(ENABLED, enabled)
.apply()
}
}

View File

@ -0,0 +1,156 @@
package com.bitchat.android.features.voice
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/** Small jitter-buffered AAC player for foreground live bursts. */
internal class PttAudioPlayer {
companion object {
private const val TAG = "PttAudioPlayer"
private const val SAMPLE_RATE = 16_000
private const val FRAME_DURATION_US = 64_000L
private const val JITTER_FRAMES = 6
private const val JITTER_DEADLINE_MS = 500L
private const val CODEC_TIMEOUT_US = 10_000L
}
private val frames = LinkedBlockingQueue<ByteArray>(128)
private val stopped = AtomicBoolean(false)
private val finishing = AtomicBoolean(false)
private val startedAt = System.currentTimeMillis()
private val worker = Thread(::playbackLoop, "bitchat-ptt-playback").also(Thread::start)
fun enqueue(accessUnits: List<ByteArray>) {
if (stopped.get()) return
accessUnits.forEach { frames.offer(it.copyOf()) }
}
fun finishAfterDrain() {
finishing.set(true)
}
fun stop() {
stopped.set(true)
worker.interrupt()
}
private fun playbackLoop() {
var decoder: MediaCodec? = null
var track: AudioTrack? = null
try {
while (
!stopped.get() && frames.size < JITTER_FRAMES &&
System.currentTimeMillis() - startedAt < JITTER_DEADLINE_MS &&
!finishing.get()
) {
Thread.sleep(10L)
}
if (stopped.get() || (frames.isEmpty() && finishing.get())) return
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, 1).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, 2)
// AudioSpecificConfig: AAC-LC, 16 kHz (index 8), mono.
setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0x14, 0x08)))
}
decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
configure(format, null, null, 0)
start()
}
val minBuffer = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(4_096)
track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build()
)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(minBuffer)
.build()
track.play()
val info = MediaCodec.BufferInfo()
var presentationUs = 0L
var inputEnded = false
var outputEnded = false
while (!stopped.get() && !outputEnded) {
if (!inputEnded) {
val frame = frames.poll(25L, TimeUnit.MILLISECONDS)
if (frame != null) {
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
decoder.getInputBuffer(index)?.apply {
clear()
put(frame)
}
decoder.queueInputBuffer(index, 0, frame.size, presentationUs, 0)
presentationUs += FRAME_DURATION_US
} else {
frames.offer(frame)
}
} else if (finishing.get()) {
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
decoder.queueInputBuffer(
index,
0,
0,
presentationUs,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
}
}
}
when (val index = decoder.dequeueOutputBuffer(info, CODEC_TIMEOUT_US)) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED,
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
else -> if (index >= 0) {
decoder.getOutputBuffer(index)?.let { output ->
if (info.size > 0) {
output.position(info.offset)
output.limit(info.offset + info.size)
val pcm = ByteArray(info.size)
output.get(pcm)
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
decoder.releaseOutputBuffer(index, false)
}
}
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: Exception) {
Log.w(TAG, "Live playback stopped: ${error.message}")
} finally {
stopped.set(true)
runCatching { track?.stop() }
runCatching { track?.release() }
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
}
}
}

View File

@ -0,0 +1,220 @@
package com.bitchat.android.features.voice
import java.io.ByteArrayOutputStream
import java.security.SecureRandom
/** iOS-compatible codec identifier carried by a live push-to-talk START packet. */
enum class VoiceBurstCodec(val value: UByte) {
AAC_LC_16K_MONO(0x01u);
companion object {
fun fromValue(value: UByte): VoiceBurstCodec? = entries.firstOrNull { it.value == value }
}
}
/**
* One live push-to-talk packet.
*
* Wire format (shared with iOS):
* `[burstID: 8][seq: UInt16 BE][flags: UInt8][payload...]`.
*/
class VoiceBurstPacket private constructor(
val burstID: ByteArray,
val sequence: Int,
val kind: Kind
) {
sealed interface Kind {
data class Start(val codec: VoiceBurstCodec) : Kind
class Frames(val frames: List<ByteArray>) : Kind {
override fun equals(other: Any?): Boolean =
other is Frames && frames.size == other.frames.size &&
frames.indices.all { frames[it].contentEquals(other.frames[it]) }
override fun hashCode(): Int = frames.fold(1) { acc, frame -> 31 * acc + frame.contentHashCode() }
}
data class End(val totalDataPackets: Int, val durationMs: Long) : Kind
data object Canceled : Kind
}
fun encode(): ByteArray {
val output = ByteArrayOutputStream(HEADER_SIZE + 16)
output.write(burstID)
output.write((sequence ushr 8) and 0xFF)
output.write(sequence and 0xFF)
when (val packetKind = kind) {
is Kind.Start -> {
output.write(FLAG_START)
output.write(packetKind.codec.value.toInt())
}
is Kind.Frames -> {
output.write(0)
packetKind.frames.forEach { frame ->
output.write((frame.size ushr 8) and 0xFF)
output.write(frame.size and 0xFF)
output.write(frame)
}
}
is Kind.End -> {
output.write(FLAG_END)
output.write((packetKind.totalDataPackets ushr 8) and 0xFF)
output.write(packetKind.totalDataPackets and 0xFF)
output.write(((packetKind.durationMs ushr 24) and 0xFF).toInt())
output.write(((packetKind.durationMs ushr 16) and 0xFF).toInt())
output.write(((packetKind.durationMs ushr 8) and 0xFF).toInt())
output.write((packetKind.durationMs and 0xFF).toInt())
}
Kind.Canceled -> output.write(FLAG_CANCELED)
}
return output.toByteArray()
}
companion object {
const val BURST_ID_SIZE = 8
const val HEADER_SIZE = BURST_ID_SIZE + 2 + 1
const val MAX_FRAMES_PER_PACKET = 8
const val MAX_CONTENT_BYTES = 210
private const val FLAG_START = 0x01
private const val FLAG_END = 0x02
private const val FLAG_CANCELED = 0x04
private val random = SecureRandom()
fun create(burstID: ByteArray, sequence: Int, kind: Kind): VoiceBurstPacket? {
if (burstID.size != BURST_ID_SIZE || sequence !in 0..0xFFFF) return null
when (kind) {
is Kind.Frames -> if (
kind.frames.isEmpty() ||
kind.frames.size > MAX_FRAMES_PER_PACKET ||
kind.frames.any { it.isEmpty() || it.size > 0xFFFF }
) return null
is Kind.End -> if (
kind.totalDataPackets !in 0..0xFFFF ||
kind.durationMs !in 0..0xFFFF_FFFFL
) return null
else -> Unit
}
return VoiceBurstPacket(burstID.copyOf(), sequence, kind)
}
fun decode(data: ByteArray): VoiceBurstPacket? {
if (data.size < HEADER_SIZE) return null
val burstID = data.copyOfRange(0, BURST_ID_SIZE)
val sequence = ((data[BURST_ID_SIZE].toInt() and 0xFF) shl 8) or
(data[BURST_ID_SIZE + 1].toInt() and 0xFF)
val flags = data[BURST_ID_SIZE + 2].toInt() and 0xFF
val offset = HEADER_SIZE
val kind: Kind = when (flags) {
FLAG_START -> {
if (offset >= data.size) return null
val codec = VoiceBurstCodec.fromValue(data[offset].toUByte()) ?: return null
Kind.Start(codec)
}
FLAG_END -> {
if (data.size - offset < 6) return null
val total = ((data[offset].toInt() and 0xFF) shl 8) or
(data[offset + 1].toInt() and 0xFF)
var duration = 0L
repeat(4) { index ->
duration = (duration shl 8) or (data[offset + 2 + index].toLong() and 0xFF)
}
Kind.End(total, duration)
}
FLAG_CANCELED -> Kind.Canceled
0 -> {
val frames = mutableListOf<ByteArray>()
var cursor = offset
while (cursor < data.size) {
if (data.size - cursor < 2 || frames.size >= MAX_FRAMES_PER_PACKET) return null
val length = ((data[cursor].toInt() and 0xFF) shl 8) or
(data[cursor + 1].toInt() and 0xFF)
cursor += 2
if (length <= 0 || data.size - cursor < length) {
return null
}
frames += data.copyOfRange(cursor, cursor + length)
cursor += length
}
if (frames.isEmpty()) return null
Kind.Frames(frames)
}
else -> return null
}
return create(burstID, sequence, kind)
}
fun makeBurstID(): ByteArray = ByteArray(BURST_ID_SIZE).also(random::nextBytes)
fun burstIDHex(burstID: ByteArray): String =
burstID.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
}
}
/** Greedy packetizer constrained so one Noise-wrapped frame stays out of fragmentation. */
class VoiceBurstPacketizer(
val burstID: ByteArray,
private val budget: Int = VoiceBurstPacket.MAX_CONTENT_BYTES
) {
private val pendingFrames = mutableListOf<ByteArray>()
private var pendingSize = 0
var nextSequence: Int = 1
private set
var dataPacketCount: Int = 0
private set
var droppedFrameCount: Int = 0
private set
fun add(frame: ByteArray): List<ByteArray> {
val frameCost = 2 + frame.size
if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) {
droppedFrameCount++
return emptyList()
}
val output = mutableListOf<ByteArray>()
if (
pendingFrames.isNotEmpty() &&
(VoiceBurstPacket.HEADER_SIZE + pendingSize + frameCost > budget ||
pendingFrames.size >= VoiceBurstPacket.MAX_FRAMES_PER_PACKET)
) {
output += flush()
}
pendingFrames += frame.copyOf()
pendingSize += frameCost
return output
}
fun flush(): List<ByteArray> {
if (pendingFrames.isEmpty()) return emptyList()
val packet = VoiceBurstPacket.create(
burstID,
nextSequence,
VoiceBurstPacket.Kind.Frames(pendingFrames.map(ByteArray::copyOf))
) ?: run {
pendingFrames.clear()
pendingSize = 0
return emptyList()
}
pendingFrames.clear()
pendingSize = 0
nextSequence = (nextSequence + 1) and 0xFFFF
dataPacketCount = (dataPacketCount + 1).coerceAtMost(0xFFFF)
return listOf(packet.encode())
}
}
/** Adds a seven-byte ADTS header to an ADTS-less AAC-LC/16 kHz/mono access unit. */
object AdtsFramer {
fun frame(payload: ByteArray): ByteArray {
val frameLength = payload.size + 7
require(frameLength <= 0x1FFF) { "AAC frame is too large for ADTS" }
return ByteArray(frameLength).also { output ->
output[0] = 0xFF.toByte()
output[1] = 0xF1.toByte()
output[2] = 0x60.toByte() // AAC-LC, 16 kHz frequency index, mono channel config high bit
output[3] = (0x40 or ((frameLength ushr 11) and 0x03)).toByte()
output[4] = ((frameLength ushr 3) and 0xFF).toByte()
output[5] = (((frameLength and 0x07) shl 5) or 0x1F).toByte()
output[6] = 0xFC.toByte()
payload.copyInto(output, destinationOffset = 7)
}
}
}

View File

@ -18,17 +18,38 @@ import java.util.Locale
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
* The resulting file has MIME audio/mp4.
*/
class VoiceRecorder(private val context: Context) {
class VoiceRecorder(
private val context: Context,
private val liveTarget: LiveVoiceTarget? = null
) {
companion object { private const val TAG = "VoiceRecorder" }
private var recorder: MediaRecorder? = null
private var liveCapture: LiveVoiceCapture? = null
private val _amplitude = MutableStateFlow(0)
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
private var outFile: File? = null
val isLive: Boolean
get() = liveCapture != null
fun start(): File? {
stop() // ensure previous session closed
if (liveTarget != null) {
val directory = File(context.filesDir, "voicenotes/outgoing")
val capture = LiveVoiceCapture(directory, liveTarget)
val liveFile = capture.start()
if (liveFile != null) {
liveCapture = capture
outFile = liveFile
return liveFile
}
}
return startClassic()
}
private fun startClassic(): File? {
return try {
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
@ -56,13 +77,19 @@ class VoiceRecorder(private val context: Context) {
fun pollAmplitude(): Int {
return try {
val amp = recorder?.maxAmplitude ?: 0
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
_amplitude.value = amp
amp
} catch (_: Exception) { 0 }
}
fun stop(): File? {
fun stop(canceled: Boolean = false): File? {
liveCapture?.let { capture ->
val file = capture.stop(canceled)
liveCapture = null
outFile = null
return file
}
try {
recorder?.apply {
try { stop() } catch (_: Exception) {}
@ -73,6 +100,10 @@ class VoiceRecorder(private val context: Context) {
val f = outFile
recorder = null
outFile = null
if (canceled) {
f?.delete()
return null
}
return f
}
}

View File

@ -68,8 +68,17 @@ class BluetoothConnectionManager(
}
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) {
packetBroadcaster.onLinkDisconnected(device.address, linkID)
delegate?.onDeviceDisconnected(device, linkID, peerID)
}
override fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
packetBroadcaster.onGattClientWriteComplete(deviceAddress, linkID, status)
}
override fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
packetBroadcaster.onGattServerNotificationComplete(deviceAddress, linkID, status)
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
delegate?.onRSSIUpdated(deviceAddress, rssi)
@ -485,4 +494,6 @@ interface BluetoothConnectionManagerDelegate {
fun onDeviceConnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?)
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) = Unit
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) = Unit
}

View File

@ -616,6 +616,16 @@ class BluetoothGattClientManager(
Log.d(TAG, "Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
delegate?.onGattClientWriteComplete(gatt.device.address, linkID, status)
}
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
val deviceAddress = gatt.device.address

View File

@ -297,6 +297,14 @@ class BluetoothGattServerManager(
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
delegate?.onGattServerNotificationComplete(
device.address,
serverLinkIDs[device.address],
status
)
}
}
// Proper cleanup sequencing to prevent race conditions

View File

@ -20,6 +20,7 @@ import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.util.*
import kotlin.math.sign
import kotlin.random.Random
@ -111,6 +112,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
private lateinit var gossipSyncManager: GossipSyncManager
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
@ -129,6 +132,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
private var terminated = false
init {
serviceScope.launch {
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
}
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
VerificationService.configure(encryptionService)
setupDelegates()
@ -607,6 +613,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
} catch (_: Exception) { }
}
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleLeave(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleLeave(routed) }
@ -949,6 +958,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
if (payload.isEmpty()) return
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
}
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
try {
val recipientPeerID = request.recipientPeerID
val packet = if (recipientPeerID == null) {
BitchatPacket(
version = 1u,
type = MessageType.VOICE_FRAME.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = request.payload,
ttl = MAX_TTL
)
} else {
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = MAX_TTL
)
}
broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet)))
} catch (e: Exception) {
Log.w(TAG, "Live voice frame send failed: ${e.message}")
}
}
fun prepareFilePrivate(
recipientPeerID: String,
file: com.bitchat.android.model.BitchatFilePacket,

View File

@ -1,10 +1,13 @@
package com.bitchat.android.mesh
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothStatusCodes
import android.os.Build
import android.util.Log
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket
@ -19,8 +22,8 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.actor
import java.util.ArrayDeque
/**
* Handles packet broadcasting to connected devices using actor pattern for serialization
@ -48,7 +51,10 @@ class BluetoothPacketBroadcaster(
companion object {
private const val TAG = "BluetoothPacketBroadcaster"
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
private const val MAX_PENDING_SENDS_PER_LINK = 256
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
private const val SEND_RETRY_DELAY_MS = 15L
private const val MAX_CALLBACK_RETRIES = 3
}
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
@ -119,11 +125,38 @@ class BluetoothPacketBroadcaster(
// Actor scope for the broadcaster
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
private enum class SendDirection { CLIENT_WRITE, SERVER_NOTIFICATION }
private data class SendKey(
val deviceAddress: String,
val linkID: String,
val direction: SendDirection
)
private data class PendingSend(
val data: ByteArray,
val device: BluetoothDevice,
val gatt: BluetoothGatt? = null,
val gattServer: BluetoothGattServer? = null,
val characteristic: BluetoothGattCharacteristic,
var callbackFailures: Int = 0
)
private class LinkSendState {
val pending = ArrayDeque<PendingSend>()
var pendingBytes = 0
var inFlight = false
var retryScheduled = false
}
private val sendLock = Any()
private val sendStates = mutableMapOf<SendKey, LinkSendState>()
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
private val broadcasterActor = broadcasterScope.actor<BroadcastRequest>(
capacity = Channel.UNLIMITED
capacity = 256
) {
for (request in channel) {
val accepted = try {
@ -443,21 +476,16 @@ class BluetoothPacketBroadcaster(
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
return try {
characteristic?.let { char ->
char.value = data
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
connectionTracker.removeSubscribedDevice(device)
connectionTracker.addressPeerMap.remove(device.address)
}
false
}
val server = gattServer ?: return false
val char = characteristic ?: return false
val linkID = connectionTracker.getDeviceConnection(device.address)
?.takeIf { !it.isClient }
?.linkID
?: return false
return enqueueSend(
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
)
}
/**
@ -467,20 +495,160 @@ class BluetoothPacketBroadcaster(
deviceConn: BluetoothConnectionTracker.DeviceConnection,
data: ByteArray
): Boolean {
return try {
deviceConn.characteristic?.let { char ->
char.value = data
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
connectionTracker.cleanupDeviceConnection(deviceConn.device.address)
val gatt = deviceConn.gatt ?: return false
val char = deviceConn.characteristic ?: return false
return enqueueSend(
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
)
}
/**
* Android permits only one outstanding GATT operation per link. Queueing here mirrors the
* readiness-driven iOS transport and prevents later voice frames from overwriting an operation
* that the controller has not completed yet.
*/
private fun enqueueSend(key: SendKey, request: PendingSend): Boolean {
val startNow = synchronized(sendLock) {
val state = sendStates.getOrPut(key, ::LinkSendState)
if (
state.pending.size >= MAX_PENDING_SENDS_PER_LINK ||
state.pendingBytes + request.data.size > MAX_PENDING_BYTES_PER_LINK
) {
Log.w(TAG, "BLE send queue full for ${key.direction}; rejecting ${request.data.size} bytes")
return false
}
state.pending.addLast(request)
state.pendingBytes += request.data.size
if (!state.inFlight && !state.retryScheduled) {
state.inFlight = true
true
} else {
false
}
}
if (startNow) startHead(key)
return true
}
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission", "ObsoleteSdkInt")
private fun startHead(key: SendKey) {
val request = synchronized(sendLock) { sendStates[key]?.pending?.peekFirst() } ?: return
val accepted = try {
when (key.direction) {
SendDirection.CLIENT_WRITE -> {
val gatt = request.gatt
if (gatt == null) {
false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(
request.characteristic,
request.data,
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
) == BluetoothStatusCodes.SUCCESS
} else {
request.characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
request.characteristic.value = request.data
gatt.writeCharacteristic(request.characteristic)
}
}
SendDirection.SERVER_NOTIFICATION -> {
val server = request.gattServer
if (server == null) {
false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
server.notifyCharacteristicChanged(
request.device,
request.characteristic,
false,
request.data
) == BluetoothStatusCodes.SUCCESS
} else {
request.characteristic.value = request.data
server.notifyCharacteristicChanged(request.device, request.characteristic, false)
}
}
}
} catch (error: Exception) {
Log.w(TAG, "BLE ${key.direction} failed to start: ${error.message}")
false
}
if (!accepted) rejectStart(key)
}
private fun rejectStart(key: SendKey): Boolean {
val schedule = synchronized(sendLock) {
val state = sendStates[key] ?: return false
state.inFlight = false
if (state.retryScheduled || state.pending.isEmpty()) false else {
state.retryScheduled = true
true
}
}
if (schedule) {
connectionScope.launch {
delay(SEND_RETRY_DELAY_MS)
val retry = synchronized(sendLock) {
val state = sendStates[key] ?: return@synchronized false
state.retryScheduled = false
if (!state.inFlight && state.pending.isNotEmpty()) {
state.inFlight = true
true
} else false
}
if (retry) startHead(key)
}
}
return false
}
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
completeSend(SendKey(deviceAddress, linkID, SendDirection.CLIENT_WRITE), status)
}
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
if (linkID == null) return
completeSend(SendKey(deviceAddress, linkID, SendDirection.SERVER_NOTIFICATION), status)
}
private fun completeSend(key: SendKey, status: Int) {
var retry = false
val startNext = synchronized(sendLock) {
val state = sendStates[key] ?: return
val head = state.pending.peekFirst() ?: run {
sendStates.remove(key)
return
}
state.inFlight = false
if (status != BluetoothGatt.GATT_SUCCESS && head.callbackFailures < MAX_CALLBACK_RETRIES) {
head.callbackFailures++
retry = true
false
} else {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "BLE ${key.direction} failed with status $status after retries")
}
state.pending.removeFirst()
state.pendingBytes -= head.data.size
if (state.pending.isEmpty()) {
sendStates.remove(key)
false
} else {
state.inFlight = true
true
}
}
}
if (retry) rejectStart(key) else if (startNext) startHead(key)
}
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
synchronized(sendLock) {
sendStates.keys.removeAll { key ->
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
}
}
}
/**
@ -499,6 +667,7 @@ class BluetoothPacketBroadcaster(
* Shutdown the broadcaster actor gracefully
*/
fun shutdown() {
synchronized(sendLock) { sendStates.clear() }
// Close the actor gracefully
broadcasterActor.close()

View File

@ -20,6 +20,7 @@ import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -111,6 +112,8 @@ class MeshCore(
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
private val packetProcessor = PacketProcessor(myPeerID)
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
private val directPeers = ConcurrentHashMap.newKeySet<String>()
val gossipSyncManager: GossipSyncManager =
@ -122,6 +125,9 @@ class MeshCore(
private var isActive = false
init {
scope.launch {
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
}
messageHandler.packetProcessor = packetProcessor
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
setupDelegates()
@ -481,6 +487,9 @@ class MeshCore(
} catch (_: Exception) { }
}
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleLeave(routed: RoutedPacket) {
scope.launch { messageHandler.handleLeave(routed) }
}
@ -611,6 +620,44 @@ class MeshCore(
}
}
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
if (payload.isEmpty()) return
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
}
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
try {
val recipientPeerID = request.recipientPeerID
val packet = if (recipientPeerID == null) {
BitchatPacket(
version = 1u,
type = MessageType.VOICE_FRAME.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = request.payload,
ttl = maxTtl
)
} else {
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = maxTtl
)
}
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
} catch (e: Exception) {
Log.w("MeshCore", "Live voice frame send failed: ${e.message}")
}
}
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -21,6 +21,7 @@ interface MeshService {
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -10,6 +10,8 @@ import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.util.toHexString
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoiceScope
import kotlinx.coroutines.*
import java.util.*
@ -140,7 +142,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
senderPeerID = peerID
)
delegate?.onMessageReceived(message)
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
delegate?.onMessageReceived(message)
}
// Send delivery ACK with generated message ID
sendDeliveryAck(uniqueMsgId, peerID)
@ -149,6 +153,16 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
}
com.bitchat.android.model.NoisePayloadType.VOICE_FRAME -> {
return LiveVoiceManager.getInstance(appContext).handleFrame(
peerID = peerID,
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
scope = LiveVoiceScope.DIRECT_MESSAGE,
payload = noisePayload.data,
timestampMs = packet.timestamp.toLong()
)
}
com.bitchat.android.model.NoisePayloadType.PEER_STATE -> {
val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data)
if (authenticatedState == null) {
@ -411,6 +425,27 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
// Message relay is now handled by centralized PacketRelayManager
}
/** Validate and ingest an ephemeral public push-to-talk frame. */
fun handlePublicVoiceFrame(routed: RoutedPacket): Boolean {
val packet = routed.packet
val peerID = routed.peerID ?: return false
if (peerID == myPeerID) return true
val recipient = packet.recipientID
if (recipient != null && !recipient.contentEquals(delegate?.getBroadcastRecipient())) return false
if (packet.timestamp > Long.MAX_VALUE.toULong()) return false
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
if (ageMs !in -30_000L..30_000L) return false
val peerInfo = delegate?.getPeerInfo(peerID)
if (peerInfo == null || !peerInfo.isVerifiedNickname) return false
return LiveVoiceManager.getInstance(appContext).handleFrame(
peerID = peerID,
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
scope = LiveVoiceScope.PUBLIC_MESH,
payload = packet.payload,
timestampMs = packet.timestamp.toLong()
)
}
/**
* Handle broadcast message with verification enforcement
@ -441,7 +476,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
delegate?.onMessageReceived(message)
}
return
} else if (isFileTransfer) {
Log.w(TAG, "FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)}")
@ -501,7 +538,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
recipientNickname = delegate?.getMyNickname()
)
Log.d(TAG, "📄 Saved incoming file to $savedPath")
delegate?.onMessageReceived(message)
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
delegate?.onMessageReceived(message)
}
return
} else if (isFileTransfer) {
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}")

View File

@ -136,6 +136,7 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
@ -296,6 +297,7 @@ interface PacketProcessorDelegate {
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleRequestSync(routed: RoutedPacket)

View File

@ -62,8 +62,16 @@ class PacketRelayManager(private val myPeerID: String) {
}
// Decrement TTL by 1
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
val networkSize = delegate?.getNetworkSize() ?: 1
val decrementedTtl = (packet.ttl - 1u).toUByte()
val voiceTtl = if (
MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME && networkSize > 6
) minOf(decrementedTtl, 5u.toUByte()) else decrementedTtl
val relayPacket = packet.copy(ttl = voiceTtl)
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
if (MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME) {
delay(Random.nextLong(8L, 26L))
}
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
val route = relayPacket.route

View File

@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER,
MessageType.VOICE_FRAME,
MessageType.LEAVE
)) {
return true

View File

@ -167,6 +167,23 @@ class UnifiedMeshService(
}
}
override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
if (recipientPeerID == null) {
when {
isBleEnabled() -> bluetooth.sendVoiceFrame(null, payload)
else -> wifiService()?.sendVoiceFrame(null, payload)
}
return
}
when {
isBleReady(recipientPeerID) -> bluetooth.sendVoiceFrame(recipientPeerID, payload)
isWifiReady(recipientPeerID) -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.sendVoiceFrame(recipientPeerID, payload)
else -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
}
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -21,6 +21,7 @@ enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered
VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u),

View File

@ -242,6 +242,7 @@ class NostrDirectMessageHandler(
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.VOICE_FRAME,
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
}
}

View File

@ -17,7 +17,8 @@ enum class MessageType(val value: UByte) {
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT(0x20u), // Fragmentation for large packets
REQUEST_SYNC(0x21u), // GCS-based sync request
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync
companion object {
fun fromValue(value: UByte): MessageType? {

View File

@ -196,6 +196,29 @@ object AppStateStore {
}
}
/** Replace a live media row by ID, or append it if the row was not admitted yet. */
fun upsertPublicMessage(msg: BitchatMessage) {
synchronized(this) {
val index = _publicMessages.value.indexOfFirst { it.id == msg.id }
if (index >= 0) {
_publicMessages.value = _publicMessages.value.toMutableList().also { it[index] = msg }
} else {
seenMessageIds.add(msg.id)
seenPublicMessageKeys.add(publicMessageKey(msg))
_publicMessages.value = _publicMessages.value + msg
}
}
}
fun removePublicMessage(messageID: String) {
synchronized(this) {
val existing = _publicMessages.value.firstOrNull { it.id == messageID } ?: return
_publicMessages.value = _publicMessages.value.filterNot { it.id == messageID }
seenMessageIds.remove(messageID)
seenPublicMessageKeys.remove(publicMessageKey(existing))
}
}
fun addPrivateMessage(
peerID: String,
msg: BitchatMessage,
@ -204,6 +227,38 @@ object AppStateStore {
addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true)
}
/** Replace-or-append used by a live voice row as its partial file becomes final media. */
fun upsertPrivateMessage(peerID: String, msg: BitchatMessage, forceRead: Boolean = false) {
synchronized(this) {
if (privateConversationWritesSuspended) return
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val matchingKey = map.keys.firstOrNull {
ContactDirectory.canonicalConversationId(it).equals(canonicalID, ignoreCase = true)
} ?: canonicalID
val messages = map[matchingKey].orEmpty().toMutableList()
val index = messages.indexOfFirst { it.id == msg.id }
if (index >= 0) {
messages[index] = msg
} else {
messages += msg
seenMessageIds.add(msg.id)
}
map[matchingKey] = messages
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
if (forceRead) {
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
}
conversationRepository?.upsertMessage(
conversationID = canonicalID,
aliases = privateConversationAliases(peerID, canonicalID),
displayName = ContactDirectory.resolve(canonicalID).displayName,
message = msg,
isRead = forceRead
)
}
}
/**
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
* notification state. Transport callbacks invoke this from their background worker.

View File

@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.Warning
@ -482,6 +483,9 @@ fun AboutSheet(
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) }
var liveVoiceEnabled by remember {
mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context))
}
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
val torProvider = remember { ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
@ -520,6 +524,23 @@ fun AboutSheet(
color = colorScheme.outlineVariant
)
SettingsToggleRow(
icon = Icons.Filled.Mic,
title = "Live push-to-talk",
subtitle = "Play voice bursts live on the mesh; voice notes are always sent on release",
checked = liveVoiceEnabled,
onCheckedChange = { enabled ->
liveVoiceEnabled = enabled
com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(context, enabled)
}
)
HorizontalDivider(
modifier = Modifier.padding(start = 54.dp),
thickness = 1.dp,
color = colorScheme.outlineVariant
)
// Proof of Work Toggle
SettingsToggleRow(
icon = Icons.Filled.Speed,

View File

@ -120,6 +120,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val nearbyNotesController = remember { NearbyNotesController.shared }
val liveVoiceManager = remember(context) {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context)
}
val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle()
val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
@ -142,10 +145,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
val observer = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(true)
liveVoiceManager.setAppForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(false)
liveVoiceManager.setAppForeground(false)
}
}
@ -157,6 +162,16 @@ fun ChatScreen(viewModel: ChatViewModel) {
onDispose {
lifecycle.removeObserver(observer)
nearbyNotesController.updateAppForeground(false)
liveVoiceManager.setAppForeground(false)
}
}
LaunchedEffect(isMeshTimeline, privateChatSheetPeer, selectedPrivatePeer) {
when {
privateChatSheetPeer != null -> liveVoiceManager.showDirectMessage(privateChatSheetPeer!!)
selectedPrivatePeer != null -> liveVoiceManager.showDirectMessage(selectedPrivatePeer!!)
isMeshTimeline -> liveVoiceManager.showPublicMesh()
else -> liveVoiceManager.clearVisibleConversation()
}
}
@ -400,6 +415,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
onSendFileNote = { peer, onionOrChannel, path ->
viewModel.sendFileNote(peer, onionOrChannel, path)
},
recorderFactory = viewModel::createVoiceRecorder,
showCommandSuggestions = showCommandSuggestions,
commandSuggestions = commandSuggestions,
@ -625,8 +641,13 @@ fun ChatInputSection(
nickname: String,
colorScheme: ColorScheme,
showMediaButtons: Boolean,
recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null,
modifier: Modifier = Modifier
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activePublicTalker by remember(context) {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context).activePublicTalker
}.collectAsState()
Column(
// Flat, slightly translucent screen background — the same treatment as the top bar, so the
// two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked
@ -703,6 +724,8 @@ fun ChatInputSection(
nickname = nickname,
showMediaButtons = showMediaButtons,
mentionPeerIdentities = mentionPeerIdentities,
recorderFactory = recorderFactory,
activePublicTalker = activePublicTalker,
modifier = Modifier.fillMaxWidth()
)
}

View File

@ -39,6 +39,9 @@ import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.VoiceRecorder
private data class ConversationLiveIdentityState(
val connectedPeerIDs: List<String>,
@ -73,6 +76,22 @@ class ChatViewModel(
mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun createVoiceRecorder(toPeerIDOrNull: String?, channelOrNull: String?): VoiceRecorder {
val context = getApplication<Application>().applicationContext
if (!LiveVoicePreferences.isEnabled(context)) return VoiceRecorder(context)
val recipientPeerID = toPeerIDOrNull?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)?.meshPeerID
}
val liveTarget = when {
toPeerIDOrNull != null && recipientPeerID != null && mesh.hasEstablishedSession(recipientPeerID) ->
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipientPeerID, payload) }
toPeerIDOrNull == null && channelOrNull == null && mesh.getActivePeerCount() > 0 ->
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(null, payload) }
else -> null
}
return VoiceRecorder(context, liveTarget)
}
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
mediaSendingManager.sendFileNote(toPeerIDOrNull, channelOrNull, filePath)
}

View File

@ -71,6 +71,7 @@ import androidx.compose.ui.unit.toSize
import kotlin.math.roundToInt
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import com.bitchat.android.features.voice.VoiceRecorder
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@ -317,6 +318,8 @@ fun MessageInput(
nickname: String,
showMediaButtons: Boolean,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
recorderFactory: ((String?, String?) -> VoiceRecorder)? = null,
activePublicTalker: String? = null,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
@ -325,6 +328,7 @@ fun MessageInput(
val hasText = value.text.isNotBlank()
val focusRequester = remember { FocusRequester() }
var isRecording by remember { mutableStateOf(false) }
var isLiveRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
val cashuToken = remember(value.text) {
@ -484,7 +488,9 @@ fun MessageInput(
)
if (placeholderAlpha > 0f) {
Text(
text = stringResource(R.string.type_a_message_placeholder),
text = if (
selectedPrivatePeer == null && currentChannel == null && activePublicTalker != null
) "$activePublicTalker is live" else stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = BitchatFontFamily
),
@ -518,7 +524,8 @@ fun MessageInput(
// scrolls off the left edge while live data streams in from the right.
val secs = (elapsedMs / 1000).toInt()
Text(
text = String.format("%02d:%02d", secs / 60, secs % 60),
text = (if (isLiveRecording) "LIVE · " else "") +
String.format("%02d:%02d", secs / 60, secs % 60),
fontFamily = BitchatFontFamily,
color = colorScheme.error,
fontSize = (BASE_FONT_SIZE - 4).sp
@ -628,12 +635,18 @@ fun MessageInput(
VoiceRecordButton(
isRecording = isRecording,
recorderFactory = recorderFactory?.let { factory ->
{ factory(latestSelectedPeer.value, latestChannel.value) }
},
courtesyActive = selectedPrivatePeer == null && currentChannel == null &&
activePublicTalker != null,
shouldCancel = { pos ->
cancelBounds?.inflate(cancelSlackPx)?.contains(pos) == true
},
onTrackFinger = { cancelFinger = it },
onStart = {
onStart = { live ->
isRecording = true
isLiveRecording = live
elapsedMs = 0L
// Keep existing focus to avoid IME collapse, but do not
// force-show the keyboard.
@ -647,6 +660,7 @@ fun MessageInput(
},
onFinish = { path ->
isRecording = false
isLiveRecording = false
// Extract and cache the waveform from the actual audio file
// so it matches the receiver's rendering.
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
@ -667,6 +681,7 @@ fun MessageInput(
// waveform over an empty field.
onCancel = {
isRecording = false
isLiveRecording = false
amplitude = 0
elapsedMs = 0L
}

View File

@ -1872,6 +1872,7 @@ fun PrivateChatSheet(
onSendFileNote = { peer, channel, path ->
viewModel.sendFileNote(peer, channel, path)
},
recorderFactory = viewModel::createVoiceRecorder,
showCommandSuggestions = false,
commandSuggestions = emptyList(),
showMentionSuggestions = false,

View File

@ -62,6 +62,8 @@ private const val ReleaseTailMs = 500L
@Composable
fun VoiceRecordButton(
modifier: Modifier = Modifier,
recorderFactory: (() -> VoiceRecorder)? = null,
courtesyActive: Boolean = false,
/**
* Recording state as the composer sees it. Drives the active tint so the button and the
* pill's border change together instead of one lagging the other.
@ -79,7 +81,7 @@ fun VoiceRecordButton(
* cancel target); null once the gesture ends.
*/
onTrackFinger: (Offset?) -> Unit = {},
onStart: () -> Unit,
onStart: (isLive: Boolean) -> Unit,
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
onFinish: (filePath: String) -> Unit,
/**
@ -109,6 +111,7 @@ fun VoiceRecordButton(
val latestOnCancel = rememberUpdatedState(onCancel)
val latestShouldCancel = rememberUpdatedState(shouldCancel)
val latestOnTrackFinger = rememberUpdatedState(onTrackFinger)
val latestRecorderFactory = rememberUpdatedState(recorderFactory)
// Set when this instance was composed, so presses inherited from whatever occupied this spot
// beforehand can be rejected.
@ -130,7 +133,7 @@ fun VoiceRecordButton(
ampJob = null
if (isCapturing) {
isCapturing = false
runCatching { recorder?.stop() }
runCatching { recorder?.stop(canceled = true) }
recorder = null
recordedFilePath = null
latestOnTrackFinger.value(null)
@ -141,8 +144,11 @@ fun VoiceRecordButton(
// Same disc, same sizing and the same press feedback as the camera and send buttons.
ComposerActionSurface(
isActive = isRecording || isCapturing,
isActive = isRecording || isCapturing || courtesyActive,
isPressed = isCapturing,
activeColor = if (courtesyActive && !isRecording && !isCapturing) {
androidx.compose.ui.graphics.Color(0xFFFFB300)
} else androidx.compose.ui.graphics.Color.Unspecified,
modifier = modifier
.onGloballyPositioned { buttonCoords = it }
.pointerInput(Unit) {
@ -169,12 +175,12 @@ fun VoiceRecordButton(
}
if (releasedEarly != null || stolenDuringArm) return@awaitEachGesture
val rec = VoiceRecorder(context)
val rec = latestRecorderFactory.value?.invoke() ?: VoiceRecorder(context)
val startedFile = rec.start()
if (startedFile == null) {
// Recorder refused to start; make sure the caller does not sit in a
// recording state that never began.
runCatching { rec.stop() }
runCatching { rec.stop(canceled = true) }
latestOnCancel.value()
return@awaitEachGesture
}
@ -183,7 +189,7 @@ fun VoiceRecordButton(
recordedFilePath = startedFile.absolutePath
recordingStart = System.currentTimeMillis()
isCapturing = true
latestOnStart.value()
latestOnStart.value(rec.isLive)
buzz()
ampJob?.cancel()
@ -198,7 +204,7 @@ fun VoiceRecordButton(
val file = recorder?.stop()
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
val path = file?.absolutePath ?: recordedFilePath?.takeIf { File(it).isFile }
recordedFilePath = null
latestOnTrackFinger.value(null)
buzz()
@ -237,10 +243,10 @@ fun VoiceRecordButton(
withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() }
}
if (isCapturing) {
val file = recorder?.stop()
val file = recorder?.stop(canceled = cancel)
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
val path = file?.absolutePath ?: recordedFilePath?.takeIf { File(it).isFile }
recordedFilePath = null
if (cancel) {
path?.let { runCatching { File(it).delete() } }

View File

@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -23,6 +25,7 @@ import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
import androidx.compose.ui.platform.LocalContext
@Composable
fun AudioMessageItem(
@ -38,6 +41,10 @@ fun AudioMessageItem(
showSender: Boolean = true
) {
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val liveMessageIDs by com.bitchat.android.features.voice.LiveVoiceManager
.getInstance(context).liveMessageIDs.collectAsState()
val isLive = message.id in liveMessageIDs
val path = message.content.trim()
// Derive sending progress if applicable
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
@ -78,6 +85,14 @@ fun AudioMessageItem(
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLive) {
androidx.compose.material3.Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 8.dp)
)
}
VoiceNotePlayer(
path = path,
progressOverride = overrideProgress,

View File

@ -1422,6 +1422,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendFilePrivate(recipientPeerID, file)
}
override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
meshCore.sendVoiceFrame(recipientPeerID, payload)
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -0,0 +1,116 @@
package com.bitchat.android.features.voice
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class VoiceBurstPacketTest {
private val burstID = ByteArray(8) { (it + 1).toByte() }
@Test
fun wirePacketsRoundTripWithIosLayout() {
val packets = listOf(
VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)!!,
VoiceBurstPacket.create(
burstID,
7,
VoiceBurstPacket.Kind.Frames(
listOf(byteArrayOf(0xDE.toByte(), 0xAD.toByte()), ByteArray(130) { 0x42 })
)
)!!,
VoiceBurstPacket.create(burstID, 42, VoiceBurstPacket.Kind.End(41, 2_688))!!,
VoiceBurstPacket.create(burstID, 3, VoiceBurstPacket.Kind.Canceled)!!
)
packets.forEach { expected ->
val actual = VoiceBurstPacket.decode(expected.encode())
assertNotNull(actual)
assertArrayEquals(expected.burstID, actual!!.burstID)
assertEquals(expected.sequence, actual.sequence)
assertEquals(expected.kind, actual.kind)
}
}
@Test
fun encodedGoldenVectorsMatchIos() {
val start = VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)!!.encode()
assertArrayEquals(
byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 1, 1),
start
)
val end = VoiceBurstPacket.create(
burstID,
42,
VoiceBurstPacket.Kind.End(41, 2_688)
)!!.encode()
assertArrayEquals(
byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 42, 2, 0, 41, 0, 0, 10, 0x80.toByte()),
end
)
}
@Test
fun rejectsMalformedPacketsAndConstruction() {
assertNull(VoiceBurstPacket.decode(byteArrayOf()))
assertNull(VoiceBurstPacket.decode(ByteArray(10)))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0xFF.toByte())))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0)))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0, 0, 16, 0xAB.toByte())))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 0, 1, 0x7F)))
assertNull(VoiceBurstPacket.create(byteArrayOf(1, 2), 0, VoiceBurstPacket.Kind.Canceled))
assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(emptyList())))
assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(listOf(byteArrayOf()))))
}
@Test
fun packetizerRespectsBudgetAndCounters() {
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
val frame = ByteArray(130) { 0x55 }
assertTrue(packetizer.add(frame).isEmpty())
val first = packetizer.add(frame).single()
assertEquals(1, VoiceBurstPacket.decode(first)!!.sequence)
val final = packetizer.flush().single()
assertEquals(2, VoiceBurstPacket.decode(final)!!.sequence)
assertEquals(2, packetizer.dataPacketCount)
assertEquals(3, packetizer.nextSequence)
assertTrue(packetizer.flush().isEmpty())
assertTrue(first.size + 1 + 16 <= 256)
}
@Test
fun packetizerBatchesEightOrBudgetAndAdtsHeaderIsValid() {
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
repeat(4) { assertTrue(packetizer.add(ByteArray(40) { 0x11 }).isEmpty()) }
val decoded = VoiceBurstPacket.decode(packetizer.flush().single())!!
assertEquals(4, (decoded.kind as VoiceBurstPacket.Kind.Frames).frames.size)
val framed = AdtsFramer.frame(byteArrayOf(1, 2, 3))
assertEquals(10, framed.size)
assertEquals(0xFF, framed[0].toInt() and 0xFF)
assertEquals(0xF1, framed[1].toInt() and 0xFF)
assertArrayEquals(byteArrayOf(1, 2, 3), framed.copyOfRange(7, framed.size))
}
@Test
fun packetizerReportsFramesThatCannotFitTheWireBudget() {
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
assertTrue(packetizer.add(ByteArray(198)).isEmpty())
assertEquals(1, packetizer.droppedFrameCount)
assertEquals(0, packetizer.dataPacketCount)
assertEquals(1, packetizer.nextSequence)
}
}

View File

@ -24,6 +24,7 @@ class BLEPacketPaddingPolicyTest {
MessageType.REQUEST_SYNC to false,
MessageType.FRAGMENT to false,
MessageType.FILE_TRANSFER to false,
MessageType.VOICE_FRAME to false,
MessageType.NOISE_ENCRYPTED to true,
MessageType.NOISE_HANDSHAKE to true
)
@ -50,7 +51,8 @@ class BLEPacketPaddingPolicyTest {
MessageType.LEAVE,
MessageType.REQUEST_SYNC,
MessageType.FRAGMENT,
MessageType.FILE_TRANSFER
MessageType.FILE_TRANSFER,
MessageType.VOICE_FRAME
)
publicTypes.forEach { type ->

View File

@ -248,7 +248,7 @@ command and stop the local relay/Tor fixture.
For day-to-day development there is a lighter-weight harness that drives a
debug-only broadcast receiver (`app/src/debug/`, never shipped in release)
exposing mesh operations over ADB: scan, connect, Noise handshake, DMs,
public broadcast, announce, file send/receive, BLE toggle, state dumps, and
public broadcast, live push-to-talk, announce, file send/receive, BLE toggle, state dumps, and
raw packet injection. Results are JSON files in the app sandbox polled by the
host (`cache/testhook/results/<id>.json`, also logged under tag `TestHook`).
@ -298,6 +298,8 @@ python3 tools/release_gate/mesh_lab.py scenario all \
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
| `broadcast` | public mesh message A→B |
| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption |
| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions |
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
| `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing |
| `file_private` | Noise-encrypted private file, digest match |
@ -326,7 +328,8 @@ See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`,
`favorite_status`, `verification_set`, `verification_status`, `file_send`,
`file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`).
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`,
`clear_results`).
### Troubleshooting

View File

@ -532,6 +532,96 @@ def scenario_broadcast(a: Device, b: Device) -> dict:
return {"send": send_result, "recv": recv_result}
def _ptt_one_way(
sender: Device,
receiver: Device,
sender_id: str,
receiver_id: str,
scope: str,
) -> dict:
# Long enough to expose sustained GATT/codec backpressure while remaining a quick gate.
send_args: dict[str, object] = {"duration_ms": 3_000}
if scope == "dm":
send_args["peer"] = receiver_id
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(
receiver.cmd_ok,
"ptt_recv",
240_000,
peer=sender_id,
scope="public" if scope == "public" else "dm",
)
time.sleep(2)
send = pool.submit(sender.cmd_ok, "ptt_send", 240_000, **send_args)
recv_result, send_result = recv.result(), send.result()
if not recv_result.get("live_observed") or recv_result.get("frames", 0) <= 0:
raise MeshLabError(f"live {scope} burst was not assembled before fallback: {recv_result}")
if recv_result.get("burst_id") != send_result.get("burst_id"):
raise MeshLabError(
f"live {scope} burst/final-note identity mismatch: send={send_result} recv={recv_result}"
)
encoded = int(send_result.get("encoded_frames", 0))
queued = int(send_result.get("queued_pcm_frames", 0))
sent_packets = int(send_result.get("data_packets", 0))
received_packets = int(recv_result.get("data_packets", 0))
expected_packets = int(recv_result.get("expected_packets", 0))
missing_packets = int(recv_result.get("missing_packets", -1))
received_frames = int(recv_result.get("frames", 0))
if int(send_result.get("dropped_oversize_frames", -1)) != 0:
raise MeshLabError(f"live {scope} encoder produced an unsent AAC frame: {send_result}")
# AAC encoders may emit up to two priming access units in addition to one unit per PCM block.
if queued <= 0 or encoded < queued or encoded > queued + 2 or sent_packets != encoded:
raise MeshLabError(f"live {scope} encoder/packetizer continuity failed: {send_result}")
outbound_packets = int(send_result.get("outbound_packets", 0))
delivered_packets = int(send_result.get("delivered_packets", -1))
if outbound_packets != delivered_packets or outbound_packets != sent_packets + 2:
raise MeshLabError(f"live {scope} network dispatch did not drain in order: {send_result}")
minimum_realtime_pcm_frames = int(3_000 / 64) - 2
if queued < minimum_realtime_pcm_frames:
raise MeshLabError(f"live {scope} capture fell behind real time: {send_result}")
if missing_packets != 0:
raise MeshLabError(f"live {scope} burst contained sequence gaps: {recv_result}")
if not (received_packets == expected_packets == sent_packets and received_frames == encoded):
raise MeshLabError(
f"live {scope} frame counts differ across the physical link: "
f"send={send_result} recv={recv_result}"
)
decoded_samples = int(recv_result.get("decoded_samples", 0))
if decoded_samples < max(1, received_frames - 2) * 1_024:
raise MeshLabError(f"live {scope} decoded PCM is truncated: {recv_result}")
rms = float(recv_result.get("rms", 0.0))
silent_fraction = float(recv_result.get("silent_block_fraction", 1.0))
longest_silent_run = int(recv_result.get("longest_silent_block_run", 999))
crossings_per_second = float(recv_result.get("zero_crossings_per_second", 0.0))
if rms < 0.05 or silent_fraction > 0.10 or longest_silent_run > 2:
raise MeshLabError(f"live {scope} decoded tone is silent or broken up: {recv_result}")
if not 650.0 <= crossings_per_second <= 1_150.0:
raise MeshLabError(f"live {scope} decoded tone continuity is distorted: {recv_result}")
return {"send": send_result, "recv": recv_result}
def scenario_ptt_dm(a: Device, b: Device) -> dict:
"""Gap-free Noise-encrypted PTT tone plus finalized note in both directions."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b)
b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a)
return {
"a_to_b": _ptt_one_way(a, b, id_a, id_b, "dm"),
"b_to_a": _ptt_one_way(b, a, id_b, id_a, "dm"),
}
def scenario_ptt_broadcast(a: Device, b: Device) -> dict:
"""Gap-free signed public PTT tone plus finalized note in both directions."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
return {
"a_to_b": _ptt_one_way(a, b, id_a, id_b, "public"),
"b_to_a": _ptt_one_way(b, a, id_b, id_a, "public"),
}
def scenario_file(
a: Device,
b: Device,
@ -779,6 +869,8 @@ SCENARIOS = {
"dm": scenario_dm,
"favorite_verification": scenario_favorite_verification,
"broadcast": scenario_broadcast,
"ptt_dm": scenario_ptt_dm,
"ptt_broadcast": scenario_ptt_broadcast,
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
# the small fixture is end-to-end receivable.
"file": lambda a, b: scenario_file(
@ -807,6 +899,8 @@ WATCH_SCENARIOS = [
"dm",
"favorite_verification",
"broadcast",
"ptt_dm",
"ptt_broadcast",
"raw",
"file",
"file_private",

View File

@ -0,0 +1,142 @@
package com.bitchat.watch.testhook
import android.media.MediaCodec
import android.media.MediaExtractor
import java.io.File
import java.nio.ByteOrder
import kotlin.math.sqrt
internal data class PttTestAudioAnalysis(
val decodedSamples: Long,
val rms: Double,
val silentBlockFraction: Double,
val longestSilentBlockRun: Int,
val zeroCrossingsPerSecond: Double
)
/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */
internal object PttTestAudioAnalyzer {
private const val BLOCK_SAMPLES = 1_024
private const val SILENT_BLOCK_RMS = 0.015
private const val SAMPLE_RATE = 16_000.0
fun analyze(file: File): PttTestAudioAnalysis {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
try {
extractor.setDataSource(file.absolutePath)
val track = (0 until extractor.trackCount).firstOrNull { index ->
extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true
} ?: error("received live stream has no audio track")
extractor.selectTrack(track)
val format = extractor.getTrackFormat(track)
val mime = format.getString("mime") ?: error("received live stream has no audio MIME")
val activeDecoder = MediaCodec.createDecoderByType(mime).apply {
configure(format, null, null, 0)
start()
}
decoder = activeDecoder
val info = MediaCodec.BufferInfo()
var inputEnded = false
var outputEnded = false
var idlePolls = 0
var decodedSamples = 0L
var sumSquares = 0.0
var zeroCrossings = 0L
var previousSample: Short? = null
var blockSquares = 0.0
var blockSamples = 0
var blocks = 0
var silentBlocks = 0
var silentRun = 0
var longestSilentRun = 0
while (!outputEnded && idlePolls < 500) {
if (!inputEnded) {
val inputIndex = activeDecoder.dequeueInputBuffer(10_000L)
if (inputIndex >= 0) {
val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input")
input.clear()
val size = extractor.readSampleData(input, 0)
if (size < 0) {
activeDecoder.queueInputBuffer(
inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
} else {
activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0
else -> if (outputIndex >= 0) {
idlePolls = 0
activeDecoder.getOutputBuffer(outputIndex)?.let { output ->
output.position(info.offset)
output.limit(info.offset + info.size)
output.order(ByteOrder.LITTLE_ENDIAN)
val pcm = output.asShortBuffer()
while (pcm.hasRemaining()) {
val sample = pcm.get()
val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble()
val square = normalized * normalized
sumSquares += square
blockSquares += square
decodedSamples++
blockSamples++
previousSample?.let { previous ->
if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) {
zeroCrossings++
}
}
previousSample = sample
if (blockSamples == BLOCK_SAMPLES) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
} else {
silentRun = 0
}
blockSquares = 0.0
blockSamples = 0
}
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
activeDecoder.releaseOutputBuffer(outputIndex, false)
}
}
}
if (!outputEnded) error("received live stream decoder did not finish")
if (decodedSamples == 0L) error("received live stream decoded to no PCM")
if (blockSamples > 0) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
}
}
return PttTestAudioAnalysis(
decodedSamples = decodedSamples,
rms = sqrt(sumSquares / decodedSamples),
silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks,
longestSilentBlockRun = longestSilentRun,
zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples
)
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
extractor.release()
}
}
}

View File

@ -4,6 +4,13 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.features.voice.LiveVoiceEvent
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceScope
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.LiveVoiceCapture
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.service.TransportBridgeService
@ -64,6 +71,8 @@ object WearTestHookDriver {
"verification_status" -> verificationStatus(context, intent.requiredString("peer"))
"raw_send" -> rawSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"ptt_send" -> pttSend(context, intent)
"state" -> state(context)
"clear_results" -> clearResults(context)
else -> err(cmd, "unknown command: $cmd")
@ -380,6 +389,104 @@ object WearTestHookDriver {
return err("file_recv", "timeout after ${timeoutMs}ms")
}
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L)
val fromPeer = intent.getStringExtra("peer")
val expectedScope = if (intent.getStringExtra("scope") == "public") {
LiveVoiceScope.PUBLIC_MESH
} else {
LiveVoiceScope.DIRECT_MESSAGE
}
LiveVoicePreferences.setEnabled(context, true)
var finished: LiveVoiceEvent.Finished? = null
var liveSnapshot: File? = null
val absorbed = withTimeoutOrNull(timeoutMs) {
LiveVoiceManager.getInstance(context).events.first { event ->
val matches = event.scope == expectedScope &&
(fromPeer == null || event.peerID == fromPeer)
if (matches && event is LiveVoiceEvent.Finished) {
finished = event
liveSnapshot = runCatching {
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
snapshot.parentFile?.mkdirs()
File(event.path).copyTo(snapshot, overwrite = true)
}
}.getOrNull()
}
matches && event is LiveVoiceEvent.Absorbed
} as LiveVoiceEvent.Absorbed
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
val analysis = liveSnapshot?.let { snapshot ->
try {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
PttTestAudioAnalyzer.analyze(snapshot)
}
} finally {
snapshot.delete()
}
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
return ok("ptt_recv")
.put("live_observed", finished != null)
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
.put("from", absorbed.peerID)
.put("burst_id", absorbed.burstID)
.put("frames", finished?.frames ?: 0)
.put("data_packets", finished?.dataPackets ?: 0)
.put("bytes", finished?.bytes ?: 0)
.put("expected_packets", finished?.expectedPackets ?: 0)
.put("missing_packets", finished?.missingPackets ?: 0)
.put("decoded_samples", analysis.decodedSamples)
.put("rms", analysis.rms)
.put("silent_block_fraction", analysis.silentBlockFraction)
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
}
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
val peerID = intent.getStringExtra("peer")
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
val mesh = mesh(context)
LiveVoicePreferences.setEnabled(context, true)
if (peerID != null && !mesh.hasEstablishedSession(peerID)) {
val handshake = handshake(context, peerID, intent)
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
}
val recorder = LiveVoiceCapture(
File(context.filesDir, "voicenotes/outgoing"),
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(peerID, payload) },
syntheticPcm = true
)
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
delay(durationMs)
val finalFile = recorder.stop(canceled = false)
?: return err("ptt_send", "capture did not produce a finalized note")
val captureStats = recorder.stats()
if (finalFile != pendingFile || !finalFile.isFile) {
return err("ptt_send", "finalized note is unavailable")
}
val content = finalFile.readBytes()
val packet = BitchatFilePacket(
fileName = finalFile.name,
fileSize = content.size.toLong(),
mimeType = "audio/mp4",
content = content
)
if (peerID == null) mesh.sendFileBroadcast(packet)
else mesh.sendFilePrivateEncrypted(peerID, packet)
return ok("ptt_send")
.put("live", true)
.put("scope", if (peerID == null) "public" else "dm")
.put("duration_ms", durationMs)
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
.put("bytes", content.size)
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
.put("encoded_frames", captureStats.encodedFrames)
.put("data_packets", captureStats.dataPackets)
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
.put("outbound_packets", captureStats.outboundPackets)
.put("delivered_packets", captureStats.deliveredPackets)
}
// MARK: - State
private fun state(context: Context): JSONObject {

View File

@ -137,6 +137,8 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(applicationContext)
.setAppForeground(true)
WearChatState.setAppInForeground(true)
WearChatState.openDmPeer?.let { peerID ->
WearChatState.openDm(peerID)
@ -146,6 +148,8 @@ class MainActivity : ComponentActivity() {
}
override fun onPause() {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(applicationContext)
.setAppForeground(false)
WearChatState.setAppInForeground(false)
super.onPause()
}

View File

@ -373,6 +373,10 @@ class WearMeshService private constructor(private val context: Context) {
meshCore.sendFileBroadcast(file)
}
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
meshCore.sendVoiceFrame(recipientPeerID, payload)
}
/**
* Noise-encrypted private file transfer with session/prep retry (mirrors the phone's
* dispatchFileSend): ensures an established session, then retries transient

View File

@ -54,7 +54,12 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette
* level so [VoiceRecordOverlay] can render full-screen outside this slot.
*/
@Composable
fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Modifier = Modifier) {
fun ChatActionBar(
onKeyboard: () -> Unit,
voice: VoiceNoteController,
busyTalker: String? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val palette = LocalBitchatPalette.current
@ -87,7 +92,11 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier:
.size(38.dp)
.clip(CircleShape)
.background(
if (voice.recording) MaterialTheme.colorScheme.primary else palette.inputButton
when {
voice.recording -> MaterialTheme.colorScheme.primary
busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300).copy(alpha = 0.28f)
else -> palette.inputButton
}
)
.pointerInput(Unit) {
detectTapGestures(
@ -115,8 +124,11 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier:
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = "push to talk",
tint = if (voice.recording) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.primary,
tint = when {
voice.recording -> MaterialTheme.colorScheme.onPrimary
busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300)
else -> MaterialTheme.colorScheme.primary
},
modifier = Modifier.size(18.dp)
)
}
@ -226,7 +238,7 @@ fun VoiceRecordOverlay(
.height(44.dp)
)
Text(
text = "%d:%02d".format(
text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60
) + " / 0:10",

View File

@ -27,6 +27,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
import androidx.wear.compose.foundation.rotary.rotaryScrollable
@ -59,14 +60,33 @@ import java.util.Locale
@Composable
fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
val context = LocalContext.current
val messages by AppStateStore.publicMessages.collectAsState()
val peers by AppStateStore.peers.collectAsState()
val unreadDms by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path ->
mesh?.let { sendVoiceNote(it, null, path) }
val liveVoiceManager = remember(context) {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context)
}
val busyTalker by liveVoiceManager.activePublicTalker.collectAsState()
val voice = rememberVoiceNoteController(
recorderFactory = {
val target = if (
mesh != null &&
com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context) &&
mesh.getPeerNicknames().isNotEmpty()
) com.bitchat.android.features.voice.LiveVoiceTarget { payload ->
mesh.sendVoiceFrame(null, payload)
} else null
com.bitchat.android.features.voice.VoiceRecorder(context, target)
}
) { path -> mesh?.let { sendVoiceNote(it, null, path) } }
androidx.compose.runtime.DisposableEffect(liveVoiceManager) {
liveVoiceManager.showPublicMesh()
onDispose { liveVoiceManager.clearVisibleConversation() }
}
ChatScaffold(
@ -84,7 +104,7 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
)
},
actionBar = {
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice)
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice, busyTalker = busyTalker)
}
)
@ -230,7 +250,10 @@ fun MessageItem(
path = message.content.trim(),
onOpen = onOpenImage
)
BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim())
BitchatMessageType.Audio -> VoiceNoteItem(
path = message.content.trim(),
messageID = message.id
)
BitchatMessageType.File -> {
val path = message.content.trim()
val file = remember(path) { File(path) }

View File

@ -60,9 +60,20 @@ fun DmScreen(
val myPeerID = mesh?.myPeerID ?: ""
val palette = LocalBitchatPalette.current
var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path ->
mesh?.let { sendVoiceNote(it, peerID, path) }
val liveVoiceManager = remember(context) {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context)
}
val voice = rememberVoiceNoteController(
recorderFactory = {
val target = if (
mesh != null && mesh.hasEstablishedSession(peerID) &&
com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)
) com.bitchat.android.features.voice.LiveVoiceTarget { payload ->
mesh.sendVoiceFrame(peerID, payload)
} else null
com.bitchat.android.features.voice.VoiceRecorder(context, target)
}
) { path -> mesh?.let { sendVoiceNote(it, peerID, path) } }
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
val identityRevision by WearPeerIdentityState.revision.collectAsState()
@ -74,9 +85,13 @@ fun DmScreen(
}
DisposableEffect(peerID) {
liveVoiceManager.showDirectMessage(peerID)
WearChatState.openDm(peerID)
WearNotificationCoordinator.getInstance(context).clearConversation(peerID)
onDispose { WearChatState.closeDm() }
onDispose {
WearChatState.closeDm()
liveVoiceManager.clearVisibleConversation()
}
}
LaunchedEffect(peerID) {

View File

@ -13,6 +13,10 @@ import androidx.compose.material.icons.filled.Verified
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -38,6 +42,7 @@ import com.bitchat.watch.ui.theme.colorForPeer
@Composable
fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
val context = LocalContext.current
val peers by AppStateStore.peers.collectAsState()
val unread by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
@ -45,6 +50,9 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
val palette = LocalBitchatPalette.current
val nicknames = mesh?.getPeerNicknames() ?: emptyMap()
val identityRevision by WearPeerIdentityState.revision.collectAsState()
var liveVoiceEnabled by remember {
mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context))
}
// Peers with unread messages float to the top so they are easy to see and reach.
val sortedPeers = androidx.compose.runtime.remember(
@ -92,6 +100,29 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
onClick = onEditNickname
)
}
item(key = "live_voice") {
Card(
onClick = {
liveVoiceEnabled = !liveVoiceEnabled
com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(
context,
liveVoiceEnabled
)
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp)
) {
Text(
text = if (liveVoiceEnabled) "Live push-to-talk: on" else "Live push-to-talk: off",
style = ChatVisualTokens.SenderStyle,
color = if (liveVoiceEnabled) MaterialTheme.colorScheme.primary else palette.textTertiary
)
Text(
text = "Tap to toggle; a voice note is still sent on release",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)
}
}
items(sortedPeers, key = { it }) { peerID ->
val nick = nicknames[peerID] ?: peerID.take(8)
val identity = WearPeerIdentityState.snapshot(peerID, mesh)

View File

@ -29,12 +29,15 @@ private const val LIVE_BARS = 32
class VoiceNoteController(
private val context: Context,
private val scope: CoroutineScope,
private val recorderFactory: () -> VoiceRecorder,
private val onSendVoice: (String) -> Unit
) {
private val recorder = VoiceRecorder(context.applicationContext)
private var recorder: VoiceRecorder? = null
var recording by mutableStateOf(false)
private set
var isLive by mutableStateOf(false)
private set
var elapsedMs by mutableLongStateOf(0L)
private set
var liveSamples by mutableStateOf(FloatArray(LIVE_BARS))
@ -45,7 +48,10 @@ class VoiceNoteController(
fun start() {
if (recording) return
recorder.start() ?: return
val activeRecorder = recorderFactory()
activeRecorder.start() ?: return
recorder = activeRecorder
isLive = activeRecorder.isLive
startedAt = System.currentTimeMillis()
elapsedMs = 0L
liveSamples = FloatArray(LIVE_BARS)
@ -54,7 +60,7 @@ class VoiceNoteController(
pollJob = scope.launch {
while (true) {
delay(AMPLITUDE_POLL_MS)
val amp = normalizeAmplitudeSample(recorder.pollAmplitude())
val amp = normalizeAmplitudeSample(recorder?.pollAmplitude() ?: 0)
liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp
val elapsed = System.currentTimeMillis() - startedAt
elapsedMs = elapsed
@ -74,7 +80,9 @@ class VoiceNoteController(
if (send) WearHaptics.click(context)
pollJob?.cancel()
pollJob = null
val file = recorder.stop()
val file = recorder?.stop(canceled = !send)
recorder = null
isLive = false
val elapsed = System.currentTimeMillis() - startedAt
if (send && file != null && elapsed >= MIN_RECORDING_MS) {
onSendVoice(file.absolutePath)
@ -85,8 +93,11 @@ class VoiceNoteController(
}
@Composable
fun rememberVoiceNoteController(onSendVoice: (String) -> Unit): VoiceNoteController {
fun rememberVoiceNoteController(
recorderFactory: () -> VoiceRecorder,
onSendVoice: (String) -> Unit
): VoiceNoteController {
val context = LocalContext.current
val scope = rememberCoroutineScope()
return remember { VoiceNoteController(context, scope, onSendVoice) }
return remember { VoiceNoteController(context, scope, recorderFactory, onSendVoice) }
}

View File

@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -41,6 +42,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -124,8 +126,12 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
* duration/progress. Playback via MediaPlayer.
*/
@Composable
fun VoiceNoteItem(path: String) {
fun VoiceNoteItem(path: String, messageID: String? = null) {
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val liveIDs by com.bitchat.android.features.voice.LiveVoiceManager
.getInstance(context).liveMessageIDs.collectAsState()
val isLive = messageID != null && messageID in liveIDs
var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) }
var playing by remember { mutableStateOf(false) }
var progress by remember { mutableFloatStateOf(0f) }
@ -175,6 +181,14 @@ fun VoiceNoteItem(path: String) {
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isLive) {
Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = ChatVisualTokens.SystemActionStyle,
modifier = Modifier.padding(end = 4.dp)
)
}
Box(
modifier = Modifier
.size(26.dp)