Compare commits

...

2 Commits

Author SHA1 Message Date
9e14724ae2 Релиз 1.4.6: обновление протокола звонков
All checks were successful
Android Kernel Build / build (push) Successful in 23m10s
2026-04-04 23:32:00 +05:00
2bb3281ccf Переход Android звонков на новый серверный протокол 2026-04-04 23:18:23 +05:00
8 changed files with 189 additions and 108 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.5" val rosettaVersionName = "1.4.6"
val rosettaVersionCode = 47 // Increment on each release val rosettaVersionCode = 48 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

@@ -18,31 +18,15 @@ object ReleaseNotes {
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Звонки Звонки
- Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить - Android переведён на новый серверный сигналинг звонков: CALL -> ACCEPT -> KEY_EXCHANGE -> ACTIVE
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером) - Для звонков добавлена полная поддержка callId/joinToken (в CALL/ACCEPT/END_CALL)
- Звонок больше не сбрасывается при переподключении WebSocket - Добавлена обработка RINGING_TIMEOUT с корректным завершением звонка
- Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек - WebRTC пакет 0x1B обновлён под новый формат сервера (без лишних полей в payload)
- Автоматическая привязка аккаунта при принятии звонка из push-уведомления - Push звонка теперь пробрасывает callId/joinToken в CallManager для стабильного принятия до WebSocket
- Исправлен краш ForegroundService при исходящем звонке (safeStopForeground)
- Убрано мелькание "Unknown" при завершении звонка
- Кнопка Decline теперь работает во всех фазах звонка
- Баннер активного звонка теперь отображается внутри диалога
- Дедупликация push + WebSocket сигналов (без мерцания уведомлений)
- Защита от фантомных звонков при принятии на другом устройстве
- Корректное освобождение PeerConnection (dispose) при завершении звонка
- Кастомный WebRTC AAR с E2EE добавлен в репозиторий для CI-сборок
- Диагностические логи звонков и уведомлений в rosettadev1
Уведомления Стабильность
- Аватарки и имена пользователей в уведомлениях о сообщениях и звонках - Улучшены диагностические логи звонков (callId/joinToken в state/log)
- Настройка включения/выключения аватарок в уведомлениях (Notifications → Avatars in Notifications) - Обновлена совместимость Android с актуальными версиями desktop и rosetta-wss
- Сохранение FCM токена в rosettadev1 для диагностики
- Поддержка tokenType и deviceId в push-подписке
Интерфейс
- Ограничение масштаба шрифта до 1.3x — вёрстка не ломается на телефонах с огромным текстом
- Новые обои: Light 1-3 для светлой темы, Dark 1-3 для тёмной темы
- Убраны старые обои, исправлено растяжение превью обоев
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -110,13 +110,14 @@ object CallManager {
private var ownPublicKey: String = "" private var ownPublicKey: String = ""
private var role: CallRole? = null private var role: CallRole? = null
private var roomId: String = "" private var serverCallId: String = ""
private var serverJoinToken: String = ""
private var offerSent = false private var offerSent = false
private var remoteDescriptionSet = false private var remoteDescriptionSet = false
private var callSessionId: String = "" private var callSessionId: String = ""
private var callStartedAtMs: Long = 0L private var callStartedAtMs: Long = 0L
private var keyExchangeSent = false private var keyExchangeSent = false
private var createRoomSent = false private var activeSignalSent = false
private var lastPeerSharedPublicHex: String = "" private var lastPeerSharedPublicHex: String = ""
private var localPrivateKey: ByteArray? = null private var localPrivateKey: ByteArray? = null
@@ -183,7 +184,8 @@ object CallManager {
if (phase == CallPhase.IDLE) { if (phase == CallPhase.IDLE) {
val hasResidualSession = val hasResidualSession =
callSessionId.isNotBlank() || callSessionId.isNotBlank() ||
roomId.isNotBlank() || serverCallId.isNotBlank() ||
serverJoinToken.isNotBlank() ||
role != null || role != null ||
_state.value.peerPublicKey.isNotBlank() || _state.value.peerPublicKey.isNotBlank() ||
sharedKeyBytes != null || sharedKeyBytes != null ||
@@ -214,7 +216,12 @@ object CallManager {
* Ставит CallManager в INCOMING сразу, не дожидаясь WebSocket сигнала. * Ставит CallManager в INCOMING сразу, не дожидаясь WebSocket сигнала.
* Если WebSocket CALL придёт позже — дедупликация его отбросит. * Если WebSocket CALL придёт позже — дедупликация его отбросит.
*/ */
fun setIncomingFromPush(peerPublicKey: String, peerTitle: String) { fun setIncomingFromPush(
peerPublicKey: String,
peerTitle: String,
callId: String = "",
joinToken: String = ""
) {
val peer = peerPublicKey.trim() val peer = peerPublicKey.trim()
if (peer.isBlank()) return if (peer.isBlank()) return
// Уже в звонке — не перебиваем // Уже в звонке — не перебиваем
@@ -222,7 +229,12 @@ object CallManager {
breadcrumb("setIncomingFromPush SKIP: phase=${_state.value.phase}") breadcrumb("setIncomingFromPush SKIP: phase=${_state.value.phase}")
return return
} }
breadcrumb("setIncomingFromPush peer=${peer.take(8)}… title=$peerTitle") serverCallId = callId.trim()
serverJoinToken = joinToken.trim()
breadcrumb(
"setIncomingFromPush peer=${peer.take(8)}… title=$peerTitle " +
"callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
)
beginCallSession("incoming-push:${peer.take(8)}") beginCallSession("incoming-push:${peer.take(8)}")
role = CallRole.CALLEE role = CallRole.CALLEE
resetRtcObjects() resetRtcObjects()
@@ -299,7 +311,6 @@ object CallManager {
role = CallRole.CALLEE role = CallRole.CALLEE
generateSessionKeys() generateSessionKeys()
val localPublic = localPublicKey ?: return CallActionResult.INVALID_TARGET
incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null incomingRingTimeoutJob = null
@@ -312,19 +323,30 @@ object CallManager {
} }
armConnectingTimeout("acceptIncomingCall") armConnectingTimeout("acceptIncomingCall")
// Отправляем KEY_EXCHANGE — если WebSocket не подключен, ждём и ретраим // Отправляем ACCEPT с callId/joinToken. Если push пришел раньше WS CALL,
// подождем немного пока идентификаторы звонка подтянутся.
scope.launch { scope.launch {
var sent = false var sent = false
for (attempt in 1..30) { // 30 * 200ms = 6 sec for (attempt in 1..30) { // 30 * 200ms = 6 sec
val callIdNow = serverCallId.trim()
val joinTokenNow = serverJoinToken.trim()
if (callIdNow.isBlank() || joinTokenNow.isBlank()) {
breadcrumb("acceptIncomingCall: waiting callId/joinToken (attempt #$attempt)")
kotlinx.coroutines.delay(200)
continue
}
if (ProtocolManager.isAuthenticated()) { if (ProtocolManager.isAuthenticated()) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.KEY_EXCHANGE, signalType = SignalType.ACCEPT,
src = ownPublicKey, src = ownPublicKey,
dst = snapshot.peerPublicKey, dst = snapshot.peerPublicKey,
sharedPublic = localPublic.toHex() callId = callIdNow,
joinToken = joinTokenNow
)
breadcrumb(
"acceptIncomingCall: ACCEPT sent (attempt #$attempt) " +
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
) )
keyExchangeSent = true
breadcrumb("acceptIncomingCall: KEY_EXCHANGE sent (attempt #$attempt)")
sent = true sent = true
break break
} }
@@ -332,7 +354,7 @@ object CallManager {
kotlinx.coroutines.delay(200) kotlinx.coroutines.delay(200)
} }
if (!sent) { if (!sent) {
breadcrumb("acceptIncomingCall: FAILED to send KEY_EXCHANGE after 6s — resetting") breadcrumb("acceptIncomingCall: FAILED to send ACCEPT after 6s — resetting")
resetSession(reason = "Failed to connect", notifyPeer = false) resetSession(reason = "Failed to connect", notifyPeer = false)
} }
} }
@@ -346,11 +368,20 @@ object CallManager {
if (snapshot.phase != CallPhase.INCOMING) return if (snapshot.phase != CallPhase.INCOMING) return
incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null incomingRingTimeoutJob = null
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) { val callIdNow = serverCallId.trim()
val joinTokenNow = serverJoinToken.trim()
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.END_CALL, signalType = SignalType.END_CALL,
src = ownPublicKey, src = ownPublicKey,
dst = snapshot.peerPublicKey dst = snapshot.peerPublicKey,
callId = callIdNow,
joinToken = joinTokenNow
)
} else {
breadcrumb(
"declineIncomingCall: skip END_CALL (missing ids) " +
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
) )
} }
resetSession(reason = null, notifyPeer = false) resetSession(reason = null, notifyPeer = false)
@@ -400,6 +431,11 @@ object CallManager {
resetSession(reason = "Peer disconnected", notifyPeer = false) resetSession(reason = "Peer disconnected", notifyPeer = false)
return return
} }
SignalType.RINGING_TIMEOUT -> {
breadcrumb("SIG: ringing timeout → reset")
resetSession(reason = "No answer", notifyPeer = false)
return
}
SignalType.END_CALL -> { SignalType.END_CALL -> {
breadcrumb("SIG: END_CALL → reset") breadcrumb("SIG: END_CALL → reset")
resetSession(reason = "Call ended", notifyPeer = false) resetSession(reason = "Call ended", notifyPeer = false)
@@ -419,10 +455,15 @@ object CallManager {
SignalType.CALL -> { SignalType.CALL -> {
val incomingPeer = packet.src.trim() val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return if (incomingPeer.isBlank()) return
serverCallId = packet.callId.trim()
serverJoinToken = packet.joinToken.trim()
// Дедупликация: push уже поставил INCOMING для этого peer — обновляем только имя // Дедупликация: push уже поставил INCOMING для этого peer — обновляем только имя
if (_state.value.phase == CallPhase.INCOMING && _state.value.peerPublicKey == incomingPeer) { if (_state.value.phase == CallPhase.INCOMING && _state.value.peerPublicKey == incomingPeer) {
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… but already INCOMING — dedup") breadcrumb(
"SIG: CALL from ${incomingPeer.take(8)}… but already INCOMING — dedup " +
"callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
)
resolvePeerIdentity(incomingPeer) resolvePeerIdentity(incomingPeer)
return return
} }
@@ -439,7 +480,10 @@ object CallManager {
return return
} }
beginCallSession("incoming:${incomingPeer.take(8)}") beginCallSession("incoming:${incomingPeer.take(8)}")
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") breadcrumb(
"SIG: CALL from ${incomingPeer.take(8)}… → INCOMING " +
"callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
)
role = CallRole.CALLEE role = CallRole.CALLEE
resetRtcObjects() resetRtcObjects()
// Пробуем сразу взять имя из кэша чтобы ForegroundService показал его // Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
@@ -490,29 +534,51 @@ object CallManager {
breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange") breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
handleKeyExchange(packet) handleKeyExchange(packet)
} }
SignalType.CREATE_ROOM -> { SignalType.ACCEPT -> {
val incomingRoomId = packet.roomId.trim() breadcrumb(
breadcrumb("SIG: CREATE_ROOM roomId=${incomingRoomId.take(16)}") "SIG: ACCEPT callId=${packet.callId.take(12)} join=${packet.joinToken.take(12)}"
if (incomingRoomId.isBlank()) { )
breadcrumb("SIG: CREATE_ROOM IGNORED — empty roomId!") serverCallId = packet.callId.trim()
serverJoinToken = packet.joinToken.trim()
if (role != CallRole.CALLER) {
breadcrumb("SIG: ACCEPT ignored — role=$role")
return return
} }
// Если ключей нет — звонок был принят на другом устройстве, if (localPrivateKey == null || localPublicKey == null) {
// а сервер всё равно прислал CREATE_ROOM. Сбрасываем. breadcrumb("SIG: ACCEPT — generating local session keys")
generateSessionKeys()
}
val localPublic = localPublicKey ?: return
ProtocolManager.sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = _state.value.peerPublicKey,
sharedPublic = localPublic.toHex()
)
keyExchangeSent = true
updateState {
it.copy(
phase = CallPhase.CONNECTING,
statusText = "Exchanging keys..."
)
}
armConnectingTimeout("signal:accept")
}
SignalType.ACTIVE -> {
breadcrumb("SIG: ACTIVE")
if (sharedKeyBytes == null && localPrivateKey == null) { if (sharedKeyBytes == null && localPrivateKey == null) {
breadcrumb("SIG: CREATE_ROOM but no session keys — call accepted on another device, resetting") breadcrumb("SIG: ACTIVE but no session keys — resetting")
CallSoundManager.stop() CallSoundManager.stop()
resetSession(reason = null, notifyPeer = false) resetSession(reason = null, notifyPeer = false)
return return
} }
roomId = incomingRoomId
updateState { updateState {
it.copy( it.copy(
phase = CallPhase.CONNECTING, phase = CallPhase.CONNECTING,
statusText = "Connecting..." statusText = "Connecting..."
) )
} }
armConnectingTimeout("signal:create_room") armConnectingTimeout("signal:active")
ensurePeerConnectionAndOffer() ensurePeerConnectionAndOffer()
} }
SignalType.ACTIVE_CALL -> Unit SignalType.ACTIVE_CALL -> Unit
@@ -552,25 +618,15 @@ object CallManager {
return return
} }
setupE2EE(sharedKey) setupE2EE(sharedKey)
breadcrumb("KE: CALLER — E2EE ready, sending missing signaling packets") breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE")
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") } updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") }
val localPublic = localPublicKey ?: return if (!activeSignalSent) {
if (!keyExchangeSent) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.KEY_EXCHANGE, signalType = SignalType.ACTIVE,
src = ownPublicKey,
dst = peerKey,
sharedPublic = localPublic.toHex()
)
keyExchangeSent = true
}
if (!createRoomSent) {
ProtocolManager.sendCallSignal(
signalType = SignalType.CREATE_ROOM,
src = ownPublicKey, src = ownPublicKey,
dst = peerKey dst = peerKey
) )
createRoomSent = true activeSignalSent = true
} }
updateState { it.copy(phase = CallPhase.CONNECTING) } updateState { it.copy(phase = CallPhase.CONNECTING) }
armConnectingTimeout("key_exchange:caller") armConnectingTimeout("key_exchange:caller")
@@ -588,10 +644,23 @@ object CallManager {
return return
} }
setupE2EE(sharedKey) setupE2EE(sharedKey)
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM") if (!keyExchangeSent) {
val localPublic = localPublicKey ?: return
ProtocolManager.sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = peerKey,
sharedPublic = localPublic.toHex()
)
keyExchangeSent = true
}
breadcrumb("KE: CALLEE — E2EE ready, waiting for ACTIVE")
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) } updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
armConnectingTimeout("key_exchange:callee") armConnectingTimeout("key_exchange:callee")
return
} }
breadcrumb("KE: ignored — unknown role")
} }
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) { private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
@@ -710,8 +779,8 @@ object CallManager {
private suspend fun ensurePeerConnectionAndOffer() { private suspend fun ensurePeerConnectionAndOffer() {
val peerKey = _state.value.peerPublicKey val peerKey = _state.value.peerPublicKey
if (peerKey.isBlank() || roomId.isBlank()) { if (peerKey.isBlank()) {
breadcrumb("PC: ensurePCAndOffer SKIP — peer=${peerKey.take(8)} room=${roomId.take(8)}") breadcrumb("PC: ensurePCAndOffer SKIP — peer=${peerKey.take(8)}")
return return
} }
if (offerSent) { if (offerSent) {
@@ -719,7 +788,7 @@ object CallManager {
return return
} }
breadcrumb("PC: ensurePCAndOffer START role=$role room=${roomId.take(8)}") breadcrumb("PC: ensurePCAndOffer START role=$role")
ensurePeerFactory() ensurePeerFactory()
val factory = peerConnectionFactory val factory = peerConnectionFactory
if (factory == null) { if (factory == null) {
@@ -897,8 +966,8 @@ object CallManager {
val snapshot = _state.value val snapshot = _state.value
if (snapshot.phase != CallPhase.CONNECTING) return@launch if (snapshot.phase != CallPhase.CONNECTING) return@launch
breadcrumb( breadcrumb(
"CONNECTING TIMEOUT origin=$origin role=$role room=${roomId.take(12)} " + "CONNECTING TIMEOUT origin=$origin role=$role callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)} " +
"keyExSent=$keyExchangeSent createRoomSent=$createRoomSent offerSent=$offerSent " + "keyExSent=$keyExchangeSent activeSent=$activeSignalSent offerSent=$offerSent " +
"remoteDesc=$remoteDescriptionSet peer=${snapshot.peerPublicKey.take(8)}" "remoteDesc=$remoteDescriptionSet peer=${snapshot.peerPublicKey.take(8)}"
) )
resetSession(reason = "Connecting timeout", notifyPeer = false) resetSession(reason = "Connecting timeout", notifyPeer = false)
@@ -983,7 +1052,9 @@ object CallManager {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.END_CALL, signalType = SignalType.END_CALL,
src = ownPublicKey, src = ownPublicKey,
dst = peerToNotify dst = peerToNotify,
callId = serverCallId,
joinToken = serverJoinToken
) )
} }
// Отменяем все jobs ПЕРВЫМИ — чтобы они не вызвали updateState с пустым state // Отменяем все jobs ПЕРВЫМИ — чтобы они не вызвали updateState с пустым state
@@ -1011,11 +1082,12 @@ object CallManager {
lastHealthLog = "" lastHealthLog = ""
healthLogCount = 0 healthLogCount = 0
role = null role = null
roomId = "" serverCallId = ""
serverJoinToken = ""
offerSent = false offerSent = false
remoteDescriptionSet = false remoteDescriptionSet = false
keyExchangeSent = false keyExchangeSent = false
createRoomSent = false activeSignalSent = false
lastPeerSharedPublicHex = "" lastPeerSharedPublicHex = ""
lastRemoteOfferFingerprint = "" lastRemoteOfferFingerprint = ""
lastLocalOfferFingerprint = "" lastLocalOfferFingerprint = ""
@@ -1273,7 +1345,8 @@ object CallManager {
append(" phase=").append(st.phase) append(" phase=").append(st.phase)
append(" role=").append(role) append(" role=").append(role)
append(" peer=").append(st.peerPublicKey.take(12)) append(" peer=").append(st.peerPublicKey.take(12))
append(" room=").append(roomId.take(16)) append(" callId=").append(serverCallId.take(16))
append(" join=").append(serverJoinToken.take(16))
append(" offerSent=").append(offerSent) append(" offerSent=").append(offerSent)
append(" remoteDescSet=").append(remoteDescriptionSet) append(" remoteDescSet=").append(remoteDescriptionSet)
append(" e2eeAvail=").append(e2eeAvailable) append(" e2eeAvail=").append(e2eeAvailable)

View File

@@ -5,9 +5,11 @@ enum class SignalType(val value: Int) {
KEY_EXCHANGE(1), KEY_EXCHANGE(1),
ACTIVE_CALL(2), ACTIVE_CALL(2),
END_CALL(3), END_CALL(3),
CREATE_ROOM(4), ACTIVE(4),
END_CALL_BECAUSE_PEER_DISCONNECTED(5), END_CALL_BECAUSE_PEER_DISCONNECTED(5),
END_CALL_BECAUSE_BUSY(6); END_CALL_BECAUSE_BUSY(6),
ACCEPT(7),
RINGING_TIMEOUT(8);
companion object { companion object {
fun fromValue(value: Int): SignalType = fun fromValue(value: Int): SignalType =
@@ -25,7 +27,8 @@ class PacketSignalPeer : Packet() {
var dst: String = "" var dst: String = ""
var sharedPublic: String = "" var sharedPublic: String = ""
var signalType: SignalType = SignalType.CALL var signalType: SignalType = SignalType.CALL
var roomId: String = "" var callId: String = ""
var joinToken: String = ""
override fun getPacketId(): Int = 0x1A override fun getPacketId(): Int = 0x1A
@@ -33,7 +36,8 @@ class PacketSignalPeer : Packet() {
signalType = SignalType.fromValue(stream.readInt8()) signalType = SignalType.fromValue(stream.readInt8())
if ( if (
signalType == SignalType.END_CALL_BECAUSE_BUSY || signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED ||
signalType == SignalType.RINGING_TIMEOUT
) { ) {
return return
} }
@@ -42,8 +46,13 @@ class PacketSignalPeer : Packet() {
if (signalType == SignalType.KEY_EXCHANGE) { if (signalType == SignalType.KEY_EXCHANGE) {
sharedPublic = stream.readString() sharedPublic = stream.readString()
} }
if (signalType == SignalType.CREATE_ROOM) { if (
roomId = stream.readString() signalType == SignalType.CALL ||
signalType == SignalType.ACCEPT ||
signalType == SignalType.END_CALL
) {
callId = stream.readString()
joinToken = stream.readString()
} }
} }
@@ -53,7 +62,8 @@ class PacketSignalPeer : Packet() {
stream.writeInt8(signalType.value) stream.writeInt8(signalType.value)
if ( if (
signalType == SignalType.END_CALL_BECAUSE_BUSY || signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED ||
signalType == SignalType.RINGING_TIMEOUT
) { ) {
return stream return stream
} }
@@ -62,8 +72,13 @@ class PacketSignalPeer : Packet() {
if (signalType == SignalType.KEY_EXCHANGE) { if (signalType == SignalType.KEY_EXCHANGE) {
stream.writeString(sharedPublic) stream.writeString(sharedPublic)
} }
if (signalType == SignalType.CREATE_ROOM) { if (
stream.writeString(roomId) signalType == SignalType.CALL ||
signalType == SignalType.ACCEPT ||
signalType == SignalType.END_CALL
) {
stream.writeString(callId)
stream.writeString(joinToken)
} }
return stream return stream
} }

View File

@@ -19,16 +19,12 @@ enum class WebRTCSignalType(val value: Int) {
class PacketWebRTC : Packet() { class PacketWebRTC : Packet() {
var signalType: WebRTCSignalType = WebRTCSignalType.OFFER var signalType: WebRTCSignalType = WebRTCSignalType.OFFER
var sdpOrCandidate: String = "" var sdpOrCandidate: String = ""
var publicKey: String = ""
var deviceId: String = ""
override fun getPacketId(): Int = 0x1B override fun getPacketId(): Int = 0x1B
override fun receive(stream: Stream) { override fun receive(stream: Stream) {
signalType = WebRTCSignalType.fromValue(stream.readInt8()) signalType = WebRTCSignalType.fromValue(stream.readInt8())
sdpOrCandidate = stream.readString() sdpOrCandidate = stream.readString()
publicKey = stream.readString()
deviceId = stream.readString()
} }
override fun send(): Stream { override fun send(): Stream {
@@ -36,8 +32,6 @@ class PacketWebRTC : Packet() {
stream.writeInt16(getPacketId()) stream.writeInt16(getPacketId())
stream.writeInt8(signalType.value) stream.writeInt8(signalType.value)
stream.writeString(sdpOrCandidate) stream.writeString(sdpOrCandidate)
stream.writeString(publicKey)
stream.writeString(deviceId)
return stream return stream
} }
} }

View File

@@ -135,10 +135,9 @@ class Protocol(
"status=${packet.status} ts=${packet.timestamp}" "status=${packet.status} ts=${packet.timestamp}"
is PacketSignalPeer -> is PacketSignalPeer ->
"type=${packet.signalType} src=${shortKey(packet.src)} dst=${shortKey(packet.dst)} " + "type=${packet.signalType} src=${shortKey(packet.src)} dst=${shortKey(packet.dst)} " +
"sharedLen=${packet.sharedPublic.length} room=${shortKey(packet.roomId, 12)}" "sharedLen=${packet.sharedPublic.length} callId=${shortKey(packet.callId, 12)} join=${shortKey(packet.joinToken, 12)}"
is PacketWebRTC -> is PacketWebRTC ->
"type=${packet.signalType} sdpLen=${packet.sdpOrCandidate.length} " + "type=${packet.signalType} sdpLen=${packet.sdpOrCandidate.length} " +
"pk=${shortKey(packet.publicKey)} device=${shortKey(packet.deviceId, 12)} " +
"preview='${shortText(packet.sdpOrCandidate, 64)}'" "preview='${shortText(packet.sdpOrCandidate, 64)}'"
is PacketIceServers -> is PacketIceServers ->
"count=${packet.iceServers.size} firstUrl='${packet.iceServers.firstOrNull()?.url?.let { shortText(it, 40) } ?: "<none>"}'" "count=${packet.iceServers.size} firstUrl='${packet.iceServers.firstOrNull()?.url?.let { shortText(it, 40) } ?: "<none>"}'"

View File

@@ -1373,11 +1373,12 @@ object ProtocolManager {
src: String = "", src: String = "",
dst: String = "", dst: String = "",
sharedPublic: String = "", sharedPublic: String = "",
roomId: String = "" callId: String = "",
joinToken: String = ""
) { ) {
addLog( addLog(
"📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " + "📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " +
"sharedLen=${sharedPublic.length} room=${shortKeyForLog(roomId, 12)}" "sharedLen=${sharedPublic.length} callId=${shortKeyForLog(callId, 12)} join=${shortKeyForLog(joinToken, 12)}"
) )
send( send(
PacketSignalPeer().apply { PacketSignalPeer().apply {
@@ -1385,7 +1386,8 @@ object ProtocolManager {
this.src = src this.src = src
this.dst = dst this.dst = dst
this.sharedPublic = sharedPublic this.sharedPublic = sharedPublic
this.roomId = roomId this.callId = callId
this.joinToken = joinToken
} }
) )
} }
@@ -1394,19 +1396,14 @@ object ProtocolManager {
* Send WebRTC signaling packet (0x1B). * Send WebRTC signaling packet (0x1B).
*/ */
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) { fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" }
val did = appContext?.let { getOrCreateDeviceId(it) } ?: ""
addLog( addLog(
"📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " + "📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " +
"pk=${shortKeyForLog(pk)} did=${shortKeyForLog(did, 12)} " +
"preview='${shortTextForLog(sdpOrCandidate, 56)}'" "preview='${shortTextForLog(sdpOrCandidate, 56)}'"
) )
send( send(
PacketWebRTC().apply { PacketWebRTC().apply {
this.signalType = signalType this.signalType = signalType
this.sdpOrCandidate = sdpOrCandidate this.sdpOrCandidate = sdpOrCandidate
this.publicKey = pk
this.deviceId = did
} }
) )
} }
@@ -1428,7 +1425,7 @@ object ProtocolManager {
(packet as? PacketSignalPeer)?.let { (packet as? PacketSignalPeer)?.let {
addLog( addLog(
"📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src)} dst=${shortKeyForLog(it.dst)} " + "📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src)} dst=${shortKeyForLog(it.dst)} " +
"sharedLen=${it.sharedPublic.length} room=${shortKeyForLog(it.roomId, 12)}" "sharedLen=${it.sharedPublic.length} callId=${shortKeyForLog(it.callId, 12)} join=${shortKeyForLog(it.joinToken, 12)}"
) )
callback(it) callback(it)
} }
@@ -1450,7 +1447,6 @@ object ProtocolManager {
(packet as? PacketWebRTC)?.let { (packet as? PacketWebRTC)?.let {
addLog( addLog(
"📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " + "📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " +
"pk=${shortKeyForLog(it.publicKey)} did=${shortKeyForLog(it.deviceId, 12)} " +
"preview='${shortTextForLog(it.sdpOrCandidate, 56)}'" "preview='${shortTextForLog(it.sdpOrCandidate, 56)}'"
) )
callback(it) callback(it)

View File

@@ -160,6 +160,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
"public_key", "public_key",
"publicKey" "publicKey"
) )
val callId = firstNonBlank(data, "callId", "call_id")
val joinToken = firstNonBlank(data, "joinToken", "join_token")
val senderName = val senderName =
firstNonBlank( firstNonBlank(
data, data,
@@ -203,7 +205,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
handleIncomingCallPush( handleIncomingCallPush(
dialogKey = dialogKey ?: senderPublicKey.orEmpty(), dialogKey = dialogKey ?: senderPublicKey.orEmpty(),
title = senderName, title = senderName,
body = messagePreview callId = callId.orEmpty(),
joinToken = joinToken.orEmpty()
) )
handledByData = true handledByData = true
} }
@@ -395,7 +398,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
/** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */ /** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) { private fun handleIncomingCallPush(
dialogKey: String,
title: String,
callId: String,
joinToken: String
) {
pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title") pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title")
wakeProtocolFromPush("call") wakeProtocolFromPush("call")
@@ -415,7 +423,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
return return
} }
val dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}" val normalizedCallId = callId.trim()
val normalizedJoinToken = joinToken.trim()
val dedupKey =
if (normalizedCallId.isNotBlank()) {
"call:$normalizedCallId"
} else {
"call:${normalizedDialog.ifEmpty { "__no_dialog__" }}"
}
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey] val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
@@ -428,7 +443,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush") pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush")
// Сразу ставим CallManager в INCOMING — не ждём WebSocket // Сразу ставим CallManager в INCOMING — не ждём WebSocket
CallManager.setIncomingFromPush(normalizedDialog, resolvedName) CallManager.setIncomingFromPush(
peerPublicKey = normalizedDialog,
peerTitle = resolvedName,
callId = normalizedCallId,
joinToken = normalizedJoinToken
)
pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}") pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}")
// Пробуем запустить IncomingCallActivity напрямую из FCM // Пробуем запустить IncomingCallActivity напрямую из FCM