feat: wire QUIC transport, JNI bridge, connect UI + add docs
- Replace raw FFI with proper `jni` crate for string marshalling - Wire QUIC transport in engine: connect to relay, crypto handshake (CallOffer/CallAnswer, X25519+Ed25519), send/recv MediaPackets - Feed received packets into jitter buffer (was previously ignored) - Add connect screen UI with CALL button (idle state) and in-call controls (mute, speaker, hang up, live stats) - Hardcode relay 172.16.81.125:4433, room "android" - Add comprehensive docs in docs/android/: architecture.md (8 mermaid diagrams), build-guide.md, debugging.md, maintenance.md, roadmap.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,13 @@
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:name=".WzpApplication"
|
||||
android:name="com.wzp.WzpApplication"
|
||||
android:label="WZ Phone"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".ui.call.CallActivity"
|
||||
android:name="com.wzp.ui.call.CallActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
@@ -26,7 +26,7 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.CallService"
|
||||
android:name="com.wzp.service.CallService"
|
||||
android:foregroundServiceType="phoneCall"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
@@ -32,12 +32,12 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
* 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
|
||||
* @param room room identifier (used as QUIC SNI)
|
||||
* @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random)
|
||||
* @param token authentication token (empty = no auth)
|
||||
* @return 0 on success, negative error code on failure
|
||||
*/
|
||||
fun startCall(relayAddr: String, room: String, seedHex: String, token: String): Int {
|
||||
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = ""): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token)
|
||||
if (result == 0) {
|
||||
|
||||
@@ -45,7 +45,6 @@ class CallActivity : ComponentActivity() {
|
||||
viewModel = viewModel,
|
||||
onHangUp = {
|
||||
viewModel.stopCall()
|
||||
finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private var engine: WzpEngine? = null
|
||||
private var engineInitialized = false
|
||||
|
||||
// Observable state
|
||||
private val _callState = MutableStateFlow(0)
|
||||
val callState: StateFlow<Int> = _callState.asStateFlow()
|
||||
|
||||
@@ -39,7 +38,15 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
private var statsJob: Job? = null
|
||||
|
||||
fun startCall(relayAddr: String, room: String, seedHex: String, token: String) {
|
||||
companion object {
|
||||
const val DEFAULT_RELAY = "172.16.81.125:4433"
|
||||
const val DEFAULT_ROOM = "android"
|
||||
}
|
||||
|
||||
fun startCall(
|
||||
relayAddr: String = DEFAULT_RELAY,
|
||||
room: String = DEFAULT_ROOM
|
||||
) {
|
||||
try {
|
||||
if (engine == null) {
|
||||
engine = WzpEngine(this)
|
||||
@@ -48,14 +55,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
engine?.init()
|
||||
engineInitialized = true
|
||||
}
|
||||
val result = engine?.startCall(relayAddr, room, seedHex, token) ?: -1
|
||||
_callState.value = 1 // Connecting
|
||||
val result = engine?.startCall(relayAddr, room) ?: -1
|
||||
if (result == 0) {
|
||||
_callState.value = 1 // Connecting
|
||||
startStatsPolling()
|
||||
} else {
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Failed to start call (code $result)"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
}
|
||||
}
|
||||
@@ -94,8 +103,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
try {
|
||||
val json = engine?.getStats() ?: "{}"
|
||||
if (json.isNotEmpty()) {
|
||||
val parsed = CallStats.fromJson(json)
|
||||
_stats.value = parsed
|
||||
_stats.value = CallStats.fromJson(json)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
delay(500L)
|
||||
|
||||
@@ -14,9 +14,10 @@ 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.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
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
|
||||
@@ -29,7 +30,6 @@ 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
|
||||
@@ -37,11 +37,6 @@ 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,
|
||||
@@ -52,6 +47,7 @@ fun InCallScreen(
|
||||
val isSpeaker by viewModel.isSpeaker.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val qualityTier by viewModel.qualityTier.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -65,63 +61,121 @@ fun InCallScreen(
|
||||
) {
|
||||
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
|
||||
// App title
|
||||
Text(
|
||||
text = "WZ Phone",
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// -- Stats overlay ------------------------------------------------
|
||||
StatsOverlay(stats)
|
||||
CallStateLabel(callState)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (callState == 0) {
|
||||
// Idle — show connect button
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "Relay: ${CallViewModel.DEFAULT_RELAY}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Room: ${CallViewModel.DEFAULT_ROOM}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.startCall() },
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(CircleShape),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF4CAF50)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "CALL",
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if any
|
||||
errorMessage?.let { err ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = err,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// In-call UI
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
DurationDisplay(stats.durationSecs)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
QualityIndicator(qualityTier, stats.qualityLabel)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
AudioLevelBar(stats.framesEncoded)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
ControlRow(
|
||||
isMuted = isMuted,
|
||||
isSpeaker = isSpeaker,
|
||||
onToggleMute = viewModel::toggleMute,
|
||||
onToggleSpeaker = viewModel::toggleSpeaker,
|
||||
onHangUp = {
|
||||
viewModel.stopCall()
|
||||
// Don't finish activity — go back to idle
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
StatsOverlay(stats)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun CallStateLabel(state: Int) {
|
||||
val label = when (state) {
|
||||
0 -> "Idle"
|
||||
0 -> "Ready to connect"
|
||||
1 -> "Connecting..."
|
||||
2 -> "Active"
|
||||
3 -> "Reconnecting..."
|
||||
4 -> "Call Ended"
|
||||
else -> "Unknown"
|
||||
}
|
||||
val color = when (state) {
|
||||
2 -> Color(0xFF4CAF50)
|
||||
1, 3 -> Color(0xFFFFC107)
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,12 +197,11 @@ private fun DurationDisplay(durationSecs: Double) {
|
||||
@Composable
|
||||
private fun QualityIndicator(tier: Int, label: String) {
|
||||
val dotColor = when (tier) {
|
||||
0 -> Color(0xFF4CAF50) // green
|
||||
1 -> Color(0xFFFFC107) // yellow
|
||||
2 -> Color(0xFFF44336) // red
|
||||
0 -> Color(0xFF4CAF50)
|
||||
1 -> Color(0xFFFFC107)
|
||||
2 -> Color(0xFFF44336)
|
||||
else -> Color.Gray
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
@@ -170,14 +223,11 @@ private fun QualityIndicator(tier: Int, label: String) {
|
||||
|
||||
@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",
|
||||
@@ -210,7 +260,6 @@ private fun ControlRow(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Mute button
|
||||
FilledTonalIconButton(
|
||||
onClick = onToggleMute,
|
||||
modifier = Modifier.size(56.dp),
|
||||
@@ -231,7 +280,6 @@ private fun ControlRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Hang up button
|
||||
FilledIconButton(
|
||||
onClick = onHangUp,
|
||||
modifier = Modifier.size(72.dp),
|
||||
@@ -249,7 +297,6 @@ private fun ControlRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Speaker button
|
||||
FilledTonalIconButton(
|
||||
onClick = onToggleSpeaker,
|
||||
modifier = Modifier.size(56.dp),
|
||||
@@ -304,7 +351,7 @@ private fun StatsOverlay(stats: CallStats) {
|
||||
) {
|
||||
StatItem("Enc", "${stats.framesEncoded}")
|
||||
StatItem("Dec", "${stats.framesDecoded}")
|
||||
StatItem("JB Depth", "${stats.jitterBufferDepth}")
|
||||
StatItem("JB", "${stats.jitterBufferDepth}")
|
||||
StatItem("Under", "${stats.underruns}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user