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,
|
val roomParticipantCount: Int = 0,
|
||||||
/** Participants in the room (fingerprint + optional alias). */
|
/** Participants in the room (fingerprint + optional alias). */
|
||||||
val roomParticipants: List<RoomMember> = emptyList(),
|
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. */
|
/** Human-readable quality label. */
|
||||||
val qualityLabel: String
|
val qualityLabel: String
|
||||||
@@ -87,7 +95,11 @@ data class CallStats(
|
|||||||
peerCodec = obj.optString("peer_codec", ""),
|
peerCodec = obj.optString("peer_codec", ""),
|
||||||
autoMode = obj.optBoolean("auto_mode", false),
|
autoMode = obj.optBoolean("auto_mode", false),
|
||||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
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) {
|
} catch (e: Exception) {
|
||||||
CallStats()
|
CallStats()
|
||||||
|
|||||||
@@ -132,6 +132,84 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
|
|
||||||
private var statsJob: Job? = null
|
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 {
|
companion object {
|
||||||
private const val TAG = "WzpCall"
|
private const val TAG = "WzpCall"
|
||||||
val DEFAULT_SERVERS = listOf(
|
val DEFAULT_SERVERS = listOf(
|
||||||
@@ -418,6 +496,45 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
startCallInternal()
|
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() {
|
private fun startCallInternal() {
|
||||||
val serverEntry = _servers.value[_selectedServer.value]
|
val serverEntry = _servers.value[_selectedServer.value]
|
||||||
val room = _roomName.value
|
val room = _roomName.value
|
||||||
@@ -571,6 +688,27 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
if (s.state != 0) {
|
if (s.state != 0) {
|
||||||
_callState.value = s.state
|
_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) {
|
if (s.state == 2 && !audioStarted) {
|
||||||
startAudio()
|
startAudio()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.wzp.ui.call
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -217,65 +218,211 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Room
|
// Mode toggle: Room vs Direct Call
|
||||||
SectionLabel("ROOM")
|
val callMode by viewModel.callMode.collectAsState()
|
||||||
OutlinedTextField(
|
val signalState by viewModel.signalState.collectAsState()
|
||||||
value = roomName,
|
val targetFp by viewModel.targetFingerprint.collectAsState()
|
||||||
onValueChange = { viewModel.setRoomName(it) },
|
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
||||||
singleLine = true,
|
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
||||||
modifier = Modifier.fillMaxWidth()
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Checkbox(
|
Button(
|
||||||
checked = aecEnabled,
|
onClick = { viewModel.setCallMode(0) },
|
||||||
onCheckedChange = { viewModel.setAecEnabled(it) }
|
modifier = Modifier.weight(1f).height(36.dp),
|
||||||
)
|
|
||||||
Text("OS ECHO CANCEL", color = TextDim, style = MaterialTheme.typography.labelSmall)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Surface(
|
|
||||||
onClick = onOpenSettings,
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = Color.Transparent,
|
colors = ButtonDefaults.buttonColors(
|
||||||
modifier = Modifier.size(36.dp)
|
containerColor = if (callMode == 0) Accent else Color(0xFF333333)
|
||||||
) {
|
)
|
||||||
Box(contentAlignment = Alignment.Center) {
|
) { Text("Room", color = Color.White, fontSize = 13.sp) }
|
||||||
Text("\u2699", fontSize = 18.sp, color = TextDim)
|
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
|
if (callMode == 0) {
|
||||||
Button(
|
// ── Room mode ──
|
||||||
onClick = { viewModel.startCall() },
|
SectionLabel("ROOM")
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
OutlinedTextField(
|
||||||
shape = RoundedCornerShape(8.dp),
|
value = roomName,
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Accent)
|
onValueChange = { viewModel.setRoomName(it) },
|
||||||
) {
|
singleLine = true,
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth()
|
||||||
"Connect",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = Color.White
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 ->
|
errorMessage?.let { err ->
|
||||||
|
|||||||
Reference in New Issue
Block a user