From 0d3f0d4dcbb10ed3c61d135225c63834d317dfe9 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 9 Apr 2026 06:18:07 +0400 Subject: [PATCH] feat: Android UI for direct 1:1 calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/main/java/com/wzp/engine/CallStats.kt | 14 +- .../java/com/wzp/ui/call/CallViewModel.kt | 138 ++++++++++ .../main/java/com/wzp/ui/call/InCallScreen.kt | 249 ++++++++++++++---- 3 files changed, 349 insertions(+), 52 deletions(-) diff --git a/android/app/src/main/java/com/wzp/engine/CallStats.kt b/android/app/src/main/java/com/wzp/engine/CallStats.kt index 2bbb60b..e51783d 100644 --- a/android/app/src/main/java/com/wzp/engine/CallStats.kt +++ b/android/app/src/main/java/com/wzp/engine/CallStats.kt @@ -43,6 +43,14 @@ data class CallStats( val roomParticipantCount: Int = 0, /** Participants in the room (fingerprint + optional alias). */ val roomParticipants: List = 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() diff --git a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt index 9edae46..eb183f3 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt @@ -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 = _callMode.asStateFlow() + + /** Target fingerprint for direct call */ + private val _targetFingerprint = MutableStateFlow("") + val targetFingerprint: StateFlow = _targetFingerprint.asStateFlow() + + /** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */ + private val _signalState = MutableStateFlow(0) + val signalState: StateFlow = _signalState.asStateFlow() + + /** Incoming call info */ + private val _incomingCallId = MutableStateFlow(null) + val incomingCallId: StateFlow = _incomingCallId.asStateFlow() + + private val _incomingCallerFp = MutableStateFlow(null) + val incomingCallerFp: StateFlow = _incomingCallerFp.asStateFlow() + + private val _incomingCallerAlias = MutableStateFlow(null) + val incomingCallerAlias: StateFlow = _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() } diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt index 1552c56..d5caffb 100644 --- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -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 ->