feat: Android UI for direct 1:1 calling
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m51s

- Mode toggle: "Room" vs "Direct Call" tabs on pre-connection screen
- Direct Call mode: Register button → registers on relay signal channel
- After registration: shows fingerprint dial pad + incoming call panel
- Incoming call: green Accept / red Reject buttons with caller info
- Ringing state display while waiting for callee
- CallSetup auto-connects to media room
- CallStats extended: sas_code, incoming_call_id/fp/alias fields
- CallViewModel: registerForCalls(), placeDirectCall(), answerIncomingCall()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-09 06:18:07 +04:00
parent c184d5e1f3
commit 0d3f0d4dcb
3 changed files with 349 additions and 52 deletions

View File

@@ -43,6 +43,14 @@ data class CallStats(
val roomParticipantCount: Int = 0,
/** Participants in the room (fingerprint + optional alias). */
val roomParticipants: List<RoomMember> = emptyList(),
/** SAS verification code (4-digit, null if not in a call). */
val sasCode: Int? = null,
/** Incoming call ID (or "relay|room" for CallSetup). */
val incomingCallId: String? = null,
/** Incoming caller's fingerprint. */
val incomingCallerFp: String? = null,
/** Incoming caller's alias. */
val incomingCallerAlias: String? = null,
) {
/** Human-readable quality label. */
val qualityLabel: String
@@ -87,7 +95,11 @@ data class CallStats(
peerCodec = obj.optString("peer_codec", ""),
autoMode = obj.optBoolean("auto_mode", false),
roomParticipantCount = obj.optInt("room_participant_count", 0),
roomParticipants = parseParticipants(obj.optJSONArray("room_participants"))
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
)
} catch (e: Exception) {
CallStats()

View File

@@ -132,6 +132,84 @@ class CallViewModel : ViewModel(), WzpCallback {
private var statsJob: Job? = null
// ── Direct calling state ──
/** 0=room mode, 1=direct call mode */
private val _callMode = MutableStateFlow(0)
val callMode: StateFlow<Int> = _callMode.asStateFlow()
/** Target fingerprint for direct call */
private val _targetFingerprint = MutableStateFlow("")
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
private val _signalState = MutableStateFlow(0)
val signalState: StateFlow<Int> = _signalState.asStateFlow()
/** Incoming call info */
private val _incomingCallId = MutableStateFlow<String?>(null)
val incomingCallId: StateFlow<String?> = _incomingCallId.asStateFlow()
private val _incomingCallerFp = MutableStateFlow<String?>(null)
val incomingCallerFp: StateFlow<String?> = _incomingCallerFp.asStateFlow()
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
fun setCallMode(mode: Int) { _callMode.value = mode }
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
/** Register on relay for direct calls */
fun registerForCalls() {
if (engine == null) {
engine = WzpEngine(this).also { it.init() }
}
val serverIdx = _selectedServer.value
val serverList = _servers.value
if (serverIdx >= serverList.size) return
val relay = serverList[serverIdx].address
val seed = _seedHex.value
val alias = _alias.value
viewModelScope.launch(Dispatchers.IO) {
val resolvedRelay = resolveToIp(relay) ?: relay
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
if (result == 0) {
_signalState.value = 5 // Registered
startStatsPolling()
} else {
_errorMessage.value = "Failed to register on relay"
}
}
}
/** Place a direct call to the target fingerprint */
fun placeDirectCall() {
val target = _targetFingerprint.value.trim()
if (target.isEmpty()) {
_errorMessage.value = "Enter a fingerprint to call"
return
}
engine?.placeCall(target)
_signalState.value = 6 // Ringing
}
/** Answer an incoming direct call */
fun answerIncomingCall(mode: Int = 2) {
val callId = _incomingCallId.value ?: return
engine?.answerCall(callId, mode)
}
/** Reject an incoming direct call */
fun rejectIncomingCall() {
val callId = _incomingCallId.value ?: return
engine?.answerCall(callId, 0) // 0 = Reject
_signalState.value = 5 // Back to registered
_incomingCallId.value = null
_incomingCallerFp.value = null
_incomingCallerAlias.value = null
}
companion object {
private const val TAG = "WzpCall"
val DEFAULT_SERVERS = listOf(
@@ -418,6 +496,45 @@ class CallViewModel : ViewModel(), WzpCallback {
startCallInternal()
}
/** Start a call to a specific relay + room (used by direct call setup). */
private fun startCallInternal(relay: String, room: String) {
Log.i(TAG, "startCallDirect: relay=$relay room=$room")
try {
// Don't teardown — keep the signal connection alive
engine = WzpEngine(this)
engine!!.init()
engineInitialized = true
_callState.value = 1
_errorMessage.value = null
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
Log.w(TAG, "service start err: $e")
}
startStatsPolling()
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
val seed = _seedHex.value
val name = _alias.value
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
CallService.onStopFromNotification = { stopCall() }
if (result != 0) {
_callState.value = 0
_errorMessage.value = "Failed to connect to call room (code $result)"
appContext?.let { CallService.stop(it) }
}
} catch (e: Exception) {
Log.e(TAG, "startCallDirect error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
appContext?.let { CallService.stop(it) }
}
}
} catch (e: Exception) {
Log.e(TAG, "startCallDirect error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
}
}
private fun startCallInternal() {
val serverEntry = _servers.value[_selectedServer.value]
val room = _roomName.value
@@ -571,6 +688,27 @@ class CallViewModel : ViewModel(), WzpCallback {
if (s.state != 0) {
_callState.value = s.state
}
// Track signal state changes for direct calling
if (s.state in 5..7) {
_signalState.value = s.state
}
// Incoming call detection
if (s.state == 7) { // IncomingCall
_incomingCallId.value = s.incomingCallId
_incomingCallerFp.value = s.incomingCallerFp
_incomingCallerAlias.value = s.incomingCallerAlias
}
// CallSetup: auto-connect to media room
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
// Format: "relay_addr|room_name"
val parts = s.incomingCallId.split("|", limit = 2)
if (parts.size == 2) {
val mediaRelay = parts[0]
val mediaRoom = parts[1]
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
startCallInternal(mediaRelay, mediaRoom)
}
}
if (s.state == 2 && !audioStarted) {
startAudio()
}

View File

@@ -2,6 +2,7 @@ package com.wzp.ui.call
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -217,65 +218,211 @@ fun InCallScreen(
Spacer(modifier = Modifier.height(12.dp))
// Room
SectionLabel("ROOM")
OutlinedTextField(
value = roomName,
onValueChange = { viewModel.setRoomName(it) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Mode toggle: Room vs Direct Call
val callMode by viewModel.callMode.collectAsState()
val signalState by viewModel.signalState.collectAsState()
val targetFp by viewModel.targetFingerprint.collectAsState()
val incomingCallId by viewModel.incomingCallId.collectAsState()
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
val incomingCallerAlias by viewModel.incomingCallerAlias.collectAsState()
Spacer(modifier = Modifier.height(12.dp))
// Alias
SectionLabel("ALIAS")
OutlinedTextField(
value = alias,
onValueChange = { viewModel.setAlias(it) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
// AEC + Settings
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = aecEnabled,
onCheckedChange = { viewModel.setAecEnabled(it) }
)
Text("OS ECHO CANCEL", color = TextDim, style = MaterialTheme.typography.labelSmall)
Spacer(modifier = Modifier.weight(1f))
Surface(
onClick = onOpenSettings,
Button(
onClick = { viewModel.setCallMode(0) },
modifier = Modifier.weight(1f).height(36.dp),
shape = RoundedCornerShape(8.dp),
color = Color.Transparent,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text("\u2699", fontSize = 18.sp, color = TextDim)
}
}
colors = ButtonDefaults.buttonColors(
containerColor = if (callMode == 0) Accent else Color(0xFF333333)
)
) { Text("Room", color = Color.White, fontSize = 13.sp) }
Button(
onClick = { viewModel.setCallMode(1) },
modifier = Modifier.weight(1f).height(36.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (callMode == 1) Accent else Color(0xFF333333)
)
) { Text("Direct Call", color = Color.White, fontSize = 13.sp) }
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
// Connect button
Button(
onClick = { viewModel.startCall() },
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Accent)
) {
Text(
"Connect",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = Color.White
if (callMode == 0) {
// ── Room mode ──
SectionLabel("ROOM")
OutlinedTextField(
value = roomName,
onValueChange = { viewModel.setRoomName(it) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
SectionLabel("ALIAS")
OutlinedTextField(
value = alias,
onValueChange = { viewModel.setAlias(it) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = aecEnabled,
onCheckedChange = { viewModel.setAecEnabled(it) }
)
Text("OS ECHO CANCEL", color = TextDim, style = MaterialTheme.typography.labelSmall)
Spacer(modifier = Modifier.weight(1f))
Surface(
onClick = onOpenSettings,
shape = RoundedCornerShape(8.dp),
color = Color.Transparent,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text("\u2699", fontSize = 18.sp, color = TextDim)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.startCall() },
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Accent)
) {
Text(
"Connect",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = Color.White
)
}
} else {
// ── Direct call mode ──
if (signalState < 5) {
// Not registered yet
SectionLabel("ALIAS")
OutlinedTextField(
value = alias,
onValueChange = { viewModel.setAlias(it) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.registerForCalls() },
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2196F3))
) {
Text(
"Register on Relay",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = Color.White
)
}
} else if (signalState == 5) {
// Registered — show dial pad
Text(
"\u2705 Registered — waiting for calls",
color = Green,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(12.dp))
// Incoming call notification
if (incomingCallId != null && incomingCallerFp != null) {
Surface(
color = Color(0xFF1B5E20),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Incoming Call",
color = Color.White,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
)
Text(
"From: ${incomingCallerAlias ?: incomingCallerFp?.take(16) ?: "unknown"}",
color = Color.White.copy(alpha = 0.8f),
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { viewModel.answerIncomingCall(2) },
colors = ButtonDefaults.buttonColors(containerColor = Green),
modifier = Modifier.weight(1f)
) { Text("Accept", color = Color.White) }
Button(
onClick = { viewModel.rejectIncomingCall() },
colors = ButtonDefaults.buttonColors(containerColor = Red),
modifier = Modifier.weight(1f)
) { Text("Reject", color = Color.White) }
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
SectionLabel("CALL BY FINGERPRINT")
OutlinedTextField(
value = targetFp,
onValueChange = { viewModel.setTargetFingerprint(it) },
singleLine = true,
placeholder = { Text("Paste fingerprint (xxxx:xxxx:...)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.placeDirectCall() },
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Accent),
enabled = targetFp.isNotBlank()
) {
Text(
"Call",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = Color.White
)
}
} else if (signalState == 6) {
// Ringing
Text(
"\uD83D\uDD14 Ringing...",
color = Yellow,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else if (signalState == 7) {
// Incoming call (state 7 also handled above in registered view)
Text(
"\uD83D\uDCDE Incoming call...",
color = Green,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
errorMessage?.let { err ->