Попытка обновления шифрования звонков и работа над UI

This commit is contained in:
2026-03-25 01:47:12 +05:00
parent 419101a4a9
commit 530047c5d0
14 changed files with 1326 additions and 99 deletions

View File

@@ -19,10 +19,6 @@ import org.bouncycastle.math.ec.rfc7748.X25519
import org.json.JSONObject
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.FrameCryptor
import org.webrtc.FrameCryptorAlgorithm
import org.webrtc.FrameCryptorFactory
import org.webrtc.FrameCryptorKeyProvider
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection
@@ -120,10 +116,10 @@ object CallManager {
private var localAudioTrack: AudioTrack? = null
private val bufferedRemoteCandidates = mutableListOf<IceCandidate>()
// E2EE (FrameCryptor AES-GCM)
private var keyProvider: FrameCryptorKeyProvider? = null
private var senderCryptor: FrameCryptor? = null
private var receiverCryptor: FrameCryptor? = null
// E2EE (XChaCha20 — compatible with Desktop)
private var sharedKeyBytes: ByteArray? = null
private var senderEncryptor: XChaCha20E2EE.Encryptor? = null
private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null
private var iceServers: List<PeerConnection.IceServer> = emptyList()
@@ -132,6 +128,7 @@ object CallManager {
initialized = true
appContext = context.applicationContext
CallSoundManager.initialize(context)
XChaCha20E2EE.initWithContext(context)
signalWaiter = ProtocolManager.waitCallSignal { packet ->
scope.launch { handleSignalPacket(packet) }
@@ -255,16 +252,21 @@ object CallManager {
}
private suspend fun handleSignalPacket(packet: PacketSignalPeer) {
breadcrumb("SIG: ${packet.signalType} from=${packet.src.take(8)}… phase=${_state.value.phase}")
when (packet.signalType) {
SignalType.END_CALL_BECAUSE_BUSY -> {
breadcrumb("SIG: peer busy → reset")
resetSession(reason = "User is busy", notifyPeer = false)
return
}
SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> {
breadcrumb("SIG: peer disconnected → reset")
resetSession(reason = "Peer disconnected", notifyPeer = false)
return
}
SignalType.END_CALL -> {
breadcrumb("SIG: END_CALL → reset")
resetSession(reason = "Call ended", notifyPeer = false)
return
}
@@ -274,16 +276,18 @@ object CallManager {
val currentPeer = _state.value.peerPublicKey
val src = packet.src.trim()
if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) {
breadcrumb("SIG: IGNORED (src mismatch: expected=${currentPeer.take(8)}… got=${src.take(8)}…)")
return
}
when (packet.signalType) {
SignalType.CALL -> {
if (_state.value.phase != CallPhase.IDLE) {
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
val callerKey = packet.src.trim()
if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) {
ProtocolManager.sendCallSignal(
signalType = SignalType.END_CALL,
signalType = SignalType.END_CALL_BECAUSE_BUSY,
src = ownPublicKey,
dst = callerKey
)
@@ -292,6 +296,7 @@ object CallManager {
}
val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
role = CallRole.CALLEE
resetRtcObjects()
setPeer(incomingPeer, "", "")
@@ -305,11 +310,16 @@ object CallManager {
resolvePeerIdentity(incomingPeer)
}
SignalType.KEY_EXCHANGE -> {
breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
handleKeyExchange(packet)
}
SignalType.CREATE_ROOM -> {
val incomingRoomId = packet.roomId.trim()
if (incomingRoomId.isBlank()) return
breadcrumb("SIG: CREATE_ROOM roomId=${incomingRoomId.take(16)}")
if (incomingRoomId.isBlank()) {
breadcrumb("SIG: CREATE_ROOM IGNORED — empty roomId!")
return
}
roomId = incomingRoomId
updateState {
it.copy(
@@ -320,23 +330,35 @@ object CallManager {
ensurePeerConnectionAndOffer()
}
SignalType.ACTIVE_CALL -> Unit
else -> Unit
else -> breadcrumb("SIG: unhandled ${packet.signalType}")
}
}
private suspend fun handleKeyExchange(packet: PacketSignalPeer) {
val peerKey = packet.src.trim().ifBlank { _state.value.peerPublicKey }
if (peerKey.isBlank()) return
if (peerKey.isBlank()) {
breadcrumb("KE: ABORT — peerKey blank")
return
}
setPeer(peerKey, _state.value.peerTitle, _state.value.peerUsername)
val peerPublicHex = packet.sharedPublic.trim()
if (peerPublicHex.isBlank()) return
if (peerPublicHex.isBlank()) {
breadcrumb("KE: ABORT — sharedPublic blank")
return
}
breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}")
if (role == CallRole.CALLER) {
generateSessionKeys()
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
val sharedKey = computeSharedSecretHex(peerPublicHex)
if (sharedKey == null) {
breadcrumb("KE: CALLER — computeSharedSecret FAILED")
return
}
setupE2EE(sharedKey)
updateState { it.copy(keyCast = sharedKey.take(32), statusText = "Creating room...") }
breadcrumb("KE: CALLER — E2EE ready, sending KEY_EXCHANGE + CREATE_ROOM")
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") }
val localPublic = localPublicKey ?: return
ProtocolManager.sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
@@ -355,39 +377,58 @@ object CallManager {
if (role == CallRole.CALLEE) {
if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("KE: CALLEE — regenerating session keys (were null)")
generateSessionKeys()
}
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
val sharedKey = computeSharedSecretHex(peerPublicHex)
if (sharedKey == null) {
breadcrumb("KE: CALLEE — computeSharedSecret FAILED")
return
}
setupE2EE(sharedKey)
updateState { it.copy(keyCast = sharedKey.take(32), phase = CallPhase.CONNECTING) }
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM")
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
}
}
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
val phase = _state.value.phase
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) return
val pc = peerConnection ?: return
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
return
}
val pc = peerConnection
if (pc == null) {
breadcrumb("RTC: IGNORED ${packet.signalType} — peerConnection=null!")
return
}
when (packet.signalType) {
WebRTCSignalType.ANSWER -> {
breadcrumb("RTC: ANSWER received")
val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return
try {
pc.setRemoteDescriptionAwait(answer)
remoteDescriptionSet = true
flushBufferedRemoteCandidates()
breadcrumb("RTC: ANSWER applied OK, remoteDesc=true")
} catch (e: Exception) {
Log.e(TAG, "Failed to set remote answer", e)
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
saveCrashReport("setRemoteDescription(answer) failed", e)
}
}
WebRTCSignalType.ICE_CANDIDATE -> {
val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return
if (!remoteDescriptionSet) {
breadcrumb("RTC: ICE buffered (remoteDesc not set yet)")
bufferedRemoteCandidates.add(candidate)
return
}
breadcrumb("RTC: ICE added: ${candidate.sdp.take(40)}")
runCatching { pc.addIceCandidate(candidate) }
}
WebRTCSignalType.OFFER -> {
breadcrumb("RTC: OFFER received (offerSent=$offerSent)")
val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return
try {
pc.setRemoteDescriptionAwait(remoteOffer)
@@ -399,8 +440,10 @@ object CallManager {
signalType = WebRTCSignalType.ANSWER,
sdpOrCandidate = serializeSessionDescription(answer)
)
breadcrumb("RTC: OFFER handled → ANSWER sent")
} catch (e: Exception) {
Log.e(TAG, "Failed to handle remote offer", e)
breadcrumb("RTC: OFFER FAILED — ${e.message}")
saveCrashReport("handleOffer failed", e)
}
}
}
@@ -422,12 +465,27 @@ object CallManager {
private suspend fun ensurePeerConnectionAndOffer() {
val peerKey = _state.value.peerPublicKey
if (peerKey.isBlank() || roomId.isBlank()) return
if (offerSent) return
if (peerKey.isBlank() || roomId.isBlank()) {
breadcrumb("PC: ensurePCAndOffer SKIP — peer=${peerKey.take(8)}… room=${roomId.take(8)}")
return
}
if (offerSent) {
breadcrumb("PC: ensurePCAndOffer SKIP — offerSent=true")
return
}
breadcrumb("PC: ensurePCAndOffer START role=$role room=${roomId.take(8)}")
ensurePeerFactory()
val factory = peerConnectionFactory ?: return
val pc = peerConnection ?: createPeerConnection(factory) ?: return
val factory = peerConnectionFactory
if (factory == null) {
breadcrumb("PC: ABORT — factory=null")
return
}
val pc = peerConnection ?: createPeerConnection(factory)
if (pc == null) {
breadcrumb("PC: ABORT — createPeerConnection returned null")
return
}
if (audioSource == null) {
audioSource = factory.createAudioSource(MediaConstraints())
@@ -436,6 +494,7 @@ object CallManager {
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
localAudioTrack?.setEnabled(!_state.value.isMuted)
pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID))
breadcrumb("PC: audio track added, attaching E2EE…")
attachSenderE2EE(pc)
}
@@ -447,16 +506,20 @@ object CallManager {
sdpOrCandidate = serializeSessionDescription(offer)
)
offerSent = true
breadcrumb("PC: OFFER sent OK")
} catch (e: Exception) {
Log.e(TAG, "Failed to create/send offer", e)
breadcrumb("PC: OFFER FAILED — ${e.message}")
saveCrashReport("createOffer failed", e)
}
}
private fun createPeerConnection(factory: PeerConnectionFactory): PeerConnection? {
breadcrumb("PC: createPeerConnection iceServers=${iceServers.size}")
val rtcIceServers =
if (iceServers.isNotEmpty()) {
iceServers
} else {
breadcrumb("PC: no TURN servers — using Google STUN fallback")
listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer())
}
@@ -466,12 +529,19 @@ object CallManager {
val observer =
object : PeerConnection.Observer {
override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit
override fun onSignalingChange(newState: PeerConnection.SignalingState?) {
breadcrumb("PC: signalingState=$newState")
}
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
breadcrumb("PC: iceConnState=$newState")
}
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
breadcrumb("PC: iceGathering=$newState")
}
override fun onIceCandidate(candidate: IceCandidate?) {
if (candidate == null) return
breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}")
ProtocolManager.sendWebRtcSignal(
signalType = WebRTCSignalType.ICE_CANDIDATE,
sdpOrCandidate = serializeIceCandidate(candidate)
@@ -484,9 +554,11 @@ object CallManager {
override fun onRenegotiationNeeded() = Unit
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
override fun onTrack(transceiver: RtpTransceiver?) {
breadcrumb("PC: onTrack → attachReceiverE2EE")
attachReceiverE2EE(transceiver)
}
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
breadcrumb("PC: connState=$newState")
when (newState) {
PeerConnection.PeerConnectionState.CONNECTED -> {
onCallConnected()
@@ -494,7 +566,10 @@ object CallManager {
PeerConnection.PeerConnectionState.DISCONNECTED,
PeerConnection.PeerConnectionState.FAILED,
PeerConnection.PeerConnectionState.CLOSED -> {
resetSession(reason = "Connection lost", notifyPeer = false)
// Dispatch to our scope — this callback fires on WebRTC thread
scope.launch {
resetSession(reason = "Connection lost", notifyPeer = false)
}
}
else -> Unit
}
@@ -551,6 +626,7 @@ object CallManager {
}
private fun resetSession(reason: String?, notifyPeer: Boolean) {
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
val snapshot = _state.value
val wasActive = snapshot.phase != CallPhase.IDLE
val peerToNotify = snapshot.peerPublicKey
@@ -571,6 +647,7 @@ object CallManager {
Log.d(TAG, reason)
}
resetRtcObjects()
e2eeAvailable = true
role = null
roomId = ""
offerSent = false
@@ -585,6 +662,9 @@ object CallManager {
private fun resetRtcObjects() {
bufferedRemoteCandidates.clear()
// Teardown E2EE BEFORE closing PeerConnection — WebRTC may access
// encryptor/decryptor during close(), causing SIGSEGV if done after.
teardownE2EE()
runCatching { localAudioTrack?.setEnabled(false) }
runCatching { localAudioTrack?.dispose() }
runCatching { audioSource?.dispose() }
@@ -592,7 +672,6 @@ object CallManager {
localAudioTrack = null
audioSource = null
peerConnection = null
teardownE2EE()
}
private fun flushBufferedRemoteCandidates() {
@@ -604,7 +683,10 @@ object CallManager {
bufferedRemoteCandidates.clear()
}
// ── E2EE (FrameCryptor AES-GCM) ─────────────────────────────────
// ── E2EE (XChaCha20 — compatible with Desktop) ──────────────────
@Volatile
private var e2eeAvailable = true
private fun setupE2EE(sharedKeyHex: String) {
val keyBytes = sharedKeyHex.hexToBytes()
@@ -612,53 +694,107 @@ object CallManager {
Log.e(TAG, "E2EE: invalid key (${keyBytes?.size ?: 0} bytes)")
return
}
val kp = FrameCryptorFactory.createFrameCryptorKeyProvider(
/* sharedKey */ true,
/* ratchetSalt */ ByteArray(0),
/* ratchetWindowSize */ 0,
/* uncryptedMagicBytes */ ByteArray(0),
/* failureTolerance */ 0,
/* keyRingSize */ 1,
/* discardFrameWhenCryptorNotReady */ false
)
kp.setSharedKey(0, keyBytes.copyOf(32))
keyProvider = kp
Log.i(TAG, "E2EE key provider created (AES-GCM)")
sharedKeyBytes = keyBytes.copyOf(32)
// Open native diagnostics file for frame-level logging
try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
} catch (_: Throwable) {}
Log.i(TAG, "E2EE key ready (XChaCha20)")
}
/** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
private fun breadcrumb(step: String) {
try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val f = java.io.File(dir, "e2ee_breadcrumb.txt")
// Reset file at start of key exchange
if (step.startsWith("KE:") && step.contains("agreement")) {
f.writeText("")
}
f.appendText("${System.currentTimeMillis()} $step\n")
} catch (_: Throwable) {}
}
/** Save a full crash report to crash_reports/ */
private fun saveCrashReport(title: String, error: Throwable) {
try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date())
val f = java.io.File(dir, "crash_e2ee_$ts.txt")
val sw = java.io.StringWriter()
error.printStackTrace(java.io.PrintWriter(sw))
f.writeText("=== E2EE CRASH REPORT ===\n$title\n\nType: ${error.javaClass.name}\nMessage: ${error.message}\n\n$sw")
} catch (_: Throwable) {}
}
private fun attachSenderE2EE(pc: PeerConnection) {
val factory = peerConnectionFactory ?: return
val kp = keyProvider ?: return
if (!e2eeAvailable) return
val key = sharedKeyBytes ?: return
val sender = pc.senders.firstOrNull() ?: return
senderCryptor = FrameCryptorFactory.createFrameCryptorForRtpSender(
factory, sender, "caller", FrameCryptorAlgorithm.AES_GCM, kp
)
senderCryptor?.setEnabled(true)
Log.i(TAG, "E2EE sender cryptor attached")
try {
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
val enc = XChaCha20E2EE.Encryptor(key)
breadcrumb("2. encryptor created")
val ptr = enc.getNativeFrameEncryptor()
breadcrumb("3. encryptor ptr=0x${ptr.toString(16)}")
if (ptr == 0L) {
saveCrashReport("Encryptor native ptr is 0", RuntimeException("null native ptr"))
return
}
breadcrumb("4. calling sender.setFrameEncryptor…")
sender.setFrameEncryptor(enc)
breadcrumb("5. setFrameEncryptor OK!")
senderEncryptor = enc
} catch (e: Throwable) {
saveCrashReport("attachSenderE2EE failed", e)
Log.e(TAG, "E2EE: sender encryptor failed", e)
e2eeAvailable = false
}
}
private fun attachReceiverE2EE(transceiver: RtpTransceiver?) {
val factory = peerConnectionFactory ?: return
val kp = keyProvider ?: return
if (!e2eeAvailable) return
val key = sharedKeyBytes ?: return
val receiver = transceiver?.receiver ?: return
receiverCryptor = FrameCryptorFactory.createFrameCryptorForRtpReceiver(
factory, receiver, "callee", FrameCryptorAlgorithm.AES_GCM, kp
)
receiverCryptor?.setEnabled(true)
Log.i(TAG, "E2EE receiver cryptor attached")
try {
breadcrumb("6. decryptor: creating…")
val dec = XChaCha20E2EE.Decryptor(key)
breadcrumb("7. decryptor created")
val ptr = dec.getNativeFrameDecryptor()
breadcrumb("8. decryptor ptr=0x${ptr.toString(16)}")
if (ptr == 0L) {
saveCrashReport("Decryptor native ptr is 0", RuntimeException("null native ptr"))
return
}
breadcrumb("9. calling receiver.setFrameDecryptor…")
receiver.setFrameDecryptor(dec)
breadcrumb("10. setFrameDecryptor OK!")
receiverDecryptor = dec
} catch (e: Throwable) {
saveCrashReport("attachReceiverE2EE failed", e)
Log.e(TAG, "E2EE: receiver decryptor failed", e)
e2eeAvailable = false
}
}
private fun teardownE2EE() {
runCatching { senderCryptor?.setEnabled(false) }
runCatching { senderCryptor?.dispose() }
runCatching { receiverCryptor?.setEnabled(false) }
runCatching { receiverCryptor?.dispose() }
runCatching { keyProvider?.dispose() }
senderCryptor = null
receiverCryptor = null
keyProvider = null
// Release our ref. WebRTC holds its own ref via scoped_refptr.
// After our Release: WebRTC ref remains. On peerConnection.close()
// WebRTC releases its ref → ref=0 → native object deleted.
runCatching { senderEncryptor?.dispose() }
runCatching { receiverDecryptor?.dispose() }
senderEncryptor = null
receiverDecryptor = null
sharedKeyBytes?.let { it.fill(0) }
sharedKeyBytes = null
runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
}
private fun generateSessionKeys() {
@@ -674,10 +810,27 @@ object CallManager {
val privateKey = localPrivateKey ?: return null
val peerPublic = peerPublicHex.hexToBytes() ?: return null
if (peerPublic.size != 32) return null
val shared = ByteArray(32)
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, shared, 0)
if (!ok) return null
return shared.toHex()
val rawDh = ByteArray(32)
breadcrumb("KE: X25519 agreement…")
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
if (!ok) {
breadcrumb("KE: X25519 FAILED")
return null
}
breadcrumb("KE: X25519 OK, calling HSalsa20…")
return try {
val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
rawDh.fill(0)
breadcrumb("KE: HSalsa20 OK, key ready")
naclShared.toHex()
} catch (e: Throwable) {
saveCrashReport("HSalsa20 failed", e)
breadcrumb("KE: HSalsa20 FAILED: ${e.message}")
e2eeAvailable = false
val hex = rawDh.toHex()
rawDh.fill(0)
hex
}
}
private fun serializeSessionDescription(description: SessionDescription): String {

View File

@@ -0,0 +1,107 @@
package com.rosetta.messenger.network
import android.util.Log
import org.webrtc.FrameDecryptor
import org.webrtc.FrameEncryptor
/**
* XChaCha20-based E2EE compatible with Rosetta Desktop.
*
* Desktop encrypts audio frames using XChaCha20 (libsodium) with a nonce
* derived from the RTP timestamp. The shared key is computed as
* nacl.box.before(peerPub, ownSecret) = HSalsa20(zeros, X25519(sk, pk)).
*
* This class provides:
* - [hsalsa20] — applies HSalsa20 to a raw X25519 shared secret,
* producing the same key as nacl.box.before().
* - [Encryptor] / [Decryptor] — WebRTC FrameEncryptor / FrameDecryptor
* that use XChaCha20 matching the Desktop implementation.
*/
object XChaCha20E2EE {
private const val TAG = "XChaCha20E2EE"
var nativeLoaded: Boolean = false
private set
private var crashFilePath: String? = null
fun initWithContext(context: android.content.Context) {
if (!nativeLoaded) return
try {
val dir = java.io.File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val path = java.io.File(dir, "native_crash.txt").absolutePath
crashFilePath = path
nativeInstallCrashHandler(path)
Log.i(TAG, "Native crash handler installed → $path")
} catch (e: Throwable) {
Log.e(TAG, "Failed to install native crash handler", e)
}
}
init {
try {
System.loadLibrary("rosetta_e2ee")
nativeLoaded = true
Log.i(TAG, "Native library loaded successfully")
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "Failed to load native library rosetta_e2ee", e)
}
}
/**
* HSalsa20(zeros_16, rawDhShared, sigma) — converts a raw X25519
* shared secret into the NaCl box-before shared key.
*/
fun hsalsa20(rawDhShared: ByteArray): ByteArray {
require(nativeLoaded) { "Native library not loaded" }
require(rawDhShared.size >= 32) { "Raw DH shared secret must be >= 32 bytes" }
return nativeHSalsa20(rawDhShared)
}
/** WebRTC [FrameEncryptor] backed by native XChaCha20. */
class Encryptor(key: ByteArray) : FrameEncryptor {
private val nativePtr: Long
init {
require(nativeLoaded) { "Native library not loaded" }
nativePtr = nativeCreateEncryptor(key)
Log.i(TAG, "Encryptor created, ptr=0x${nativePtr.toString(16)}")
}
override fun getNativeFrameEncryptor(): Long = nativePtr
fun dispose() {
if (nativePtr != 0L) nativeReleaseEncryptor(nativePtr)
}
}
/** WebRTC [FrameDecryptor] backed by native XChaCha20. */
class Decryptor(key: ByteArray) : FrameDecryptor {
private val nativePtr: Long
init {
require(nativeLoaded) { "Native library not loaded" }
nativePtr = nativeCreateDecryptor(key)
Log.i(TAG, "Decryptor created, ptr=0x${nativePtr.toString(16)}")
}
override fun getNativeFrameDecryptor(): Long = nativePtr
fun dispose() {
if (nativePtr != 0L) nativeReleaseDecryptor(nativePtr)
}
}
/* ── JNI ─────────────────────────────────────────────────── */
@JvmStatic private external fun nativeHSalsa20(rawDh: ByteArray): ByteArray
@JvmStatic private external fun nativeCreateEncryptor(key: ByteArray): Long
@JvmStatic private external fun nativeReleaseEncryptor(ptr: Long)
@JvmStatic private external fun nativeCreateDecryptor(key: ByteArray): Long
@JvmStatic private external fun nativeReleaseDecryptor(ptr: Long)
@JvmStatic private external fun nativeInstallCrashHandler(path: String)
@JvmStatic external fun nativeOpenDiagFile(path: String)
@JvmStatic external fun nativeCloseDiagFile()
}

View File

@@ -17,6 +17,9 @@ import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -93,17 +96,28 @@ fun CallOverlay(
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
)
) {
// ── Encryption badge top center ──
// ── Top bar: "Encrypted" left + QR icon right ──
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
Text(
text = "\uD83D\uDD12 Encrypted",
color = Color.White.copy(alpha = 0.4f),
fontSize = 13.sp,
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 12.dp)
)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\uD83D\uDD12 Encrypted",
color = Color.White.copy(alpha = 0.4f),
fontSize = 13.sp,
)
// QR grid icon — tap to show popover
if (state.keyCast.isNotBlank()) {
EncryptionKeyButton(keyHex = state.keyCast)
}
}
}
// ── Center content: rings + avatar + name + status ──
@@ -162,14 +176,6 @@ fun CallOverlay(
)
}
// Emoji key
if (state.keyCast.isNotBlank() && state.phase == CallPhase.ACTIVE) {
Spacer(modifier = Modifier.height(16.dp))
val emojis = remember(state.keyCast) { keyToEmojis(state.keyCast) }
if (emojis.isNotBlank()) {
Text(emojis, fontSize = 32.sp, letterSpacing = 4.sp, textAlign = TextAlign.Center)
}
}
}
// ── Bottom buttons ──
@@ -430,16 +436,113 @@ private fun formatCallDuration(seconds: Int): String {
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec)
}
private fun keyToEmojis(keyCast: String): String {
val emojis = listOf(
"\uD83D\uDE00", "\uD83D\uDE0E", "\uD83D\uDE80", "\uD83D\uDD12",
"\uD83C\uDF1F", "\uD83C\uDF08", "\uD83D\uDC8E", "\uD83C\uDF40",
"\uD83D\uDD25", "\uD83C\uDF3A", "\uD83E\uDD8B", "\uD83C\uDF0D",
"\uD83C\uDF89", "\uD83E\uDD84", "\uD83C\uDF52", "\uD83D\uDCA1"
)
val hex = keyCast.replace(Regex("[^0-9a-fA-F]"), "").take(8)
if (hex.length < 8) return ""
return (0 until 4).joinToString(" ") { i ->
emojis[hex.substring(i * 2, i * 2 + 2).toInt(16) % emojis.size]
/**
* QR icon in top-right corner — tap to show encryption key dropdown.
* 1:1 match with Desktop's IconQrcode + Popover.
*/
@Composable
private fun EncryptionKeyButton(keyHex: String) {
var showPopup by remember { mutableStateOf(false) }
Box {
// QR code icon (matches Desktop IconQrcode size={24} color="white")
Icon(
imageVector = Icons.Default.QrCode2,
contentDescription = "Encryption key",
tint = Color.White,
modifier = Modifier
.size(24.dp)
.clickable { showPopup = !showPopup }
)
// Dropdown popover (matches Desktop Popover width={300} withArrow)
androidx.compose.material3.DropdownMenu(
expanded = showPopup,
onDismissRequest = { showPopup = false },
modifier = Modifier
.widthIn(max = 300.dp)
.background(Color(0xFF1E293B)),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "This call is secured by 256 bit end-to-end encryption. " +
"Only you and the recipient can read or listen to the content of this call.",
color = Color.White.copy(alpha = 0.6f),
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.weight(1f)
)
Canvas(modifier = Modifier.size(80.dp)) {
drawKeyGrid(keyHex, size.width, this)
}
}
Spacer(modifier = Modifier.height(8.dp))
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
var copied by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.08f))
.clickable {
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(keyHex))
copied = true
}
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = "Copy key",
tint = Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
Text(
text = if (copied) "Copied!" else keyHex.take(16) + "..." + keyHex.takeLast(8),
color = Color.White.copy(alpha = 0.4f),
fontSize = 10.sp,
maxLines = 1,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
}
}
}
/** Palette matching Desktop's Mantine theme.colors.blue[1..5] */
private val KeyGridPalette = listOf(
Color(0xFFDBE4FF), // blue[1]
Color(0xFFBAC8FF), // blue[2]
Color(0xFF91A7FF), // blue[3]
Color(0xFF748FFC), // blue[4]
Color(0xFF5C7CFA), // blue[5]
)
/**
* Draw 8x8 color grid — same algorithm as Desktop KeyImage.tsx:
* each character's charCode % palette.size determines the color.
*/
private fun drawKeyGrid(keyHex: String, totalSize: Float, scope: androidx.compose.ui.graphics.drawscope.DrawScope) {
val cells = 8
val cellSize = totalSize / cells
for (i in 0 until cells * cells) {
val color = if (i < keyHex.length) {
KeyGridPalette[keyHex[i].code % KeyGridPalette.size]
} else {
KeyGridPalette[0]
}
val row = i / cells
val col = i % cells
scope.drawRect(
color = color,
topLeft = Offset(col * cellSize, row * cellSize),
size = androidx.compose.ui.geometry.Size(cellSize, cellSize)
)
}
}

View File

@@ -44,8 +44,8 @@ class CrashReportManager private constructor(private val context: Context) : Thr
fun getCrashReports(context: Context): List<CrashReport> {
val crashDir = File(context.filesDir, CRASH_DIR)
if (!crashDir.exists()) return emptyList()
return crashDir.listFiles()
val reports = crashDir.listFiles()
?.filter { it.extension == "txt" }
?.sortedByDescending { it.lastModified() }
?.map { file ->
@@ -54,7 +54,21 @@ class CrashReportManager private constructor(private val context: Context) : Thr
timestamp = file.lastModified(),
content = file.readText()
)
} ?: emptyList()
}?.toMutableList() ?: mutableListOf()
// Include native crash report if present
val nativeCrash = File(crashDir, "native_crash.txt")
if (nativeCrash.exists() && nativeCrash.length() > 0) {
reports.add(0, CrashReport(
fileName = "native_crash.txt",
timestamp = nativeCrash.lastModified(),
content = nativeCrash.readText()
))
// Delete after reading so it doesn't show up again
nativeCrash.delete()
}
return reports
}
/**