feat: Android UI for direct 1:1 calling
- 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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user