feat: add WebRTC voice/video call infrastructure

Implements P2P calling over Nostr relays using WebRTC for media
transport and NIP-59 Gift Wraps for encrypted signaling. No custom
server required — only public STUN servers for NAT traversal.

Protocol layer (quartz/nip100WebRtcCalls):
- 6 new event kinds (25050-25055): offer, answer, ICE candidate,
  hangup, reject, renegotiate
- WebRtcCallFactory for creating and gift-wrapping signaling events
- CallIdTag and CallTypeTag for event metadata
- Events registered in EventFactory

Call state machine (commons/call):
- CallState sealed interface with full lifecycle states
- CallManager orchestrating signaling and state transitions
- Follow-gate spam prevention: only followed users can ring,
  non-follows are silently ignored

Android WebRTC integration (amethyst/service/call):
- WebRtcCallSession wrapping Google WebRTC PeerConnection
- CallForegroundService for keeping calls alive in background
- IceServerConfig with default public STUN servers
- User-configurable TURN server support

Android UI (amethyst/ui/call):
- CallScreen with offering, connecting, connected, and ended states
- IncomingCallUI with accept/reject buttons
- ConnectedCallUI with mute, video toggle, speaker, and timer
- Call button added to 1-on-1 DM chat header
- ActiveCall route added to navigation

https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
Claude
2026-04-01 21:09:12 +00:00
parent 0f7bcdba38
commit 2ee48da064
22 changed files with 1802 additions and 1 deletions
@@ -0,0 +1,259 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.call
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class CallManager(
private val signer: NostrSigner,
private val scope: CoroutineScope,
private val isFollowing: (HexKey) -> Boolean,
private val publishEvent: (GiftWrapEvent) -> Unit,
) {
private val factory = WebRtcCallFactory()
private val _state = MutableStateFlow<CallState>(CallState.Idle)
val state: StateFlow<CallState> = _state.asStateFlow()
private var timeoutJob: Job? = null
companion object {
const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout
}
suspend fun initiateCall(
calleePubKey: HexKey,
callType: CallType,
callId: String,
sdpOffer: String,
) {
val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer)
_state.value = CallState.Offering(callId, calleePubKey, callType)
publishEvent(result.wrap)
startTimeout(callId)
}
fun onIncomingCallEvent(event: CallOfferEvent) {
val callerPubKey = event.pubKey
val callId = event.callId() ?: return
val callType = event.callType() ?: CallType.VOICE
if (!isFollowing(callerPubKey)) return
if (_state.value !is CallState.Idle) return
_state.value =
CallState.IncomingCall(
callId = callId,
callerPubKey = callerPubKey,
callType = callType,
sdpOffer = event.sdpOffer(),
)
startTimeout(callId)
}
suspend fun acceptCall(sdpAnswer: String) {
val current = _state.value
if (current !is CallState.IncomingCall) return
val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer)
_state.value = CallState.Connecting(current.callId, current.callerPubKey, current.callType)
cancelTimeout()
publishEvent(result.wrap)
}
suspend fun rejectCall() {
val current = _state.value
if (current !is CallState.IncomingCall) return
val result = factory.createReject(current.callerPubKey, current.callId, signer = signer)
_state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED)
cancelTimeout()
publishEvent(result.wrap)
}
fun onCallAnswered(event: CallAnswerEvent) {
val current = _state.value
if (current !is CallState.Offering) return
if (event.callId() != current.callId) return
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType)
cancelTimeout()
}
fun onCallRejected(event: CallRejectEvent) {
val current = _state.value
if (current !is CallState.Offering) return
if (event.callId() != current.callId) return
_state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED)
cancelTimeout()
}
fun onIceCandidate(event: CallIceCandidateEvent) {
// ICE candidates are handled by the WebRTC session directly.
// This method exists for the call manager to validate the call-id.
}
fun onPeerConnected() {
val current = _state.value
if (current !is CallState.Connecting) return
_state.value =
CallState.Connected(
callId = current.callId,
peerPubKey = current.peerPubKey,
callType = current.callType,
startedAtEpoch = TimeUtils.now(),
)
}
suspend fun hangup() {
val peerPubKey: HexKey
val callId: String
when (val current = _state.value) {
is CallState.Offering -> {
peerPubKey = current.peerPubKey
callId = current.callId
}
is CallState.Connecting -> {
peerPubKey = current.peerPubKey
callId = current.callId
}
is CallState.Connected -> {
peerPubKey = current.peerPubKey
callId = current.callId
}
else -> {
return
}
}
val result = factory.createHangup(peerPubKey, callId, signer = signer)
_state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP)
cancelTimeout()
publishEvent(result.wrap)
}
fun onPeerHangup(event: CallHangupEvent) {
val current = _state.value
val callId = event.callId() ?: return
val currentCallId =
when (current) {
is CallState.Offering -> current.callId
is CallState.Connecting -> current.callId
is CallState.Connected -> current.callId
is CallState.IncomingCall -> current.callId
else -> return
}
if (callId != currentCallId) return
val peerPubKey = event.pubKey
_state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP)
cancelTimeout()
}
fun onSignalingEvent(event: Event) {
when (event) {
is CallOfferEvent -> onIncomingCallEvent(event)
is CallAnswerEvent -> onCallAnswered(event)
is CallRejectEvent -> onCallRejected(event)
is CallHangupEvent -> onPeerHangup(event)
is CallIceCandidateEvent -> onIceCandidate(event)
}
}
fun toggleAudioMute() {
val current = _state.value
if (current is CallState.Connected) {
_state.value = current.copy(isAudioMuted = !current.isAudioMuted)
}
}
fun toggleVideo() {
val current = _state.value
if (current is CallState.Connected) {
_state.value = current.copy(isVideoEnabled = !current.isVideoEnabled)
}
}
fun toggleSpeaker() {
val current = _state.value
if (current is CallState.Connected) {
_state.value = current.copy(isSpeakerOn = !current.isSpeakerOn)
}
}
fun reset() {
_state.value = CallState.Idle
cancelTimeout()
}
private fun startTimeout(callId: String) {
cancelTimeout()
timeoutJob =
scope.launch {
delay(CALL_TIMEOUT_MS)
val current = _state.value
val currentCallId =
when (current) {
is CallState.Offering -> current.callId
is CallState.IncomingCall -> current.callId
else -> null
}
if (currentCallId == callId) {
val peerPubKey =
when (current) {
is CallState.Offering -> current.peerPubKey
is CallState.IncomingCall -> current.callerPubKey
else -> return@launch
}
_state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT)
}
}
}
private fun cancelTimeout() {
timeoutJob?.cancel()
timeoutJob = null
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.call
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType
@Immutable
sealed interface CallState {
data object Idle : CallState
data class Offering(
val callId: String,
val peerPubKey: HexKey,
val callType: CallType,
) : CallState
data class IncomingCall(
val callId: String,
val callerPubKey: HexKey,
val callType: CallType,
val sdpOffer: String,
) : CallState
data class Connecting(
val callId: String,
val peerPubKey: HexKey,
val callType: CallType,
) : CallState
data class Connected(
val callId: String,
val peerPubKey: HexKey,
val callType: CallType,
val startedAtEpoch: Long,
val isAudioMuted: Boolean = false,
val isVideoEnabled: Boolean = true,
val isSpeakerOn: Boolean = false,
) : CallState
data class Ended(
val callId: String,
val peerPubKey: HexKey,
val reason: EndReason,
) : CallState
}
enum class EndReason {
HANGUP,
REJECTED,
TIMEOUT,
ERROR,
PEER_HANGUP,
PEER_REJECTED,
}