feat: Android VoIP client — Phase 2 (JNI bridge, Compose UI, AEC pipeline wiring)

- JNI bridge with 8 extern functions (init, startCall, stopCall, setMute,
  setSpeaker, getStats, forceProfile, destroy) with panic catching
- Kotlin engine layer: WzpEngine JNI wrapper, WzpCallback interface,
  CallStats data class with JSON deserialization
- Jetpack Compose UI: InCallScreen with quality indicator (green/yellow/red),
  mute/speaker/hangup buttons, stats overlay, duration timer
- CallActivity with RECORD_AUDIO permission handling, Material3 theme
- CallService foreground service with WakeLock, WiFi lock, notification
- AudioRouteManager for speaker/earpiece/Bluetooth SCO switching
- AEC wired into CallEncoder pipeline: AEC → AGC → denoise → silence → encode
- AEC farend reference fed from decode path to encode path in pipeline
- Engine exposes set_aec_enabled/set_agc_enabled via AtomicBool flags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-04 18:16:38 +00:00
parent 26e9c55f1f
commit e7b1c3372a
14 changed files with 1633 additions and 14 deletions

View File

@@ -0,0 +1,135 @@
package com.wzp.ui.call
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.wzp.service.CallService
/**
* Main activity hosting the in-call Compose UI.
*
* Requests RECORD_AUDIO permission, starts the foreground [CallService],
* and launches the call via [CallViewModel].
*/
class CallActivity : ComponentActivity() {
private val viewModel: CallViewModel by viewModels()
// -- Permission request ---------------------------------------------------
private val audioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
startCallFlow()
} else {
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
finish()
}
}
// -- Lifecycle ------------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WzpTheme {
InCallScreen(
viewModel = viewModel,
onHangUp = {
viewModel.stopCall()
CallService.stop(this@CallActivity)
finish()
}
)
}
}
// Check audio permission
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED
) {
startCallFlow()
} else {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
override fun onDestroy() {
super.onDestroy()
if (isFinishing) {
viewModel.stopCall()
CallService.stop(this)
}
}
// -- Call setup ------------------------------------------------------------
private fun startCallFlow() {
// Extract parameters from intent extras, with test defaults.
val relayAddr = intent.getStringExtra(EXTRA_RELAY_ADDR) ?: DEFAULT_RELAY
val room = intent.getStringExtra(EXTRA_ROOM) ?: DEFAULT_ROOM
val seedHex = intent.getStringExtra(EXTRA_SEED_HEX) ?: DEFAULT_SEED_HEX
val token = intent.getStringExtra(EXTRA_TOKEN) ?: DEFAULT_TOKEN
// Start foreground service
CallService.start(this)
// Start the call
viewModel.startCall(relayAddr, room, seedHex, token)
}
companion object {
const val EXTRA_RELAY_ADDR = "relay_addr"
const val EXTRA_ROOM = "room"
const val EXTRA_SEED_HEX = "seed_hex"
const val EXTRA_TOKEN = "token"
// Test defaults — replaced by real values in production
private const val DEFAULT_RELAY = "127.0.0.1:7777"
private const val DEFAULT_ROOM = "test-room"
private const val DEFAULT_SEED_HEX =
"0000000000000000000000000000000000000000000000000000000000000001"
private const val DEFAULT_TOKEN = "test-token"
}
}
/**
* WarzonePhone Material3 theme with dynamic colour support (Android 12+)
* and dark mode.
*/
@Composable
fun WzpTheme(content: @Composable () -> Unit) {
val darkTheme = isSystemInDarkTheme()
val context = LocalContext.current
val colorScheme = when {
// Dynamic colour is available on Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -0,0 +1,142 @@
package com.wzp.ui.call
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzp.engine.CallStats
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* ViewModel managing the call lifecycle and exposing observable state to the UI.
*
* Owns the [WzpEngine] instance, implements [WzpCallback] to receive engine events,
* and polls call statistics every 500 ms while the call is active.
*/
class CallViewModel : ViewModel(), WzpCallback {
// -- Engine ---------------------------------------------------------------
private val engine = WzpEngine(this)
// -- Observable state -----------------------------------------------------
private val _callState = MutableStateFlow(0) // CallStateConstants.IDLE
val callState: StateFlow<Int> = _callState.asStateFlow()
private val _isMuted = MutableStateFlow(false)
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
private val _isSpeaker = MutableStateFlow(false)
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
private val _stats = MutableStateFlow(CallStats())
val stats: StateFlow<CallStats> = _stats.asStateFlow()
private val _qualityTier = MutableStateFlow(0)
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// -- Stats polling --------------------------------------------------------
private var statsJob: Job? = null
// -- Public API -----------------------------------------------------------
/**
* Initialise the native engine and start a call.
*
* @param relayAddr relay server address (host:port)
* @param room room identifier
* @param seedHex 64-char hex-encoded 32-byte identity seed
* @param token authentication token
*/
fun startCall(relayAddr: String, room: String, seedHex: String, token: String) {
engine.init()
val result = engine.startCall(relayAddr, room, seedHex, token)
if (result == 0) {
startStatsPolling()
}
}
/** End the current call and clean up resources. */
fun stopCall() {
stopStatsPolling()
engine.stopCall()
}
/** Toggle microphone mute. */
fun toggleMute() {
val newMuted = !_isMuted.value
_isMuted.value = newMuted
engine.setMute(newMuted)
}
/** Toggle speaker (loudspeaker) mode. */
fun toggleSpeaker() {
val newSpeaker = !_isSpeaker.value
_isSpeaker.value = newSpeaker
engine.setSpeaker(newSpeaker)
}
/** Clear the current error message. */
fun clearError() {
_errorMessage.value = null
}
// -- WzpCallback ----------------------------------------------------------
override fun onCallStateChanged(state: Int) {
_callState.value = state
}
override fun onQualityTierChanged(tier: Int) {
_qualityTier.value = tier
}
override fun onError(code: Int, message: String) {
_errorMessage.value = "Error $code: $message"
}
// -- Stats polling --------------------------------------------------------
private fun startStatsPolling() {
statsJob?.cancel()
statsJob = viewModelScope.launch {
while (isActive) {
val json = engine.getStats()
val parsed = CallStats.fromJson(json)
_stats.value = parsed
_callState.value = parsed.state
_qualityTier.value = parsed.qualityTier
delay(STATS_POLL_INTERVAL_MS)
}
}
}
private fun stopStatsPolling() {
statsJob?.cancel()
statsJob = null
}
// -- Cleanup --------------------------------------------------------------
override fun onCleared() {
super.onCleared()
stopStatsPolling()
engine.stopCall()
engine.destroy()
}
companion object {
private const val STATS_POLL_INTERVAL_MS = 500L
}
}

View File

@@ -0,0 +1,328 @@
package com.wzp.ui.call
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wzp.engine.CallStats
import kotlin.math.roundToInt
/**
* Main in-call Compose screen.
*
* Displays call duration, quality indicator, audio controls, and live statistics.
*/
@Composable
fun InCallScreen(
viewModel: CallViewModel,
onHangUp: () -> Unit
) {
val callState by viewModel.callState.collectAsState()
val isMuted by viewModel.isMuted.collectAsState()
val isSpeaker by viewModel.isSpeaker.collectAsState()
val stats by viewModel.stats.collectAsState()
val qualityTier by viewModel.qualityTier.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
// -- Call state label ---------------------------------------------
CallStateLabel(callState)
Spacer(modifier = Modifier.height(16.dp))
// -- Duration -----------------------------------------------------
DurationDisplay(stats.durationSecs)
Spacer(modifier = Modifier.height(24.dp))
// -- Quality indicator --------------------------------------------
QualityIndicator(qualityTier, stats.qualityLabel)
Spacer(modifier = Modifier.height(32.dp))
// -- Audio level placeholder bar ----------------------------------
AudioLevelBar(stats.framesEncoded)
Spacer(modifier = Modifier.weight(1f))
// -- Control buttons ----------------------------------------------
ControlRow(
isMuted = isMuted,
isSpeaker = isSpeaker,
onToggleMute = viewModel::toggleMute,
onToggleSpeaker = viewModel::toggleSpeaker,
onHangUp = onHangUp
)
Spacer(modifier = Modifier.height(32.dp))
// -- Stats overlay ------------------------------------------------
StatsOverlay(stats)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
@Composable
private fun CallStateLabel(state: Int) {
val label = when (state) {
0 -> "Idle"
1 -> "Connecting..."
2 -> "Active"
3 -> "Reconnecting..."
4 -> "Call Ended"
else -> "Unknown"
}
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun DurationDisplay(durationSecs: Double) {
val totalSeconds = durationSecs.roundToInt()
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
Text(
text = "%02d:%02d".format(minutes, seconds),
style = MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Light,
letterSpacing = 4.sp
),
color = MaterialTheme.colorScheme.onBackground
)
}
@Composable
private fun QualityIndicator(tier: Int, label: String) {
val dotColor = when (tier) {
0 -> Color(0xFF4CAF50) // green
1 -> Color(0xFFFFC107) // yellow
2 -> Color(0xFFF44336) // red
else -> Color.Gray
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(dotColor)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun AudioLevelBar(framesEncoded: Long) {
// Placeholder: derive a fake "level" from frame count to show the bar is alive.
// In production this would be driven by actual RMS audio levels from the engine.
val level = if (framesEncoded > 0) {
((framesEncoded % 100).toFloat() / 100f).coerceIn(0.05f, 1f)
} else {
0f
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Audio Level",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { level },
modifier = Modifier
.fillMaxWidth(0.6f)
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
@Composable
private fun ControlRow(
isMuted: Boolean,
isSpeaker: Boolean,
onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit,
onHangUp: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Mute button
FilledTonalIconButton(
onClick = onToggleMute,
modifier = Modifier.size(56.dp),
colors = if (isMuted) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
}
) {
Text(
text = if (isMuted) "MIC\nOFF" else "MIC",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp
)
}
// Hang up button
FilledIconButton(
onClick = onHangUp,
modifier = Modifier.size(72.dp),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = Color(0xFFF44336),
contentColor = Color.White
)
) {
Text(
text = "END",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold
)
)
}
// Speaker button
FilledTonalIconButton(
onClick = onToggleSpeaker,
modifier = Modifier.size(56.dp),
colors = if (isSpeaker) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
}
) {
Text(
text = if (isSpeaker) "SPK\nON" else "SPK",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp
)
}
}
}
@Composable
private fun StatsOverlay(stats: CallStats) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Network Stats",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem("Loss", "%.1f%%".format(stats.lossPct))
StatItem("RTT", "${stats.rttMs}ms")
StatItem("Jitter", "${stats.jitterMs}ms")
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem("Enc", "${stats.framesEncoded}")
StatItem("Dec", "${stats.framesDecoded}")
StatItem("JB Depth", "${stats.jitterBufferDepth}")
StatItem("Under", "${stats.underruns}")
}
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}