refactor: improve separation of concerns in call feature

1. Move UI toggle state out of CallState.Connected:
   - Removed isAudioMuted/isVideoEnabled/isSpeakerOn from domain
     CallState. These are UI concerns, not call state.
   - Added StateFlows for toggles in CallController (isAudioMuted,
     isVideoEnabled, isSpeakerOn) with proper toggle methods.
   - CallScreen reads toggle state from CallController flows.
   - Removed toggleAudioMute/toggleVideo/toggleSpeaker from
     CallManager (no longer manages UI state).

2. Move ICE candidate parsing to quartz layer:
   - Added candidateSdp(), sdpMid(), sdpMLineIndex() parsers and
     serializeCandidate() to CallIceCandidateEvent in quartz.
   - Removed companion object with parseIceCandidate/
     serializeIceCandidate from CallController.
   - CallController now uses quartz-layer parsing directly.
   - Updated IceCandidateSerializationTest accordingly.

3. Add helper methods to CallManager:
   - currentCallId() and currentPeerPubKey() extract from any
     active state, reducing repeated when expressions.

4. Fix empty callId bug in ChatroomScreen:
   - Navigation to ActiveCall now uses callManager.currentCallId()
     instead of hardcoded empty string.

https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
Claude
2026-04-02 02:39:41 +00:00
parent f344f00e9d
commit 44d6345e16
7 changed files with 118 additions and 113 deletions
@@ -74,6 +74,15 @@ class CallController(
private val _errorMessage = MutableStateFlow<String?>(null) private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow() val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _isAudioMuted = MutableStateFlow(false)
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
private val _isVideoEnabled = MutableStateFlow(true)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
private val _isSpeakerOn = MutableStateFlow(false)
val isSpeakerOn: StateFlow<Boolean> = _isSpeakerOn.asStateFlow()
private var networkCallback: ConnectivityManager.NetworkCallback? = null private var networkCallback: ConnectivityManager.NetworkCallback? = null
init { init {
@@ -185,9 +194,8 @@ class CallController(
} }
fun onIceCandidateReceived(event: CallIceCandidateEvent) { fun onIceCandidateReceived(event: CallIceCandidateEvent) {
val json = event.candidateJson()
try { try {
val candidate = parseIceCandidate(json) val candidate = IceCandidate(event.sdpMid(), event.sdpMLineIndex(), event.candidateSdp())
if (webRtcSession != null && remoteDescriptionSet) { if (webRtcSession != null && remoteDescriptionSet) {
Log.d(TAG) { "Adding ICE candidate directly: ${candidate.sdp.take(50)}" } Log.d(TAG) { "Adding ICE candidate directly: ${candidate.sdp.take(50)}" }
webRtcSession?.addIceCandidate(candidate) webRtcSession?.addIceCandidate(candidate)
@@ -196,7 +204,7 @@ class CallController(
pendingIceCandidates.add(candidate) pendingIceCandidates.add(candidate)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse ICE candidate: $json", e) Log.e(TAG, "Failed to parse ICE candidate", e)
} }
} }
@@ -209,15 +217,21 @@ class CallController(
candidates.forEach { session.addIceCandidate(it) } candidates.forEach { session.addIceCandidate(it) }
} }
fun setAudioMuted(muted: Boolean) { fun toggleAudioMute() {
val muted = !_isAudioMuted.value
_isAudioMuted.value = muted
webRtcSession?.setAudioEnabled(!muted) webRtcSession?.setAudioEnabled(!muted)
} }
fun setVideoEnabled(enabled: Boolean) { fun toggleVideo() {
val enabled = !_isVideoEnabled.value
_isVideoEnabled.value = enabled
webRtcSession?.setVideoEnabled(enabled) webRtcSession?.setVideoEnabled(enabled)
} }
fun setSpeakerOn(on: Boolean) { fun toggleSpeaker() {
val on = !_isSpeakerOn.value
_isSpeakerOn.value = on
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = on am.isSpeakerphoneOn = on
} }
@@ -240,6 +254,9 @@ class CallController(
NotificationUtils.cancelCallNotification(context) NotificationUtils.cancelCallNotification(context)
_remoteVideoTrack.value = null _remoteVideoTrack.value = null
_localVideoTrack.value = null _localVideoTrack.value = null
_isAudioMuted.value = false
_isVideoEnabled.value = true
_isSpeakerOn.value = false
webRtcSession?.dispose() webRtcSession?.dispose()
webRtcSession = null webRtcSession = null
currentCallId = null currentCallId = null
@@ -316,7 +333,7 @@ class CallController(
Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" } Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" }
val callId = currentCallId ?: return val callId = currentCallId ?: return
val peerPubKey = currentPeerPubKey ?: return val peerPubKey = currentPeerPubKey ?: return
val candidateJson = serializeIceCandidate(candidate) val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex)
scope.launch { scope.launch {
val signer = signerProvider() val signer = signerProvider()
@@ -344,25 +361,4 @@ class CallController(
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
companion object {
fun serializeIceCandidate(candidate: IceCandidate): String = """{"candidate":"${candidate.sdp}","sdpMid":"${candidate.sdpMid}","sdpMLineIndex":${candidate.sdpMLineIndex}}"""
fun parseIceCandidate(json: String): IceCandidate {
val candidateRegex = """"candidate"\s*:\s*"([^"]*)"""".toRegex()
val sdpMidRegex = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex()
val sdpMLineIndexRegex = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex()
val sdp = candidateRegex.find(json)?.groupValues?.get(1) ?: ""
val sdpMid = sdpMidRegex.find(json)?.groupValues?.get(1) ?: "0"
val sdpMLineIndex =
sdpMLineIndexRegex
.find(json)
?.groupValues
?.get(1)
?.toIntOrNull() ?: 0
return IceCandidate(sdpMid, sdpMLineIndex, sdp)
}
}
} }
@@ -142,18 +142,9 @@ fun CallScreen(
callController = callController, callController = callController,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { onToggleMute = { callController?.toggleAudioMute() },
callManager.toggleAudioMute() onToggleVideo = { callController?.toggleVideo() },
callController?.setAudioMuted(!state.isAudioMuted) onToggleSpeaker = { callController?.toggleSpeaker() },
},
onToggleVideo = {
callManager.toggleVideo()
callController?.setVideoEnabled(!state.isVideoEnabled)
},
onToggleSpeaker = {
callManager.toggleSpeaker()
callController?.setSpeakerOn(!state.isSpeakerOn)
},
) )
} }
@@ -338,6 +329,9 @@ private fun ConnectedCallUI(
val remoteVideoTrack by (callController?.remoteVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() val remoteVideoTrack by (callController?.remoteVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState()
val localVideoTrack by (callController?.localVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() val localVideoTrack by (callController?.localVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState()
val isAudioMuted by (callController?.isAudioMuted ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: kotlinx.coroutines.flow.MutableStateFlow(true)).collectAsState()
val isSpeakerOn by (callController?.isSpeakerOn ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState()
Box( Box(
modifier = modifier =
@@ -429,9 +423,9 @@ private fun ConnectedCallUI(
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) { ) {
Icon( Icon(
imageVector = if (state.isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic, imageVector = if (isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (state.isAudioMuted) "Unmute" else "Mute", contentDescription = if (isAudioMuted) "Unmute" else "Mute",
tint = if (state.isAudioMuted) Color.Red else Color.White, tint = if (isAudioMuted) Color.Red else Color.White,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
} }
@@ -440,9 +434,9 @@ private fun ConnectedCallUI(
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) { ) {
Icon( Icon(
imageVector = if (state.isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff,
contentDescription = if (state.isVideoEnabled) "Camera off" else "Camera on", contentDescription = if (isVideoEnabled) "Camera off" else "Camera on",
tint = if (!state.isVideoEnabled) Color.Red else Color.White, tint = if (!isVideoEnabled) Color.Red else Color.White,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
} }
@@ -451,9 +445,9 @@ private fun ConnectedCallUI(
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) { ) {
Icon( Icon(
imageVector = if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
contentDescription = if (state.isSpeakerOn) "Earpiece" else "Speaker", contentDescription = if (isSpeakerOn) "Earpiece" else "Speaker",
tint = if (state.isSpeakerOn) Color.Cyan else Color.White, tint = if (isSpeakerOn) Color.Cyan else Color.White,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
} }
@@ -52,13 +52,15 @@ fun ChatroomScreen(
rememberCallWithPermission(context) { rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE)
nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) val callId = accountViewModel.callManager.currentCallId() ?: ""
nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey))
} }
val startVideoCall = val startVideoCall =
rememberCallWithPermission(context) { rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO)
nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) val callId = accountViewModel.callManager.currentCallId() ?: ""
nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey))
} }
DisappearingScaffold( DisappearingScaffold(
@@ -20,59 +20,57 @@
*/ */
package com.vitorpamplona.amethyst.service.call package com.vitorpamplona.amethyst.service.call
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
class IceCandidateSerializationTest { class IceCandidateSerializationTest {
@Test @Test
fun parseIceCandidateFromJson() { fun serializeCandidateProducesValidJson() {
val json = """{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx","sdpMid":"0","sdpMLineIndex":0}""" val json = CallIceCandidateEvent.serializeCandidate("candidate:123 1 udp 456", "audio", 1)
val candidate = CallController.parseIceCandidate(json)
assertEquals("candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx", candidate.sdp)
assertEquals("0", candidate.sdpMid)
assertEquals(0, candidate.sdpMLineIndex)
}
@Test
fun parseIceCandidateWithDifferentIndex() {
val json = """{"candidate":"candidate:1 1 tcp 1234","sdpMid":"audio","sdpMLineIndex":1}"""
val candidate = CallController.parseIceCandidate(json)
assertEquals("candidate:1 1 tcp 1234", candidate.sdp)
assertEquals("audio", candidate.sdpMid)
assertEquals(1, candidate.sdpMLineIndex)
}
@Test
fun parseIceCandidateHandlesMissingFields() {
val json = """{"candidate":"test"}"""
val candidate = CallController.parseIceCandidate(json)
assertEquals("test", candidate.sdp)
assertEquals("0", candidate.sdpMid) // default
assertEquals(0, candidate.sdpMLineIndex) // default
}
@Test
fun serializeIceCandidateProducesValidJson() {
val candidate = org.webrtc.IceCandidate("audio", 1, "candidate:123 1 udp 456")
val json = CallController.serializeIceCandidate(candidate)
// Verify it contains the expected fields
assert(json.contains(""""candidate":"candidate:123 1 udp 456"""")) assert(json.contains(""""candidate":"candidate:123 1 udp 456""""))
assert(json.contains(""""sdpMid":"audio"""")) assert(json.contains(""""sdpMid":"audio""""))
assert(json.contains(""""sdpMLineIndex":1""")) assert(json.contains(""""sdpMLineIndex":1"""))
} }
@Test @Test
fun roundTripIceCandidate() { fun serializeAndParseRoundTrip() {
val original = org.webrtc.IceCandidate("video", 2, "candidate:999 1 udp 789 10.0.0.1 5000 typ host") val sdp = "candidate:999 1 udp 789 10.0.0.1 5000 typ host"
val json = CallController.serializeIceCandidate(original) val sdpMid = "video"
val parsed = CallController.parseIceCandidate(json) val sdpMLineIndex = 2
assertEquals(original.sdp, parsed.sdp) val json = CallIceCandidateEvent.serializeCandidate(sdp, sdpMid, sdpMLineIndex)
assertEquals(original.sdpMid, parsed.sdpMid)
assertEquals(original.sdpMLineIndex, parsed.sdpMLineIndex) // Create a minimal event to test parsing
val event =
CallIceCandidateEvent(
id = "test",
pubKey = "test",
createdAt = 0,
tags = emptyArray(),
content = json,
sig = "test",
)
assertEquals(sdp, event.candidateSdp())
assertEquals(sdpMid, event.sdpMid())
assertEquals(sdpMLineIndex, event.sdpMLineIndex())
}
@Test
fun parseCandidateHandlesMissingFields() {
val event =
CallIceCandidateEvent(
id = "test",
pubKey = "test",
createdAt = 0,
tags = emptyArray(),
content = """{"candidate":"test"}""",
sig = "test",
)
assertEquals("test", event.candidateSdp())
assertEquals("0", event.sdpMid()) // default
assertEquals(0, event.sdpMLineIndex()) // default
} }
} }
@@ -216,26 +216,23 @@ class CallManager(
} }
} }
fun toggleAudioMute() { fun currentCallId(): String? =
val current = _state.value when (val s = _state.value) {
if (current is CallState.Connected) { is CallState.Offering -> s.callId
_state.value = current.copy(isAudioMuted = !current.isAudioMuted) is CallState.IncomingCall -> s.callId
is CallState.Connecting -> s.callId
is CallState.Connected -> s.callId
else -> null
} }
}
fun toggleVideo() { fun currentPeerPubKey(): HexKey? =
val current = _state.value when (val s = _state.value) {
if (current is CallState.Connected) { is CallState.Offering -> s.peerPubKey
_state.value = current.copy(isVideoEnabled = !current.isVideoEnabled) is CallState.IncomingCall -> s.callerPubKey
is CallState.Connecting -> s.peerPubKey
is CallState.Connected -> s.peerPubKey
else -> null
} }
}
fun toggleSpeaker() {
val current = _state.value
if (current is CallState.Connected) {
_state.value = current.copy(isSpeakerOn = !current.isSpeakerOn)
}
}
fun reset() { fun reset() {
_state.value = CallState.Idle _state.value = CallState.Idle
@@ -52,9 +52,6 @@ sealed interface CallState {
val peerPubKey: HexKey, val peerPubKey: HexKey,
val callType: CallType, val callType: CallType,
val startedAtEpoch: Long, val startedAtEpoch: Long,
val isAudioMuted: Boolean = false,
val isVideoEnabled: Boolean = true,
val isSpeakerOn: Boolean = false,
) : CallState ) : CallState
data class Ended( data class Ended(
@@ -45,11 +45,32 @@ class CallIceCandidateEvent(
fun candidateJson() = content fun candidateJson() = content
fun candidateSdp(): String = CANDIDATE_REGEX.find(content)?.groupValues?.get(1) ?: ""
fun sdpMid(): String = SDP_MID_REGEX.find(content)?.groupValues?.get(1) ?: "0"
fun sdpMLineIndex(): Int =
SDP_MLINE_INDEX_REGEX
.find(content)
?.groupValues
?.get(1)
?.toIntOrNull() ?: 0
companion object { companion object {
const val KIND = 25052 const val KIND = 25052
const val ALT_DESCRIPTION = "WebRTC ICE candidate" const val ALT_DESCRIPTION = "WebRTC ICE candidate"
const val EXPIRATION_SECONDS = 20L const val EXPIRATION_SECONDS = 20L
private val CANDIDATE_REGEX = """"candidate"\s*:\s*"([^"]*)"""".toRegex()
private val SDP_MID_REGEX = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex()
private val SDP_MLINE_INDEX_REGEX = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex()
fun serializeCandidate(
sdp: String,
sdpMid: String,
sdpMLineIndex: Int,
): String = """{"candidate":"$sdp","sdpMid":"$sdpMid","sdpMLineIndex":$sdpMLineIndex}"""
fun build( fun build(
candidateJson: String, candidateJson: String,
peerPubKey: HexKey, peerPubKey: HexKey,