diff --git a/app/src/debug/java/com/bitchat/android/testhook/PttTestAudioAnalyzer.kt b/app/src/debug/java/com/bitchat/android/testhook/PttTestAudioAnalyzer.kt new file mode 100644 index 00000000..636145aa --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/PttTestAudioAnalyzer.kt @@ -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() + } + } +} diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt index 8aa8a303..7379b5ef 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceCapture.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceCapture.kt new file mode 100644 index 00000000..a3214825 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceCapture.kt @@ -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(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 + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt new file mode 100644 index 00000000..05223b48 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt @@ -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>() + 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() + private val finishedBursts = linkedMapOf() + private val _liveMessageIDs = MutableStateFlow>(emptySet()) + val liveMessageIDs: StateFlow> = _liveMessageIDs.asStateFlow() + private val _activePublicTalker = MutableStateFlow(null) + val activePublicTalker: StateFlow = _activePublicTalker.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _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_.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() } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoicePreferences.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoicePreferences.kt new file mode 100644 index 00000000..ed3c3268 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoicePreferences.kt @@ -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() + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/PttAudioPlayer.kt b/app/src/main/java/com/bitchat/android/features/voice/PttAudioPlayer.kt new file mode 100644 index 00000000..d1897655 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/PttAudioPlayer.kt @@ -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(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) { + 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() } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt b/app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt new file mode 100644 index 00000000..b8bc6e52 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt @@ -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) : 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() + 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() + 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 { + val frameCost = 2 + frame.size + if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) { + droppedFrameCount++ + return emptyList() + } + val output = mutableListOf() + 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 { + 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) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt b/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt index 9d5fe122..be2fe90e 100644 --- a/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt +++ b/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt @@ -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 = _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 } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index f2bb54c6..0e4f29af 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 498339ce..40460ceb 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 7a15b963..038cd960 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 1ec24690..22f17867 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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(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, diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 1ec1baea..9ff9856d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -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() + var pendingBytes = 0 + var inFlight = false + var retryScheduled = false + } + + private val sendLock = Any() + private val sendStates = mutableMapOf() // SERIALIZATION: Actor to serialize all broadcast operations @OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class) private val broadcasterActor = broadcasterScope.actor( - 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() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 6542e6e8..28cd3d02 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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(capacity = 128) private val directPeers = ConcurrentHashMap.newKeySet() 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, diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 063fe4f8..7b357149 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 05a5bf00..9434fc6e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -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}") diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index b00c6a31..37a295e8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index a82746c4..6b5c6d16 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index e3c0388a..3f684679 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 5a0cc697..3efe758d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index cba647a7..aacdafd4 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -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), diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index fffcdab7..4125d5ca 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -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. } } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 47ee8d75..9ef9e969 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -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? { diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 203e29b5..c4f52675 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -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. diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f58e2cc4..cf0faaa1 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 301be917..4d350ee4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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() ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 16933537..0cb0fdc5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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, @@ -73,6 +76,22 @@ class ChatViewModel( mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath) } + fun createVoiceRecorder(toPeerIDOrNull: String?, channelOrNull: String?): VoiceRecorder { + val context = getApplication().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) } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index cc429dc8..1269fe6b 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -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 = 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 } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 93b364e2..f6d90538 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1872,6 +1872,7 @@ fun PrivateChatSheet( onSendFileNote = { peer, channel, path -> viewModel.sendFileNote(peer, channel, path) }, + recorderFactory = viewModel::createVoiceRecorder, showCommandSuggestions = false, commandSuggestions = emptyList(), showMentionSuggestions = false, diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index 563f1f02..1545cd2c 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -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() } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index c646d570..de18baf0 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index f6f27a7f..02bfb2d3 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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, diff --git a/app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt b/app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt new file mode 100644 index 00000000..4c1200df --- /dev/null +++ b/app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt index 96aba61c..ba4bb0cb 100644 --- a/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt @@ -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 -> diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index c8de2c55..8de1e063 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -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/.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 diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index b3c04b70..7abe8085 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -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", diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/PttTestAudioAnalyzer.kt b/wear/src/debug/java/com/bitchat/watch/testhook/PttTestAudioAnalyzer.kt new file mode 100644 index 00000000..231b4a55 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/PttTestAudioAnalyzer.kt @@ -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() + } + } +} diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt index 6b68c628..1f6d5cd8 100644 --- a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -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 { diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index e128a08a..fd03e9f3 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -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() } diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index d66dd751..eeb09295 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -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 diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt index 7111cd1c..72bdc8af 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -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", diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index 7c902ea5..d91f864b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -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(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) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index 05ebf23c..1d179a0b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -60,9 +60,20 @@ fun DmScreen( val myPeerID = mesh?.myPeerID ?: "" val palette = LocalBitchatPalette.current var viewerPath by remember { mutableStateOf(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) { diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt index 3687ec3d..3cb86df3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -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) diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt index 7080c933..7ec162af 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -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) } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt index c2fbf983..e27f132e 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -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)