mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
feat(voice): add live push-to-talk
This commit is contained in:
parent
9b69c9f08e
commit
cbc59aaf8b
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -0,0 +1,362 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
/** Destination selected for one hold gesture. Calls must be safe from a capture thread. */
|
||||
fun interface LiveVoiceTarget {
|
||||
fun send(packet: ByteArray)
|
||||
}
|
||||
|
||||
internal data class LiveVoiceCaptureStats(
|
||||
val queuedPcmFrames: Int,
|
||||
val encodedFrames: Int,
|
||||
val dataPackets: Int,
|
||||
val droppedOversizeFrames: Int,
|
||||
val outboundPackets: Int,
|
||||
val deliveredPackets: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* AudioRecord + MediaCodec capture used only when a live mesh route is available.
|
||||
*
|
||||
* Encoded access units are streamed as iOS-compatible burst packets while the same units are
|
||||
* muxed into an ordinary `.m4a`, which the existing voice-note path sends on release.
|
||||
*/
|
||||
internal class LiveVoiceCapture(
|
||||
private val outputDirectory: File,
|
||||
private val target: LiveVoiceTarget,
|
||||
private val burstID: ByteArray = VoiceBurstPacket.makeBurstID(),
|
||||
/** Debug Mesh Lab uses a deterministic tone so physical tests never capture ambient audio. */
|
||||
private val syntheticPcm: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "LiveVoiceCapture"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val CHANNEL_COUNT = 1
|
||||
private const val BIT_RATE = 16_000
|
||||
private const val SAMPLES_PER_AAC_FRAME = 1_024
|
||||
private const val MIN_VALID_DURATION_MS = 600L
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
private const val CODEC_INPUT_DEADLINE_MS = 1_000L
|
||||
private const val SYNTHETIC_TONE_HZ = 440.0
|
||||
private const val SYNTHETIC_TONE_AMPLITUDE = 8_000
|
||||
private const val AAC_FRAME_DURATION_NS = 64_000_000L
|
||||
private const val OUTBOUND_QUEUE_CAPACITY = 256
|
||||
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 10_000L
|
||||
}
|
||||
|
||||
private val running = AtomicBoolean(false)
|
||||
private val amplitude = AtomicInteger(0)
|
||||
private val queuedPcmFrames = AtomicInteger(0)
|
||||
private val encodedFrames = AtomicInteger(0)
|
||||
private val outboundPackets = AtomicInteger(0)
|
||||
private val deliveredPackets = AtomicInteger(0)
|
||||
private val outboundQueue = LinkedBlockingQueue<ByteArray>(OUTBOUND_QUEUE_CAPACITY)
|
||||
private val senderRunning = AtomicBoolean(false)
|
||||
private val packetizer = VoiceBurstPacketizer(burstID)
|
||||
private var streamStarted = false
|
||||
private var startedAtMs = 0L
|
||||
private var totalSamples = 0L
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var codec: MediaCodec? = null
|
||||
private var muxer: MediaMuxer? = null
|
||||
private var muxerTrack = -1
|
||||
private var muxerStarted = false
|
||||
private var outputFile: File? = null
|
||||
private var captureThread: Thread? = null
|
||||
private var senderThread: Thread? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(): File? {
|
||||
if (running.get()) return outputFile
|
||||
return try {
|
||||
outputDirectory.mkdirs()
|
||||
val burstHex = VoiceBurstPacket.burstIDHex(burstID)
|
||||
val file = File(outputDirectory, "voice_$burstHex.m4a")
|
||||
if (file.exists()) file.delete()
|
||||
outputFile = file
|
||||
|
||||
val format = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT
|
||||
).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, SAMPLES_PER_AAC_FRAME * 4)
|
||||
}
|
||||
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
||||
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
start()
|
||||
}
|
||||
codec = encoder
|
||||
val mediaMuxer = MediaMuxer(file.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
muxer = mediaMuxer
|
||||
val record = if (syntheticPcm) {
|
||||
null
|
||||
} else {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
if (minBuffer <= 0) error("AudioRecord buffer unavailable: $minBuffer")
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
max(minBuffer * 4, SAMPLES_PER_AAC_FRAME * 16)
|
||||
).also {
|
||||
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||
it.release()
|
||||
error("AudioRecord did not initialize")
|
||||
}
|
||||
it.startRecording()
|
||||
if (it.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
it.release()
|
||||
error("AudioRecord did not start")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioRecord = record
|
||||
startedAtMs = System.currentTimeMillis()
|
||||
senderRunning.set(true)
|
||||
senderThread = Thread(::senderLoop, "bitchat-ptt-sender").also(Thread::start)
|
||||
running.set(true)
|
||||
captureThread = Thread(::captureLoop, "bitchat-ptt-capture").also(Thread::start)
|
||||
file
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live capture unavailable; caller will fall back to a voice note: ${error.message}")
|
||||
releaseResources()
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun pollAmplitude(): Int = amplitude.get()
|
||||
|
||||
fun stats(): LiveVoiceCaptureStats = LiveVoiceCaptureStats(
|
||||
queuedPcmFrames = queuedPcmFrames.get(),
|
||||
encodedFrames = encodedFrames.get(),
|
||||
dataPackets = packetizer.dataPacketCount,
|
||||
droppedOversizeFrames = packetizer.droppedFrameCount,
|
||||
outboundPackets = outboundPackets.get(),
|
||||
deliveredPackets = deliveredPackets.get()
|
||||
)
|
||||
|
||||
fun stop(canceled: Boolean): File? {
|
||||
val wasRunning = running.getAndSet(false)
|
||||
if (wasRunning) {
|
||||
runCatching { audioRecord?.stop() }
|
||||
runCatching { captureThread?.join(3_000L) }
|
||||
}
|
||||
captureThread = null
|
||||
|
||||
val elapsedMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
|
||||
val durationMs = (encodedFrames.get().toLong() * SAMPLES_PER_AAC_FRAME * 1_000L) / SAMPLE_RATE
|
||||
val valid = !canceled && elapsedMs >= MIN_VALID_DURATION_MS &&
|
||||
durationMs >= MIN_VALID_DURATION_MS && encodedFrames.get() > 0 &&
|
||||
(outputFile?.length() ?: 0L) > 0L
|
||||
|
||||
packetizer.flush().forEach(::queueOutbound)
|
||||
val controlKind = if (valid) {
|
||||
VoiceBurstPacket.Kind.End(packetizer.dataPacketCount, durationMs.coerceAtMost(0xFFFF_FFFFL))
|
||||
} else {
|
||||
VoiceBurstPacket.Kind.Canceled
|
||||
}
|
||||
VoiceBurstPacket.create(burstID, packetizer.nextSequence, controlKind)
|
||||
?.encode()
|
||||
?.let(::queueOutbound)
|
||||
senderRunning.set(false)
|
||||
runCatching { senderThread?.join(OUTBOUND_DRAIN_TIMEOUT_MS) }
|
||||
if (senderThread?.isAlive == true) {
|
||||
Log.w(TAG, "Live voice sender did not drain before timeout")
|
||||
senderThread?.interrupt()
|
||||
}
|
||||
senderThread = null
|
||||
|
||||
val file = outputFile
|
||||
outputFile = null
|
||||
if (!valid) {
|
||||
file?.delete()
|
||||
return null
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun captureLoop() {
|
||||
val pcm = ShortArray(SAMPLES_PER_AAC_FRAME)
|
||||
var nextSyntheticFrameNs = System.nanoTime() + AAC_FRAME_DURATION_NS
|
||||
try {
|
||||
while (running.get()) {
|
||||
val read = if (syntheticPcm) {
|
||||
val waitNs = nextSyntheticFrameNs - System.nanoTime()
|
||||
if (waitNs > 0L) {
|
||||
Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt())
|
||||
}
|
||||
nextSyntheticFrameNs += AAC_FRAME_DURATION_NS
|
||||
val firstSample = totalSamples
|
||||
pcm.indices.forEach { index ->
|
||||
val phase = 2.0 * Math.PI * SYNTHETIC_TONE_HZ *
|
||||
(firstSample + index).toDouble() / SAMPLE_RATE.toDouble()
|
||||
pcm[index] = (sin(phase) * SYNTHETIC_TONE_AMPLITUDE).roundToInt().toShort()
|
||||
}
|
||||
pcm.size
|
||||
} else {
|
||||
audioRecord?.read(pcm, 0, pcm.size, AudioRecord.READ_BLOCKING) ?: break
|
||||
}
|
||||
if (read <= 0) continue
|
||||
amplitude.set(pcm.take(read).maxOfOrNull { abs(it.toInt()) } ?: 0)
|
||||
queuePcm(pcm, read, endOfStream = false)
|
||||
drainEncoder(endOfStream = false)
|
||||
}
|
||||
queuePcm(pcm, 0, endOfStream = true)
|
||||
drainEncoder(endOfStream = true)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live capture stopped after codec/audio failure: ${error.message}")
|
||||
} finally {
|
||||
releaseResources()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuePcm(samples: ShortArray, count: Int, endOfStream: Boolean) {
|
||||
val encoder = codec ?: return
|
||||
val deadlineNs = System.nanoTime() + CODEC_INPUT_DEADLINE_MS * 1_000_000L
|
||||
while (true) {
|
||||
val index = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val input = encoder.getInputBuffer(index)
|
||||
?: error("AAC encoder returned a null input buffer")
|
||||
input.clear()
|
||||
input.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, count)
|
||||
val presentationUs = (totalSamples * 1_000_000L) / SAMPLE_RATE
|
||||
totalSamples += count
|
||||
encoder.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
count * 2,
|
||||
presentationUs,
|
||||
if (endOfStream) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
||||
)
|
||||
if (!endOfStream) queuedPcmFrames.incrementAndGet()
|
||||
return
|
||||
}
|
||||
// Pull encoded output before retrying so transient codec backpressure cannot discard
|
||||
// the already-read 64 ms microphone block.
|
||||
drainEncoder(endOfStream = false)
|
||||
if (System.nanoTime() >= deadlineNs) {
|
||||
error("AAC encoder input remained unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainEncoder(endOfStream: Boolean) {
|
||||
val encoder = codec ?: return
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var sawEnd = false
|
||||
var idlePolls = 0
|
||||
while (!sawEnd && (!endOfStream || idlePolls < 100)) {
|
||||
when (val index = encoder.dequeueOutputBuffer(info, if (endOfStream) CODEC_TIMEOUT_US else 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
||||
idlePolls++
|
||||
if (!endOfStream) return
|
||||
}
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (muxerStarted) error("AAC output format changed twice")
|
||||
muxerTrack = muxer?.addTrack(encoder.outputFormat) ?: -1
|
||||
muxer?.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
else -> if (index >= 0) {
|
||||
idlePolls = 0
|
||||
val output = encoder.getOutputBuffer(index)
|
||||
if (output != null && info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
||||
output.position(info.offset)
|
||||
output.limit(info.offset + info.size)
|
||||
if (muxerStarted && muxerTrack >= 0) {
|
||||
muxer?.writeSampleData(muxerTrack, output.duplicate(), info)
|
||||
}
|
||||
val accessUnit = ByteArray(info.size)
|
||||
output.get(accessUnit)
|
||||
emitEncodedFrame(accessUnit)
|
||||
}
|
||||
sawEnd = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
encoder.releaseOutputBuffer(index, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitEncodedFrame(frame: ByteArray) {
|
||||
if (frame.isEmpty()) return
|
||||
if (!streamStarted) {
|
||||
streamStarted = true
|
||||
VoiceBurstPacket.create(
|
||||
burstID,
|
||||
0,
|
||||
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
|
||||
)?.encode()?.let(::queueOutbound)
|
||||
}
|
||||
encodedFrames.incrementAndGet()
|
||||
packetizer.add(frame).forEach(::queueOutbound)
|
||||
// At the target bitrate a packet fits one frame; flushing immediately avoids latency.
|
||||
packetizer.flush().forEach(::queueOutbound)
|
||||
}
|
||||
|
||||
private fun queueOutbound(packet: ByteArray) {
|
||||
outboundQueue.put(packet.copyOf())
|
||||
outboundPackets.incrementAndGet()
|
||||
}
|
||||
|
||||
private fun senderLoop() {
|
||||
try {
|
||||
while (senderRunning.get() || outboundQueue.isNotEmpty()) {
|
||||
val packet = outboundQueue.poll(100L, TimeUnit.MILLISECONDS) ?: continue
|
||||
target.send(packet)
|
||||
deliveredPackets.incrementAndGet()
|
||||
}
|
||||
} catch (error: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live voice network sender stopped: ${error.message}")
|
||||
} finally {
|
||||
senderRunning.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseResources() {
|
||||
runCatching { audioRecord?.stop() }
|
||||
runCatching { audioRecord?.release() }
|
||||
audioRecord = null
|
||||
runCatching { codec?.stop() }
|
||||
runCatching { codec?.release() }
|
||||
codec = null
|
||||
if (muxerStarted) runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
muxer = null
|
||||
muxerStarted = false
|
||||
muxerTrack = -1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,478 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Date
|
||||
import java.util.TreeMap
|
||||
import java.util.UUID
|
||||
|
||||
enum class LiveVoiceScope { DIRECT_MESSAGE, PUBLIC_MESH }
|
||||
|
||||
sealed interface LiveVoiceEvent {
|
||||
val peerID: String
|
||||
val burstID: String
|
||||
val scope: LiveVoiceScope
|
||||
|
||||
data class Started(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope
|
||||
) : LiveVoiceEvent
|
||||
|
||||
data class Finished(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope,
|
||||
val dataPackets: Int,
|
||||
val frames: Int,
|
||||
val bytes: Int,
|
||||
val expectedPackets: Int?,
|
||||
val missingPackets: Int,
|
||||
val path: String
|
||||
) : LiveVoiceEvent
|
||||
|
||||
data class Canceled(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope
|
||||
) : LiveVoiceEvent
|
||||
|
||||
data class Absorbed(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope,
|
||||
val finalizedPath: String
|
||||
) : LiveVoiceEvent
|
||||
}
|
||||
|
||||
/** Shared phone/Wear receiver: bounded assembly, live playback, bubble state and note absorption. */
|
||||
class LiveVoiceManager private constructor(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "LiveVoiceManager"
|
||||
private const val MAX_CONCURRENT_ASSEMBLIES = 8
|
||||
private const val MAX_BURST_BYTES = 384 * 1_024
|
||||
private const val INBOUND_BYTES_PER_SECOND = 6_000
|
||||
private const val MAX_BUFFERED_PACKETS = 128
|
||||
private const val GAP_SKIP_MS = 550L
|
||||
private const val IDLE_TIMEOUT_MS = 3_000L
|
||||
private const val FINISHED_TTL_MS = 10 * 60 * 1_000L
|
||||
private const val FINISHED_CAP = 32
|
||||
|
||||
// The manager constructor immediately narrows this to context.applicationContext.
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Volatile private var instance: LiveVoiceManager? = null
|
||||
|
||||
fun getInstance(context: Context): LiveVoiceManager = instance ?: synchronized(this) {
|
||||
instance ?: LiveVoiceManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
fun burstIDFromVoiceFileName(fileName: String): String? {
|
||||
if (!fileName.startsWith("voice_")) return null
|
||||
val id = fileName.removePrefix("voice_").take(16)
|
||||
return id.takeIf { value ->
|
||||
value.length == 16 && value.all { it.digitToIntOrNull(16) != null }
|
||||
}?.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
private data class AssemblyKey(
|
||||
val peerID: String,
|
||||
val scope: LiveVoiceScope,
|
||||
val burstID: String
|
||||
)
|
||||
|
||||
private class Assembly(
|
||||
val key: AssemblyKey,
|
||||
val nickname: String,
|
||||
val message: BitchatMessage,
|
||||
var file: File,
|
||||
val output: FileOutputStream,
|
||||
val startedAtMs: Long,
|
||||
val player: PttAudioPlayer?
|
||||
) {
|
||||
val buffered = TreeMap<Int, List<ByteArray>>()
|
||||
var nextSequence = 1
|
||||
var deliveredFrames = 0
|
||||
var deliveredPackets = 0
|
||||
var missingPackets = 0
|
||||
var receivedBytes = 0
|
||||
var endTotalPackets: Int? = null
|
||||
var idleJob: Job? = null
|
||||
var gapJob: Job? = null
|
||||
}
|
||||
|
||||
private data class FinishedBurst(
|
||||
val key: AssemblyKey,
|
||||
val messageID: String,
|
||||
val nickname: String,
|
||||
val file: File,
|
||||
val timestamp: Date,
|
||||
val expiresAtMs: Long,
|
||||
val dataPackets: Int,
|
||||
val frames: Int,
|
||||
val bytes: Int
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val assemblies = linkedMapOf<AssemblyKey, Assembly>()
|
||||
private val finishedBursts = linkedMapOf<AssemblyKey, FinishedBurst>()
|
||||
private val _liveMessageIDs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val liveMessageIDs: StateFlow<Set<String>> = _liveMessageIDs.asStateFlow()
|
||||
private val _activePublicTalker = MutableStateFlow<String?>(null)
|
||||
val activePublicTalker: StateFlow<String?> = _activePublicTalker.asStateFlow()
|
||||
private val _events = MutableSharedFlow<LiveVoiceEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<LiveVoiceEvent> = _events.asSharedFlow()
|
||||
|
||||
@Volatile private var appForeground = false
|
||||
@Volatile private var visibleScope: LiveVoiceScope? = null
|
||||
@Volatile private var visiblePeerID: String? = null
|
||||
private var activePlayer: PttAudioPlayer? = null
|
||||
|
||||
init {
|
||||
liveDirectory().listFiles()
|
||||
?.filter { it.name.startsWith("voice_live_") }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
appForeground = foreground
|
||||
if (!foreground) {
|
||||
synchronized(this) {
|
||||
activePlayer?.stop()
|
||||
activePlayer = null
|
||||
assemblies.values.forEach { assembly -> assembly.player?.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showPublicMesh() {
|
||||
visibleScope = LiveVoiceScope.PUBLIC_MESH
|
||||
visiblePeerID = null
|
||||
}
|
||||
|
||||
fun showDirectMessage(peerID: String) {
|
||||
visibleScope = LiveVoiceScope.DIRECT_MESSAGE
|
||||
visiblePeerID = peerID
|
||||
}
|
||||
|
||||
fun clearVisibleConversation() {
|
||||
visibleScope = null
|
||||
visiblePeerID = null
|
||||
}
|
||||
|
||||
/** Returns false only when the frame itself violates the live-voice wire/resource contract. */
|
||||
@Synchronized
|
||||
fun handleFrame(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
scope: LiveVoiceScope,
|
||||
payload: ByteArray,
|
||||
timestampMs: Long
|
||||
): Boolean {
|
||||
val packet = VoiceBurstPacket.decode(payload) ?: return false
|
||||
if (!LiveVoicePreferences.isEnabled(context)) return true
|
||||
val burstHex = VoiceBurstPacket.burstIDHex(packet.burstID)
|
||||
val key = AssemblyKey(peerID, scope, burstHex)
|
||||
var assembly = assemblies[key]
|
||||
if (assembly == null) {
|
||||
if (packet.kind is VoiceBurstPacket.Kind.End || packet.kind == VoiceBurstPacket.Kind.Canceled) {
|
||||
return true
|
||||
}
|
||||
if (assemblies.size >= MAX_CONCURRENT_ASSEMBLIES) return false
|
||||
assembly = createAssembly(key, nickname, timestampMs) ?: return false
|
||||
assemblies[key] = assembly
|
||||
publishLiveState()
|
||||
_events.tryEmit(LiveVoiceEvent.Started(peerID, burstHex, scope))
|
||||
}
|
||||
|
||||
assembly.receivedBytes += payload.size
|
||||
val elapsedSeconds = ((System.currentTimeMillis() - assembly.startedAtMs).coerceAtLeast(0L) / 1_000.0) + 2.0
|
||||
if (
|
||||
assembly.receivedBytes > MAX_BURST_BYTES ||
|
||||
assembly.receivedBytes > (INBOUND_BYTES_PER_SECOND * elapsedSeconds).toInt()
|
||||
) {
|
||||
Log.w(TAG, "Dropping over-quota live voice burst")
|
||||
finalizeAssembly(assembly)
|
||||
return false
|
||||
}
|
||||
rescheduleIdle(assembly)
|
||||
|
||||
when (val kind = packet.kind) {
|
||||
is VoiceBurstPacket.Kind.Start -> if (kind.codec != VoiceBurstCodec.AAC_LC_16K_MONO) {
|
||||
cancelAssembly(assembly)
|
||||
return false
|
||||
}
|
||||
is VoiceBurstPacket.Kind.Frames -> {
|
||||
if (packet.sequence < assembly.nextSequence || packet.sequence in assembly.buffered) return true
|
||||
if (assembly.buffered.size >= MAX_BUFFERED_PACKETS) return false
|
||||
assembly.buffered[packet.sequence] = kind.frames
|
||||
drainInOrder(assembly)
|
||||
}
|
||||
is VoiceBurstPacket.Kind.End -> {
|
||||
assembly.endTotalPackets = kind.totalDataPackets
|
||||
drainInOrder(assembly)
|
||||
finalizeIfComplete(assembly)
|
||||
}
|
||||
VoiceBurstPacket.Kind.Canceled -> cancelAssembly(assembly)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Swaps a finalized `voice_<burstID>.m4a` into its existing live row. */
|
||||
@Synchronized
|
||||
fun absorbFinalizedVoiceNote(message: BitchatMessage): Boolean {
|
||||
if (message.type != BitchatMessageType.Audio) return false
|
||||
val burstID = burstIDFromVoiceFileName(File(message.content).name) ?: return false
|
||||
val messageScope = if (message.isPrivate) LiveVoiceScope.DIRECT_MESSAGE else LiveVoiceScope.PUBLIC_MESH
|
||||
val peerID = message.senderPeerID ?: return false
|
||||
assemblies.entries.firstOrNull {
|
||||
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
|
||||
}?.value?.let(::finalizeAssembly)
|
||||
pruneFinished()
|
||||
val entry = finishedBursts.entries.firstOrNull {
|
||||
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
|
||||
} ?: return false
|
||||
val finished = entry.value
|
||||
val replacement = message.copy(
|
||||
id = finished.messageID,
|
||||
timestamp = finished.timestamp,
|
||||
sender = finished.nickname,
|
||||
senderPeerID = peerID,
|
||||
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
|
||||
)
|
||||
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
|
||||
} else {
|
||||
AppStateStore.upsertPublicMessage(replacement)
|
||||
}
|
||||
finished.file.delete()
|
||||
finishedBursts.remove(entry.key)
|
||||
_events.tryEmit(LiveVoiceEvent.Absorbed(peerID, burstID, messageScope, message.content))
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
assemblies.values.toList().forEach(::cancelAssembly)
|
||||
activePlayer?.stop()
|
||||
activePlayer = null
|
||||
finishedBursts.clear()
|
||||
publishLiveState()
|
||||
}
|
||||
|
||||
private fun createAssembly(key: AssemblyKey, nickname: String, timestampMs: Long): Assembly? {
|
||||
val file = File(
|
||||
liveDirectory(),
|
||||
"voice_live_${key.burstID}_${key.peerID}_${if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
|
||||
)
|
||||
file.parentFile?.mkdirs()
|
||||
file.delete()
|
||||
val output = runCatching { FileOutputStream(file) }.getOrNull() ?: return null
|
||||
val message = BitchatMessage(
|
||||
id = UUID.randomUUID().toString().uppercase(),
|
||||
sender = nickname,
|
||||
content = file.absolutePath,
|
||||
type = BitchatMessageType.Audio,
|
||||
timestamp = Date(timestampMs),
|
||||
isPrivate = key.scope == LiveVoiceScope.DIRECT_MESSAGE,
|
||||
recipientNickname = AppStateStore.nickname.value.takeIf { key.scope == LiveVoiceScope.DIRECT_MESSAGE },
|
||||
senderPeerID = key.peerID
|
||||
)
|
||||
if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.addPrivateMessage(key.peerID, message, isVisible(key.scope, key.peerID))
|
||||
} else {
|
||||
AppStateStore.addPublicMessage(message)
|
||||
}
|
||||
val player = if (canAutoplay(key)) {
|
||||
activePlayer?.stop()
|
||||
PttAudioPlayer().also { activePlayer = it }
|
||||
} else null
|
||||
return Assembly(key, nickname, message, file, output, System.currentTimeMillis(), player)
|
||||
}
|
||||
|
||||
private fun drainInOrder(assembly: Assembly) {
|
||||
while (true) {
|
||||
val frames = assembly.buffered.remove(assembly.nextSequence)
|
||||
if (frames != null) {
|
||||
frames.forEach { frame ->
|
||||
runCatching { assembly.output.write(AdtsFramer.frame(frame)) }
|
||||
}
|
||||
runCatching { assembly.output.flush() }
|
||||
assembly.deliveredFrames += frames.size
|
||||
assembly.deliveredPackets++
|
||||
assembly.player?.enqueue(frames)
|
||||
assembly.nextSequence = (assembly.nextSequence + 1) and 0xFFFF
|
||||
assembly.gapJob?.cancel()
|
||||
assembly.gapJob = null
|
||||
continue
|
||||
}
|
||||
if (assembly.buffered.isNotEmpty() && assembly.gapJob == null) {
|
||||
val key = assembly.key
|
||||
assembly.gapJob = scope.launch {
|
||||
delay(GAP_SKIP_MS)
|
||||
synchronized(this@LiveVoiceManager) {
|
||||
val current = assemblies[key] ?: return@synchronized
|
||||
current.buffered.firstKey()?.let { skipGap(current, it) }
|
||||
current.gapJob = null
|
||||
drainInOrder(current)
|
||||
finalizeIfComplete(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeIfComplete(assembly: Assembly) {
|
||||
val total = assembly.endTotalPackets ?: return
|
||||
if (assembly.nextSequence > total) finalizeAssembly(assembly)
|
||||
}
|
||||
|
||||
private fun finalizeAssembly(assembly: Assembly) {
|
||||
if (assemblies.remove(assembly.key) == null) return
|
||||
assembly.idleJob?.cancel()
|
||||
assembly.gapJob?.cancel()
|
||||
while (assembly.buffered.isNotEmpty()) {
|
||||
skipGap(assembly, assembly.buffered.firstKey())
|
||||
drainInOrder(assembly)
|
||||
if (assembly.gapJob != null) {
|
||||
assembly.gapJob?.cancel()
|
||||
assembly.gapJob = null
|
||||
}
|
||||
}
|
||||
runCatching { assembly.output.close() }
|
||||
assembly.player?.finishAfterDrain()
|
||||
val missingPackets = assembly.endTotalPackets
|
||||
?.let { total -> (total - assembly.deliveredPackets).coerceAtLeast(assembly.missingPackets) }
|
||||
?: assembly.missingPackets
|
||||
if (assembly.deliveredFrames == 0) {
|
||||
removeBubble(assembly)
|
||||
assembly.file.delete()
|
||||
publishLiveState()
|
||||
return
|
||||
}
|
||||
val fallback = File(
|
||||
assembly.file.parentFile,
|
||||
"voice_${assembly.key.burstID}_${assembly.key.peerID}_${if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
|
||||
)
|
||||
fallback.delete()
|
||||
if (assembly.file.renameTo(fallback)) assembly.file = fallback
|
||||
val finalizedMessage = assembly.message.copy(content = assembly.file.absolutePath)
|
||||
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.upsertPrivateMessage(
|
||||
assembly.key.peerID,
|
||||
finalizedMessage,
|
||||
isVisible(assembly.key.scope, assembly.key.peerID)
|
||||
)
|
||||
} else {
|
||||
AppStateStore.upsertPublicMessage(finalizedMessage)
|
||||
}
|
||||
pruneFinished()
|
||||
finishedBursts[assembly.key] = FinishedBurst(
|
||||
key = assembly.key,
|
||||
messageID = assembly.message.id,
|
||||
nickname = assembly.nickname,
|
||||
file = assembly.file,
|
||||
timestamp = assembly.message.timestamp,
|
||||
expiresAtMs = System.currentTimeMillis() + FINISHED_TTL_MS,
|
||||
dataPackets = assembly.deliveredPackets,
|
||||
frames = assembly.deliveredFrames,
|
||||
bytes = assembly.receivedBytes
|
||||
)
|
||||
_events.tryEmit(
|
||||
LiveVoiceEvent.Finished(
|
||||
assembly.key.peerID,
|
||||
assembly.key.burstID,
|
||||
assembly.key.scope,
|
||||
assembly.deliveredPackets,
|
||||
assembly.deliveredFrames,
|
||||
assembly.receivedBytes,
|
||||
assembly.endTotalPackets,
|
||||
missingPackets,
|
||||
assembly.file.absolutePath
|
||||
)
|
||||
)
|
||||
publishLiveState()
|
||||
}
|
||||
|
||||
private fun cancelAssembly(assembly: Assembly) {
|
||||
if (assemblies.remove(assembly.key) == null) return
|
||||
assembly.idleJob?.cancel()
|
||||
assembly.gapJob?.cancel()
|
||||
assembly.player?.stop()
|
||||
runCatching { assembly.output.close() }
|
||||
removeBubble(assembly)
|
||||
assembly.file.delete()
|
||||
_events.tryEmit(
|
||||
LiveVoiceEvent.Canceled(assembly.key.peerID, assembly.key.burstID, assembly.key.scope)
|
||||
)
|
||||
publishLiveState()
|
||||
}
|
||||
|
||||
private fun skipGap(assembly: Assembly, nextAvailableSequence: Int) {
|
||||
val distance = (nextAvailableSequence - assembly.nextSequence) and 0xFFFF
|
||||
if (distance in 1..0x7FFF) assembly.missingPackets += distance
|
||||
assembly.nextSequence = nextAvailableSequence
|
||||
}
|
||||
|
||||
private fun removeBubble(assembly: Assembly) {
|
||||
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.removePrivateMessage(assembly.message.id)
|
||||
} else {
|
||||
AppStateStore.removePublicMessage(assembly.message.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rescheduleIdle(assembly: Assembly) {
|
||||
assembly.idleJob?.cancel()
|
||||
val key = assembly.key
|
||||
assembly.idleJob = scope.launch {
|
||||
delay(IDLE_TIMEOUT_MS)
|
||||
synchronized(this@LiveVoiceManager) {
|
||||
assemblies[key]?.let(::finalizeAssembly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishLiveState() {
|
||||
_liveMessageIDs.value = assemblies.values.mapTo(linkedSetOf()) { it.message.id }
|
||||
_activePublicTalker.value = assemblies.values
|
||||
.firstOrNull { it.key.scope == LiveVoiceScope.PUBLIC_MESH }
|
||||
?.nickname
|
||||
}
|
||||
|
||||
private fun pruneFinished() {
|
||||
val now = System.currentTimeMillis()
|
||||
finishedBursts.entries.removeAll { it.value.expiresAtMs <= now }
|
||||
while (finishedBursts.size >= FINISHED_CAP) {
|
||||
val oldest = finishedBursts.minByOrNull { it.value.expiresAtMs }?.key ?: break
|
||||
finishedBursts.remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canAutoplay(key: AssemblyKey): Boolean =
|
||||
LiveVoicePreferences.isEnabled(context) && appForeground && isVisible(key.scope, key.peerID)
|
||||
|
||||
private fun isVisible(scope: LiveVoiceScope, peerID: String): Boolean =
|
||||
visibleScope == scope && (scope == LiveVoiceScope.PUBLIC_MESH || visiblePeerID == peerID)
|
||||
|
||||
private fun liveDirectory(): File = File(context.cacheDir, "files/incoming").apply { mkdirs() }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** Small jitter-buffered AAC player for foreground live bursts. */
|
||||
internal class PttAudioPlayer {
|
||||
companion object {
|
||||
private const val TAG = "PttAudioPlayer"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val FRAME_DURATION_US = 64_000L
|
||||
private const val JITTER_FRAMES = 6
|
||||
private const val JITTER_DEADLINE_MS = 500L
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
}
|
||||
|
||||
private val frames = LinkedBlockingQueue<ByteArray>(128)
|
||||
private val stopped = AtomicBoolean(false)
|
||||
private val finishing = AtomicBoolean(false)
|
||||
private val startedAt = System.currentTimeMillis()
|
||||
private val worker = Thread(::playbackLoop, "bitchat-ptt-playback").also(Thread::start)
|
||||
|
||||
fun enqueue(accessUnits: List<ByteArray>) {
|
||||
if (stopped.get()) return
|
||||
accessUnits.forEach { frames.offer(it.copyOf()) }
|
||||
}
|
||||
|
||||
fun finishAfterDrain() {
|
||||
finishing.set(true)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopped.set(true)
|
||||
worker.interrupt()
|
||||
}
|
||||
|
||||
private fun playbackLoop() {
|
||||
var decoder: MediaCodec? = null
|
||||
var track: AudioTrack? = null
|
||||
try {
|
||||
while (
|
||||
!stopped.get() && frames.size < JITTER_FRAMES &&
|
||||
System.currentTimeMillis() - startedAt < JITTER_DEADLINE_MS &&
|
||||
!finishing.get()
|
||||
) {
|
||||
Thread.sleep(10L)
|
||||
}
|
||||
if (stopped.get() || (frames.isEmpty() && finishing.get())) return
|
||||
|
||||
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, 1).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, 2)
|
||||
// AudioSpecificConfig: AAC-LC, 16 kHz (index 8), mono.
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0x14, 0x08)))
|
||||
}
|
||||
decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
||||
configure(format, null, null, 0)
|
||||
start()
|
||||
}
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
).coerceAtLeast(4_096)
|
||||
track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.build()
|
||||
)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
track.play()
|
||||
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var presentationUs = 0L
|
||||
var inputEnded = false
|
||||
var outputEnded = false
|
||||
while (!stopped.get() && !outputEnded) {
|
||||
if (!inputEnded) {
|
||||
val frame = frames.poll(25L, TimeUnit.MILLISECONDS)
|
||||
if (frame != null) {
|
||||
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
decoder.getInputBuffer(index)?.apply {
|
||||
clear()
|
||||
put(frame)
|
||||
}
|
||||
decoder.queueInputBuffer(index, 0, frame.size, presentationUs, 0)
|
||||
presentationUs += FRAME_DURATION_US
|
||||
} else {
|
||||
frames.offer(frame)
|
||||
}
|
||||
} else if (finishing.get()) {
|
||||
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
decoder.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
0,
|
||||
presentationUs,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputEnded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val index = decoder.dequeueOutputBuffer(info, CODEC_TIMEOUT_US)) {
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED,
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
|
||||
else -> if (index >= 0) {
|
||||
decoder.getOutputBuffer(index)?.let { output ->
|
||||
if (info.size > 0) {
|
||||
output.position(info.offset)
|
||||
output.limit(info.offset + info.size)
|
||||
val pcm = ByteArray(info.size)
|
||||
output.get(pcm)
|
||||
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
|
||||
}
|
||||
}
|
||||
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
decoder.releaseOutputBuffer(index, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live playback stopped: ${error.message}")
|
||||
} finally {
|
||||
stopped.set(true)
|
||||
runCatching { track?.stop() }
|
||||
runCatching { track?.release() }
|
||||
runCatching { decoder?.stop() }
|
||||
runCatching { decoder?.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.SecureRandom
|
||||
|
||||
/** iOS-compatible codec identifier carried by a live push-to-talk START packet. */
|
||||
enum class VoiceBurstCodec(val value: UByte) {
|
||||
AAC_LC_16K_MONO(0x01u);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): VoiceBurstCodec? = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One live push-to-talk packet.
|
||||
*
|
||||
* Wire format (shared with iOS):
|
||||
* `[burstID: 8][seq: UInt16 BE][flags: UInt8][payload...]`.
|
||||
*/
|
||||
class VoiceBurstPacket private constructor(
|
||||
val burstID: ByteArray,
|
||||
val sequence: Int,
|
||||
val kind: Kind
|
||||
) {
|
||||
sealed interface Kind {
|
||||
data class Start(val codec: VoiceBurstCodec) : Kind
|
||||
class Frames(val frames: List<ByteArray>) : Kind {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Frames && frames.size == other.frames.size &&
|
||||
frames.indices.all { frames[it].contentEquals(other.frames[it]) }
|
||||
|
||||
override fun hashCode(): Int = frames.fold(1) { acc, frame -> 31 * acc + frame.contentHashCode() }
|
||||
}
|
||||
data class End(val totalDataPackets: Int, val durationMs: Long) : Kind
|
||||
data object Canceled : Kind
|
||||
}
|
||||
|
||||
fun encode(): ByteArray {
|
||||
val output = ByteArrayOutputStream(HEADER_SIZE + 16)
|
||||
output.write(burstID)
|
||||
output.write((sequence ushr 8) and 0xFF)
|
||||
output.write(sequence and 0xFF)
|
||||
when (val packetKind = kind) {
|
||||
is Kind.Start -> {
|
||||
output.write(FLAG_START)
|
||||
output.write(packetKind.codec.value.toInt())
|
||||
}
|
||||
is Kind.Frames -> {
|
||||
output.write(0)
|
||||
packetKind.frames.forEach { frame ->
|
||||
output.write((frame.size ushr 8) and 0xFF)
|
||||
output.write(frame.size and 0xFF)
|
||||
output.write(frame)
|
||||
}
|
||||
}
|
||||
is Kind.End -> {
|
||||
output.write(FLAG_END)
|
||||
output.write((packetKind.totalDataPackets ushr 8) and 0xFF)
|
||||
output.write(packetKind.totalDataPackets and 0xFF)
|
||||
output.write(((packetKind.durationMs ushr 24) and 0xFF).toInt())
|
||||
output.write(((packetKind.durationMs ushr 16) and 0xFF).toInt())
|
||||
output.write(((packetKind.durationMs ushr 8) and 0xFF).toInt())
|
||||
output.write((packetKind.durationMs and 0xFF).toInt())
|
||||
}
|
||||
Kind.Canceled -> output.write(FLAG_CANCELED)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BURST_ID_SIZE = 8
|
||||
const val HEADER_SIZE = BURST_ID_SIZE + 2 + 1
|
||||
const val MAX_FRAMES_PER_PACKET = 8
|
||||
const val MAX_CONTENT_BYTES = 210
|
||||
private const val FLAG_START = 0x01
|
||||
private const val FLAG_END = 0x02
|
||||
private const val FLAG_CANCELED = 0x04
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun create(burstID: ByteArray, sequence: Int, kind: Kind): VoiceBurstPacket? {
|
||||
if (burstID.size != BURST_ID_SIZE || sequence !in 0..0xFFFF) return null
|
||||
when (kind) {
|
||||
is Kind.Frames -> if (
|
||||
kind.frames.isEmpty() ||
|
||||
kind.frames.size > MAX_FRAMES_PER_PACKET ||
|
||||
kind.frames.any { it.isEmpty() || it.size > 0xFFFF }
|
||||
) return null
|
||||
is Kind.End -> if (
|
||||
kind.totalDataPackets !in 0..0xFFFF ||
|
||||
kind.durationMs !in 0..0xFFFF_FFFFL
|
||||
) return null
|
||||
else -> Unit
|
||||
}
|
||||
return VoiceBurstPacket(burstID.copyOf(), sequence, kind)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): VoiceBurstPacket? {
|
||||
if (data.size < HEADER_SIZE) return null
|
||||
val burstID = data.copyOfRange(0, BURST_ID_SIZE)
|
||||
val sequence = ((data[BURST_ID_SIZE].toInt() and 0xFF) shl 8) or
|
||||
(data[BURST_ID_SIZE + 1].toInt() and 0xFF)
|
||||
val flags = data[BURST_ID_SIZE + 2].toInt() and 0xFF
|
||||
val offset = HEADER_SIZE
|
||||
val kind: Kind = when (flags) {
|
||||
FLAG_START -> {
|
||||
if (offset >= data.size) return null
|
||||
val codec = VoiceBurstCodec.fromValue(data[offset].toUByte()) ?: return null
|
||||
Kind.Start(codec)
|
||||
}
|
||||
FLAG_END -> {
|
||||
if (data.size - offset < 6) return null
|
||||
val total = ((data[offset].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 1].toInt() and 0xFF)
|
||||
var duration = 0L
|
||||
repeat(4) { index ->
|
||||
duration = (duration shl 8) or (data[offset + 2 + index].toLong() and 0xFF)
|
||||
}
|
||||
Kind.End(total, duration)
|
||||
}
|
||||
FLAG_CANCELED -> Kind.Canceled
|
||||
0 -> {
|
||||
val frames = mutableListOf<ByteArray>()
|
||||
var cursor = offset
|
||||
while (cursor < data.size) {
|
||||
if (data.size - cursor < 2 || frames.size >= MAX_FRAMES_PER_PACKET) return null
|
||||
val length = ((data[cursor].toInt() and 0xFF) shl 8) or
|
||||
(data[cursor + 1].toInt() and 0xFF)
|
||||
cursor += 2
|
||||
if (length <= 0 || data.size - cursor < length) {
|
||||
return null
|
||||
}
|
||||
frames += data.copyOfRange(cursor, cursor + length)
|
||||
cursor += length
|
||||
}
|
||||
if (frames.isEmpty()) return null
|
||||
Kind.Frames(frames)
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
return create(burstID, sequence, kind)
|
||||
}
|
||||
|
||||
fun makeBurstID(): ByteArray = ByteArray(BURST_ID_SIZE).also(random::nextBytes)
|
||||
|
||||
fun burstIDHex(burstID: ByteArray): String =
|
||||
burstID.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Greedy packetizer constrained so one Noise-wrapped frame stays out of fragmentation. */
|
||||
class VoiceBurstPacketizer(
|
||||
val burstID: ByteArray,
|
||||
private val budget: Int = VoiceBurstPacket.MAX_CONTENT_BYTES
|
||||
) {
|
||||
private val pendingFrames = mutableListOf<ByteArray>()
|
||||
private var pendingSize = 0
|
||||
|
||||
var nextSequence: Int = 1
|
||||
private set
|
||||
var dataPacketCount: Int = 0
|
||||
private set
|
||||
var droppedFrameCount: Int = 0
|
||||
private set
|
||||
|
||||
fun add(frame: ByteArray): List<ByteArray> {
|
||||
val frameCost = 2 + frame.size
|
||||
if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) {
|
||||
droppedFrameCount++
|
||||
return emptyList()
|
||||
}
|
||||
val output = mutableListOf<ByteArray>()
|
||||
if (
|
||||
pendingFrames.isNotEmpty() &&
|
||||
(VoiceBurstPacket.HEADER_SIZE + pendingSize + frameCost > budget ||
|
||||
pendingFrames.size >= VoiceBurstPacket.MAX_FRAMES_PER_PACKET)
|
||||
) {
|
||||
output += flush()
|
||||
}
|
||||
pendingFrames += frame.copyOf()
|
||||
pendingSize += frameCost
|
||||
return output
|
||||
}
|
||||
|
||||
fun flush(): List<ByteArray> {
|
||||
if (pendingFrames.isEmpty()) return emptyList()
|
||||
val packet = VoiceBurstPacket.create(
|
||||
burstID,
|
||||
nextSequence,
|
||||
VoiceBurstPacket.Kind.Frames(pendingFrames.map(ByteArray::copyOf))
|
||||
) ?: run {
|
||||
pendingFrames.clear()
|
||||
pendingSize = 0
|
||||
return emptyList()
|
||||
}
|
||||
pendingFrames.clear()
|
||||
pendingSize = 0
|
||||
nextSequence = (nextSequence + 1) and 0xFFFF
|
||||
dataPacketCount = (dataPacketCount + 1).coerceAtMost(0xFFFF)
|
||||
return listOf(packet.encode())
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a seven-byte ADTS header to an ADTS-less AAC-LC/16 kHz/mono access unit. */
|
||||
object AdtsFramer {
|
||||
fun frame(payload: ByteArray): ByteArray {
|
||||
val frameLength = payload.size + 7
|
||||
require(frameLength <= 0x1FFF) { "AAC frame is too large for ADTS" }
|
||||
return ByteArray(frameLength).also { output ->
|
||||
output[0] = 0xFF.toByte()
|
||||
output[1] = 0xF1.toByte()
|
||||
output[2] = 0x60.toByte() // AAC-LC, 16 kHz frequency index, mono channel config high bit
|
||||
output[3] = (0x40 or ((frameLength ushr 11) and 0x03)).toByte()
|
||||
output[4] = ((frameLength ushr 3) and 0xFF).toByte()
|
||||
output[5] = (((frameLength and 0x07) shl 5) or 0x1F).toByte()
|
||||
output[6] = 0xFC.toByte()
|
||||
payload.copyInto(output, destinationOffset = 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,17 +18,38 @@ import java.util.Locale
|
||||
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
|
||||
* The resulting file has MIME audio/mp4.
|
||||
*/
|
||||
class VoiceRecorder(private val context: Context) {
|
||||
class VoiceRecorder(
|
||||
private val context: Context,
|
||||
private val liveTarget: LiveVoiceTarget? = null
|
||||
) {
|
||||
companion object { private const val TAG = "VoiceRecorder" }
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var liveCapture: LiveVoiceCapture? = null
|
||||
private val _amplitude = MutableStateFlow(0)
|
||||
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
|
||||
|
||||
private var outFile: File? = null
|
||||
|
||||
val isLive: Boolean
|
||||
get() = liveCapture != null
|
||||
|
||||
fun start(): File? {
|
||||
stop() // ensure previous session closed
|
||||
if (liveTarget != null) {
|
||||
val directory = File(context.filesDir, "voicenotes/outgoing")
|
||||
val capture = LiveVoiceCapture(directory, liveTarget)
|
||||
val liveFile = capture.start()
|
||||
if (liveFile != null) {
|
||||
liveCapture = capture
|
||||
outFile = liveFile
|
||||
return liveFile
|
||||
}
|
||||
}
|
||||
return startClassic()
|
||||
}
|
||||
|
||||
private fun startClassic(): File? {
|
||||
return try {
|
||||
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
|
||||
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
|
||||
@ -56,13 +77,19 @@ class VoiceRecorder(private val context: Context) {
|
||||
|
||||
fun pollAmplitude(): Int {
|
||||
return try {
|
||||
val amp = recorder?.maxAmplitude ?: 0
|
||||
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
|
||||
_amplitude.value = amp
|
||||
amp
|
||||
} catch (_: Exception) { 0 }
|
||||
}
|
||||
|
||||
fun stop(): File? {
|
||||
fun stop(canceled: Boolean = false): File? {
|
||||
liveCapture?.let { capture ->
|
||||
val file = capture.stop(canceled)
|
||||
liveCapture = null
|
||||
outFile = null
|
||||
return file
|
||||
}
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) {}
|
||||
@ -73,6 +100,10 @@ class VoiceRecorder(private val context: Context) {
|
||||
val f = outFile
|
||||
recorder = null
|
||||
outFile = null
|
||||
if (canceled) {
|
||||
f?.delete()
|
||||
return null
|
||||
}
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -20,6 +20,7 @@ import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.*
|
||||
import kotlin.math.sign
|
||||
import kotlin.random.Random
|
||||
@ -111,6 +112,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private lateinit var gossipSyncManager: GossipSyncManager
|
||||
// Service-level notification manager for background (no-UI) DMs
|
||||
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
|
||||
@ -129,6 +132,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private var terminated = false
|
||||
|
||||
init {
|
||||
serviceScope.launch {
|
||||
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
|
||||
}
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
VerificationService.configure(encryptionService)
|
||||
setupDelegates()
|
||||
@ -607,6 +613,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleLeave(routed) }
|
||||
@ -949,6 +958,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
|
||||
}
|
||||
|
||||
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
|
||||
try {
|
||||
val recipientPeerID = request.recipientPeerID
|
||||
val packet = if (recipientPeerID == null) {
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.VOICE_FRAME.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = request.payload,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
} else {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
|
||||
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
}
|
||||
broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Live voice frame send failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: com.bitchat.android.model.BitchatFilePacket,
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattServer
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
@ -19,8 +22,8 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.actor
|
||||
import java.util.ArrayDeque
|
||||
|
||||
/**
|
||||
* Handles packet broadcasting to connected devices using actor pattern for serialization
|
||||
@ -48,7 +51,10 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothPacketBroadcaster"
|
||||
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
|
||||
private const val MAX_PENDING_SENDS_PER_LINK = 256
|
||||
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
|
||||
private const val SEND_RETRY_DELAY_MS = 15L
|
||||
private const val MAX_CALLBACK_RETRIES = 3
|
||||
}
|
||||
|
||||
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
|
||||
@ -119,11 +125,38 @@ class BluetoothPacketBroadcaster(
|
||||
// Actor scope for the broadcaster
|
||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
|
||||
|
||||
private enum class SendDirection { CLIENT_WRITE, SERVER_NOTIFICATION }
|
||||
|
||||
private data class SendKey(
|
||||
val deviceAddress: String,
|
||||
val linkID: String,
|
||||
val direction: SendDirection
|
||||
)
|
||||
|
||||
private data class PendingSend(
|
||||
val data: ByteArray,
|
||||
val device: BluetoothDevice,
|
||||
val gatt: BluetoothGatt? = null,
|
||||
val gattServer: BluetoothGattServer? = null,
|
||||
val characteristic: BluetoothGattCharacteristic,
|
||||
var callbackFailures: Int = 0
|
||||
)
|
||||
|
||||
private class LinkSendState {
|
||||
val pending = ArrayDeque<PendingSend>()
|
||||
var pendingBytes = 0
|
||||
var inFlight = false
|
||||
var retryScheduled = false
|
||||
}
|
||||
|
||||
private val sendLock = Any()
|
||||
private val sendStates = mutableMapOf<SendKey, LinkSendState>()
|
||||
|
||||
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
private val broadcasterActor = broadcasterScope.actor<BroadcastRequest>(
|
||||
capacity = Channel.UNLIMITED
|
||||
capacity = 256
|
||||
) {
|
||||
for (request in channel) {
|
||||
val accepted = try {
|
||||
@ -443,21 +476,16 @@ class BluetoothPacketBroadcaster(
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
return try {
|
||||
characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
|
||||
result
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.removeSubscribedDevice(device)
|
||||
connectionTracker.addressPeerMap.remove(device.address)
|
||||
}
|
||||
false
|
||||
}
|
||||
val server = gattServer ?: return false
|
||||
val char = characteristic ?: return false
|
||||
val linkID = connectionTracker.getDeviceConnection(device.address)
|
||||
?.takeIf { !it.isClient }
|
||||
?.linkID
|
||||
?: return false
|
||||
return enqueueSend(
|
||||
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
|
||||
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -467,20 +495,160 @@ class BluetoothPacketBroadcaster(
|
||||
deviceConn: BluetoothConnectionTracker.DeviceConnection,
|
||||
data: ByteArray
|
||||
): Boolean {
|
||||
return try {
|
||||
deviceConn.characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
|
||||
result
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.cleanupDeviceConnection(deviceConn.device.address)
|
||||
val gatt = deviceConn.gatt ?: return false
|
||||
val char = deviceConn.characteristic ?: return false
|
||||
return enqueueSend(
|
||||
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
|
||||
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Android permits only one outstanding GATT operation per link. Queueing here mirrors the
|
||||
* readiness-driven iOS transport and prevents later voice frames from overwriting an operation
|
||||
* that the controller has not completed yet.
|
||||
*/
|
||||
private fun enqueueSend(key: SendKey, request: PendingSend): Boolean {
|
||||
val startNow = synchronized(sendLock) {
|
||||
val state = sendStates.getOrPut(key, ::LinkSendState)
|
||||
if (
|
||||
state.pending.size >= MAX_PENDING_SENDS_PER_LINK ||
|
||||
state.pendingBytes + request.data.size > MAX_PENDING_BYTES_PER_LINK
|
||||
) {
|
||||
Log.w(TAG, "BLE send queue full for ${key.direction}; rejecting ${request.data.size} bytes")
|
||||
return false
|
||||
}
|
||||
state.pending.addLast(request)
|
||||
state.pendingBytes += request.data.size
|
||||
if (!state.inFlight && !state.retryScheduled) {
|
||||
state.inFlight = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
if (startNow) startHead(key)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("MissingPermission", "ObsoleteSdkInt")
|
||||
private fun startHead(key: SendKey) {
|
||||
val request = synchronized(sendLock) { sendStates[key]?.pending?.peekFirst() } ?: return
|
||||
val accepted = try {
|
||||
when (key.direction) {
|
||||
SendDirection.CLIENT_WRITE -> {
|
||||
val gatt = request.gatt
|
||||
if (gatt == null) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(
|
||||
request.characteristic,
|
||||
request.data,
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
request.characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
request.characteristic.value = request.data
|
||||
gatt.writeCharacteristic(request.characteristic)
|
||||
}
|
||||
}
|
||||
SendDirection.SERVER_NOTIFICATION -> {
|
||||
val server = request.gattServer
|
||||
if (server == null) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
server.notifyCharacteristicChanged(
|
||||
request.device,
|
||||
request.characteristic,
|
||||
false,
|
||||
request.data
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
request.characteristic.value = request.data
|
||||
server.notifyCharacteristicChanged(request.device, request.characteristic, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "BLE ${key.direction} failed to start: ${error.message}")
|
||||
false
|
||||
}
|
||||
if (!accepted) rejectStart(key)
|
||||
}
|
||||
|
||||
private fun rejectStart(key: SendKey): Boolean {
|
||||
val schedule = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return false
|
||||
state.inFlight = false
|
||||
if (state.retryScheduled || state.pending.isEmpty()) false else {
|
||||
state.retryScheduled = true
|
||||
true
|
||||
}
|
||||
}
|
||||
if (schedule) {
|
||||
connectionScope.launch {
|
||||
delay(SEND_RETRY_DELAY_MS)
|
||||
val retry = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return@synchronized false
|
||||
state.retryScheduled = false
|
||||
if (!state.inFlight && state.pending.isNotEmpty()) {
|
||||
state.inFlight = true
|
||||
true
|
||||
} else false
|
||||
}
|
||||
if (retry) startHead(key)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
|
||||
completeSend(SendKey(deviceAddress, linkID, SendDirection.CLIENT_WRITE), status)
|
||||
}
|
||||
|
||||
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
|
||||
if (linkID == null) return
|
||||
completeSend(SendKey(deviceAddress, linkID, SendDirection.SERVER_NOTIFICATION), status)
|
||||
}
|
||||
|
||||
private fun completeSend(key: SendKey, status: Int) {
|
||||
var retry = false
|
||||
val startNext = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return
|
||||
val head = state.pending.peekFirst() ?: run {
|
||||
sendStates.remove(key)
|
||||
return
|
||||
}
|
||||
state.inFlight = false
|
||||
if (status != BluetoothGatt.GATT_SUCCESS && head.callbackFailures < MAX_CALLBACK_RETRIES) {
|
||||
head.callbackFailures++
|
||||
retry = true
|
||||
false
|
||||
} else {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "BLE ${key.direction} failed with status $status after retries")
|
||||
}
|
||||
state.pending.removeFirst()
|
||||
state.pendingBytes -= head.data.size
|
||||
if (state.pending.isEmpty()) {
|
||||
sendStates.remove(key)
|
||||
false
|
||||
} else {
|
||||
state.inFlight = true
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retry) rejectStart(key) else if (startNext) startHead(key)
|
||||
}
|
||||
|
||||
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
|
||||
synchronized(sendLock) {
|
||||
sendStates.keys.removeAll { key ->
|
||||
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -499,6 +667,7 @@ class BluetoothPacketBroadcaster(
|
||||
* Shutdown the broadcaster actor gracefully
|
||||
*/
|
||||
fun shutdown() {
|
||||
synchronized(sendLock) { sendStates.clear() }
|
||||
// Close the actor gracefully
|
||||
broadcasterActor.close()
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@ -111,6 +112,8 @@ class MeshCore(
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
@ -122,6 +125,9 @@ class MeshCore(
|
||||
private var isActive = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
|
||||
}
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
@ -481,6 +487,9 @@ class MeshCore(
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleLeave(routed) }
|
||||
}
|
||||
@ -611,6 +620,44 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
|
||||
}
|
||||
|
||||
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
|
||||
try {
|
||||
val recipientPeerID = request.recipientPeerID
|
||||
val packet = if (recipientPeerID == null) {
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.VOICE_FRAME.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = request.payload,
|
||||
ttl = maxTtl
|
||||
)
|
||||
} else {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
|
||||
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = maxTtl
|
||||
)
|
||||
}
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.w("MeshCore", "Live voice frame send failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -39,6 +39,9 @@ import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import com.bitchat.android.features.voice.LiveVoicePreferences
|
||||
import com.bitchat.android.features.voice.LiveVoiceTarget
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
|
||||
private data class ConversationLiveIdentityState(
|
||||
val connectedPeerIDs: List<String>,
|
||||
@ -73,6 +76,22 @@ class ChatViewModel(
|
||||
mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun createVoiceRecorder(toPeerIDOrNull: String?, channelOrNull: String?): VoiceRecorder {
|
||||
val context = getApplication<Application>().applicationContext
|
||||
if (!LiveVoicePreferences.isEnabled(context)) return VoiceRecorder(context)
|
||||
val recipientPeerID = toPeerIDOrNull?.let {
|
||||
PrivateMediaRecipientResolver.resolve(it, mesh)?.meshPeerID
|
||||
}
|
||||
val liveTarget = when {
|
||||
toPeerIDOrNull != null && recipientPeerID != null && mesh.hasEstablishedSession(recipientPeerID) ->
|
||||
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipientPeerID, payload) }
|
||||
toPeerIDOrNull == null && channelOrNull == null && mesh.getActivePeerCount() > 0 ->
|
||||
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(null, payload) }
|
||||
else -> null
|
||||
}
|
||||
return VoiceRecorder(context, liveTarget)
|
||||
}
|
||||
|
||||
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
mediaSendingManager.sendFileNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@ import androidx.compose.ui.unit.toSize
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@ -317,6 +318,8 @@ fun MessageInput(
|
||||
nickname: String,
|
||||
showMediaButtons: Boolean,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
recorderFactory: ((String?, String?) -> VoiceRecorder)? = null,
|
||||
activePublicTalker: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
@ -325,6 +328,7 @@ fun MessageInput(
|
||||
val hasText = value.text.isNotBlank()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var isLiveRecording by remember { mutableStateOf(false) }
|
||||
var elapsedMs by remember { mutableStateOf(0L) }
|
||||
var amplitude by remember { mutableStateOf(0) }
|
||||
val cashuToken = remember(value.text) {
|
||||
@ -484,7 +488,9 @@ fun MessageInput(
|
||||
)
|
||||
if (placeholderAlpha > 0f) {
|
||||
Text(
|
||||
text = stringResource(R.string.type_a_message_placeholder),
|
||||
text = if (
|
||||
selectedPrivatePeer == null && currentChannel == null && activePublicTalker != null
|
||||
) "$activePublicTalker is live" else stringResource(R.string.type_a_message_placeholder),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
@ -518,7 +524,8 @@ fun MessageInput(
|
||||
// scrolls off the left edge while live data streams in from the right.
|
||||
val secs = (elapsedMs / 1000).toInt()
|
||||
Text(
|
||||
text = String.format("%02d:%02d", secs / 60, secs % 60),
|
||||
text = (if (isLiveRecording) "LIVE · " else "") +
|
||||
String.format("%02d:%02d", secs / 60, secs % 60),
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.error,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
@ -628,12 +635,18 @@ fun MessageInput(
|
||||
|
||||
VoiceRecordButton(
|
||||
isRecording = isRecording,
|
||||
recorderFactory = recorderFactory?.let { factory ->
|
||||
{ factory(latestSelectedPeer.value, latestChannel.value) }
|
||||
},
|
||||
courtesyActive = selectedPrivatePeer == null && currentChannel == null &&
|
||||
activePublicTalker != null,
|
||||
shouldCancel = { pos ->
|
||||
cancelBounds?.inflate(cancelSlackPx)?.contains(pos) == true
|
||||
},
|
||||
onTrackFinger = { cancelFinger = it },
|
||||
onStart = {
|
||||
onStart = { live ->
|
||||
isRecording = true
|
||||
isLiveRecording = live
|
||||
elapsedMs = 0L
|
||||
// Keep existing focus to avoid IME collapse, but do not
|
||||
// force-show the keyboard.
|
||||
@ -647,6 +660,7 @@ fun MessageInput(
|
||||
},
|
||||
onFinish = { path ->
|
||||
isRecording = false
|
||||
isLiveRecording = false
|
||||
// Extract and cache the waveform from the actual audio file
|
||||
// so it matches the receiver's rendering.
|
||||
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
|
||||
@ -667,6 +681,7 @@ fun MessageInput(
|
||||
// waveform over an empty field.
|
||||
onCancel = {
|
||||
isRecording = false
|
||||
isLiveRecording = false
|
||||
amplitude = 0
|
||||
elapsedMs = 0L
|
||||
}
|
||||
|
||||
@ -1872,6 +1872,7 @@ fun PrivateChatSheet(
|
||||
onSendFileNote = { peer, channel, path ->
|
||||
viewModel.sendFileNote(peer, channel, path)
|
||||
},
|
||||
recorderFactory = viewModel::createVoiceRecorder,
|
||||
showCommandSuggestions = false,
|
||||
commandSuggestions = emptyList(),
|
||||
showMentionSuggestions = false,
|
||||
|
||||
@ -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() } }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 ->
|
||||
|
||||
@ -248,7 +248,7 @@ command and stop the local relay/Tor fixture.
|
||||
For day-to-day development there is a lighter-weight harness that drives a
|
||||
debug-only broadcast receiver (`app/src/debug/`, never shipped in release)
|
||||
exposing mesh operations over ADB: scan, connect, Noise handshake, DMs,
|
||||
public broadcast, announce, file send/receive, BLE toggle, state dumps, and
|
||||
public broadcast, live push-to-talk, announce, file send/receive, BLE toggle, state dumps, and
|
||||
raw packet injection. Results are JSON files in the app sandbox polled by the
|
||||
host (`cache/testhook/results/<id>.json`, also logged under tag `TestHook`).
|
||||
|
||||
@ -298,6 +298,8 @@ python3 tools/release_gate/mesh_lab.py scenario all \
|
||||
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
|
||||
| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
|
||||
| `broadcast` | public mesh message A→B |
|
||||
| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption |
|
||||
| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions |
|
||||
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
|
||||
| `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing |
|
||||
| `file_private` | Noise-encrypted private file, digest match |
|
||||
@ -326,7 +328,8 @@ See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
|
||||
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
|
||||
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`,
|
||||
`favorite_status`, `verification_set`, `verification_status`, `file_send`,
|
||||
`file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`).
|
||||
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`,
|
||||
`clear_results`).
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -27,6 +27,7 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
|
||||
import androidx.wear.compose.foundation.rotary.rotaryScrollable
|
||||
@ -59,14 +60,33 @@ import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val messages by AppStateStore.publicMessages.collectAsState()
|
||||
val peers by AppStateStore.peers.collectAsState()
|
||||
val unreadDms by WearChatState.unreadDms.collectAsState()
|
||||
val mesh = WearMeshService.peek()
|
||||
val myPeerID = mesh?.myPeerID ?: ""
|
||||
var viewerPath by remember { mutableStateOf<String?>(null) }
|
||||
val voice = rememberVoiceNoteController { path ->
|
||||
mesh?.let { sendVoiceNote(it, null, path) }
|
||||
val liveVoiceManager = remember(context) {
|
||||
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context)
|
||||
}
|
||||
val busyTalker by liveVoiceManager.activePublicTalker.collectAsState()
|
||||
val voice = rememberVoiceNoteController(
|
||||
recorderFactory = {
|
||||
val target = if (
|
||||
mesh != null &&
|
||||
com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context) &&
|
||||
mesh.getPeerNicknames().isNotEmpty()
|
||||
) com.bitchat.android.features.voice.LiveVoiceTarget { payload ->
|
||||
mesh.sendVoiceFrame(null, payload)
|
||||
} else null
|
||||
com.bitchat.android.features.voice.VoiceRecorder(context, target)
|
||||
}
|
||||
) { path -> mesh?.let { sendVoiceNote(it, null, path) } }
|
||||
|
||||
androidx.compose.runtime.DisposableEffect(liveVoiceManager) {
|
||||
liveVoiceManager.showPublicMesh()
|
||||
onDispose { liveVoiceManager.clearVisibleConversation() }
|
||||
}
|
||||
|
||||
ChatScaffold(
|
||||
@ -84,7 +104,7 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
|
||||
)
|
||||
},
|
||||
actionBar = {
|
||||
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice)
|
||||
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice, busyTalker = busyTalker)
|
||||
}
|
||||
)
|
||||
|
||||
@ -230,7 +250,10 @@ fun MessageItem(
|
||||
path = message.content.trim(),
|
||||
onOpen = onOpenImage
|
||||
)
|
||||
BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim())
|
||||
BitchatMessageType.Audio -> VoiceNoteItem(
|
||||
path = message.content.trim(),
|
||||
messageID = message.id
|
||||
)
|
||||
BitchatMessageType.File -> {
|
||||
val path = message.content.trim()
|
||||
val file = remember(path) { File(path) }
|
||||
|
||||
@ -60,9 +60,20 @@ fun DmScreen(
|
||||
val myPeerID = mesh?.myPeerID ?: ""
|
||||
val palette = LocalBitchatPalette.current
|
||||
var viewerPath by remember { mutableStateOf<String?>(null) }
|
||||
val voice = rememberVoiceNoteController { path ->
|
||||
mesh?.let { sendVoiceNote(it, peerID, path) }
|
||||
val liveVoiceManager = remember(context) {
|
||||
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context)
|
||||
}
|
||||
val voice = rememberVoiceNoteController(
|
||||
recorderFactory = {
|
||||
val target = if (
|
||||
mesh != null && mesh.hasEstablishedSession(peerID) &&
|
||||
com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)
|
||||
) com.bitchat.android.features.voice.LiveVoiceTarget { payload ->
|
||||
mesh.sendVoiceFrame(peerID, payload)
|
||||
} else null
|
||||
com.bitchat.android.features.voice.VoiceRecorder(context, target)
|
||||
}
|
||||
) { path -> mesh?.let { sendVoiceNote(it, peerID, path) } }
|
||||
|
||||
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
||||
val identityRevision by WearPeerIdentityState.revision.collectAsState()
|
||||
@ -74,9 +85,13 @@ fun DmScreen(
|
||||
}
|
||||
|
||||
DisposableEffect(peerID) {
|
||||
liveVoiceManager.showDirectMessage(peerID)
|
||||
WearChatState.openDm(peerID)
|
||||
WearNotificationCoordinator.getInstance(context).clearConversation(peerID)
|
||||
onDispose { WearChatState.closeDm() }
|
||||
onDispose {
|
||||
WearChatState.closeDm()
|
||||
liveVoiceManager.clearVisibleConversation()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(peerID) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user