Попытка обновления шифрования звонков и работа над UI
This commit is contained in:
@@ -43,6 +43,19 @@ android {
|
|||||||
|
|
||||||
// Optimize Lottie animations
|
// Optimize Lottie animations
|
||||||
manifestPlaceholders["enableLottieOptimizations"] = "true"
|
manifestPlaceholders["enableLottieOptimizations"] = "true"
|
||||||
|
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake { cppFlags("-std=c++17") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
version = "3.22.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
33
app/src/main/cpp/CMakeLists.txt
Normal file
33
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22.1)
|
||||||
|
project(rosetta_e2ee LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_C_STANDARD 11)
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
|
||||||
|
add_library(rosetta_e2ee SHARED
|
||||||
|
crypto.c
|
||||||
|
rosetta_e2ee.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(rosetta_e2ee PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
# Hide all C++ symbols to avoid ODR clashes with WebRTC's .so
|
||||||
|
set_target_properties(rosetta_e2ee PROPERTIES
|
||||||
|
CXX_VISIBILITY_PRESET hidden
|
||||||
|
C_VISIBILITY_PRESET hidden
|
||||||
|
)
|
||||||
|
|
||||||
|
# Match WebRTC SDK build flags:
|
||||||
|
# -fno-rtti -fno-exceptions — standard WebRTC flags
|
||||||
|
# -fexperimental-relative-c++-abi-vtables — WebRTC uses relative vtables
|
||||||
|
# (32-bit offsets instead of 64-bit absolute pointers in vtable).
|
||||||
|
# Without this, setFrameEncryptor crashes with SIGSEGV because WebRTC
|
||||||
|
# reads our 64-bit pointers as 32-bit offsets.
|
||||||
|
target_compile_options(rosetta_e2ee PRIVATE
|
||||||
|
-fno-rtti
|
||||||
|
-fno-exceptions
|
||||||
|
-fexperimental-relative-c++-abi-vtables
|
||||||
|
)
|
||||||
|
|
||||||
|
find_library(log-lib log)
|
||||||
|
target_link_libraries(rosetta_e2ee ${log-lib})
|
||||||
248
app/src/main/cpp/crypto.c
Normal file
248
app/src/main/cpp/crypto.c
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Minimal crypto primitives for Rosetta E2EE:
|
||||||
|
* - HSalsa20 (for nacl.box.before() compatible key exchange)
|
||||||
|
* - HChaCha20 + ChaCha20-IETF → XChaCha20 (for frame encryption)
|
||||||
|
*
|
||||||
|
* Based on the public-domain algorithms by D.J. Bernstein.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "crypto.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* ── helpers ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#define ROTL32(v, n) (((v) << (n)) | ((v) >> (32 - (n))))
|
||||||
|
|
||||||
|
static uint32_t load32_le(const uint8_t *p) {
|
||||||
|
return (uint32_t)p[0]
|
||||||
|
| (uint32_t)p[1] << 8
|
||||||
|
| (uint32_t)p[2] << 16
|
||||||
|
| (uint32_t)p[3] << 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void store32_le(uint8_t *p, uint32_t v) {
|
||||||
|
p[0] = (uint8_t)(v);
|
||||||
|
p[1] = (uint8_t)(v >> 8);
|
||||||
|
p[2] = (uint8_t)(v >> 16);
|
||||||
|
p[3] = (uint8_t)(v >> 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "expand 32-byte k" as four little-endian uint32 */
|
||||||
|
static const uint32_t SIGMA[4] = {
|
||||||
|
0x61707865u, 0x3320646eu, 0x79622d32u, 0x6b206574u
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── HSalsa20 (Salsa20 family) ──────────────────────────────── */
|
||||||
|
|
||||||
|
void rosetta_hsalsa20(uint8_t out[32],
|
||||||
|
const uint8_t inp[16],
|
||||||
|
const uint8_t key[32])
|
||||||
|
{
|
||||||
|
uint32_t x[16];
|
||||||
|
x[ 0] = SIGMA[0];
|
||||||
|
x[ 1] = load32_le(key + 0);
|
||||||
|
x[ 2] = load32_le(key + 4);
|
||||||
|
x[ 3] = load32_le(key + 8);
|
||||||
|
x[ 4] = load32_le(key + 12);
|
||||||
|
x[ 5] = SIGMA[1];
|
||||||
|
x[ 6] = load32_le(inp + 0);
|
||||||
|
x[ 7] = load32_le(inp + 4);
|
||||||
|
x[ 8] = load32_le(inp + 8);
|
||||||
|
x[ 9] = load32_le(inp + 12);
|
||||||
|
x[10] = SIGMA[2];
|
||||||
|
x[11] = load32_le(key + 16);
|
||||||
|
x[12] = load32_le(key + 20);
|
||||||
|
x[13] = load32_le(key + 24);
|
||||||
|
x[14] = load32_le(key + 28);
|
||||||
|
x[15] = SIGMA[3];
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i += 2) {
|
||||||
|
/* column round */
|
||||||
|
x[ 4] ^= ROTL32(x[ 0] + x[12], 7);
|
||||||
|
x[ 8] ^= ROTL32(x[ 4] + x[ 0], 9);
|
||||||
|
x[12] ^= ROTL32(x[ 8] + x[ 4], 13);
|
||||||
|
x[ 0] ^= ROTL32(x[12] + x[ 8], 18);
|
||||||
|
x[ 9] ^= ROTL32(x[ 5] + x[ 1], 7);
|
||||||
|
x[13] ^= ROTL32(x[ 9] + x[ 5], 9);
|
||||||
|
x[ 1] ^= ROTL32(x[13] + x[ 9], 13);
|
||||||
|
x[ 5] ^= ROTL32(x[ 1] + x[13], 18);
|
||||||
|
x[14] ^= ROTL32(x[10] + x[ 6], 7);
|
||||||
|
x[ 2] ^= ROTL32(x[14] + x[10], 9);
|
||||||
|
x[ 6] ^= ROTL32(x[ 2] + x[14], 13);
|
||||||
|
x[10] ^= ROTL32(x[ 6] + x[ 2], 18);
|
||||||
|
x[ 3] ^= ROTL32(x[15] + x[11], 7);
|
||||||
|
x[ 7] ^= ROTL32(x[ 3] + x[15], 9);
|
||||||
|
x[11] ^= ROTL32(x[ 7] + x[ 3], 13);
|
||||||
|
x[15] ^= ROTL32(x[11] + x[ 7], 18);
|
||||||
|
/* row round */
|
||||||
|
x[ 1] ^= ROTL32(x[ 0] + x[ 3], 7);
|
||||||
|
x[ 2] ^= ROTL32(x[ 1] + x[ 0], 9);
|
||||||
|
x[ 3] ^= ROTL32(x[ 2] + x[ 1], 13);
|
||||||
|
x[ 0] ^= ROTL32(x[ 3] + x[ 2], 18);
|
||||||
|
x[ 6] ^= ROTL32(x[ 5] + x[ 4], 7);
|
||||||
|
x[ 7] ^= ROTL32(x[ 6] + x[ 5], 9);
|
||||||
|
x[ 4] ^= ROTL32(x[ 7] + x[ 6], 13);
|
||||||
|
x[ 5] ^= ROTL32(x[ 4] + x[ 7], 18);
|
||||||
|
x[11] ^= ROTL32(x[10] + x[ 9], 7);
|
||||||
|
x[ 8] ^= ROTL32(x[11] + x[10], 9);
|
||||||
|
x[ 9] ^= ROTL32(x[ 8] + x[11], 13);
|
||||||
|
x[10] ^= ROTL32(x[ 9] + x[ 8], 18);
|
||||||
|
x[12] ^= ROTL32(x[15] + x[14], 7);
|
||||||
|
x[13] ^= ROTL32(x[12] + x[15], 9);
|
||||||
|
x[14] ^= ROTL32(x[13] + x[12], 13);
|
||||||
|
x[15] ^= ROTL32(x[14] + x[13], 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* output words: 0, 5, 10, 15, 6, 7, 8, 9 */
|
||||||
|
store32_le(out + 0, x[ 0]);
|
||||||
|
store32_le(out + 4, x[ 5]);
|
||||||
|
store32_le(out + 8, x[10]);
|
||||||
|
store32_le(out + 12, x[15]);
|
||||||
|
store32_le(out + 16, x[ 6]);
|
||||||
|
store32_le(out + 20, x[ 7]);
|
||||||
|
store32_le(out + 24, x[ 8]);
|
||||||
|
store32_le(out + 28, x[ 9]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HChaCha20 (ChaCha20 family) ────────────────────────────── */
|
||||||
|
|
||||||
|
static void hchacha20(uint8_t out[32],
|
||||||
|
const uint8_t inp[16],
|
||||||
|
const uint8_t key[32])
|
||||||
|
{
|
||||||
|
uint32_t x[16];
|
||||||
|
x[ 0] = SIGMA[0];
|
||||||
|
x[ 1] = SIGMA[1];
|
||||||
|
x[ 2] = SIGMA[2];
|
||||||
|
x[ 3] = SIGMA[3];
|
||||||
|
x[ 4] = load32_le(key + 0);
|
||||||
|
x[ 5] = load32_le(key + 4);
|
||||||
|
x[ 6] = load32_le(key + 8);
|
||||||
|
x[ 7] = load32_le(key + 12);
|
||||||
|
x[ 8] = load32_le(key + 16);
|
||||||
|
x[ 9] = load32_le(key + 20);
|
||||||
|
x[10] = load32_le(key + 24);
|
||||||
|
x[11] = load32_le(key + 28);
|
||||||
|
x[12] = load32_le(inp + 0);
|
||||||
|
x[13] = load32_le(inp + 4);
|
||||||
|
x[14] = load32_le(inp + 8);
|
||||||
|
x[15] = load32_le(inp + 12);
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i += 2) {
|
||||||
|
/* column round */
|
||||||
|
#define QR(a, b, c, d) \
|
||||||
|
a += b; d ^= a; d = ROTL32(d, 16); \
|
||||||
|
c += d; b ^= c; b = ROTL32(b, 12); \
|
||||||
|
a += b; d ^= a; d = ROTL32(d, 8); \
|
||||||
|
c += d; b ^= c; b = ROTL32(b, 7);
|
||||||
|
|
||||||
|
QR(x[0], x[4], x[8], x[12]);
|
||||||
|
QR(x[1], x[5], x[9], x[13]);
|
||||||
|
QR(x[2], x[6], x[10], x[14]);
|
||||||
|
QR(x[3], x[7], x[11], x[15]);
|
||||||
|
/* diagonal round */
|
||||||
|
QR(x[0], x[5], x[10], x[15]);
|
||||||
|
QR(x[1], x[6], x[11], x[12]);
|
||||||
|
QR(x[2], x[7], x[8], x[13]);
|
||||||
|
QR(x[3], x[4], x[9], x[14]);
|
||||||
|
|
||||||
|
#undef QR
|
||||||
|
}
|
||||||
|
|
||||||
|
/* output words: 0, 1, 2, 3, 12, 13, 14, 15 */
|
||||||
|
store32_le(out + 0, x[ 0]);
|
||||||
|
store32_le(out + 4, x[ 1]);
|
||||||
|
store32_le(out + 8, x[ 2]);
|
||||||
|
store32_le(out + 12, x[ 3]);
|
||||||
|
store32_le(out + 16, x[12]);
|
||||||
|
store32_le(out + 20, x[13]);
|
||||||
|
store32_le(out + 24, x[14]);
|
||||||
|
store32_le(out + 28, x[15]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ChaCha20-IETF (RFC 8439) ───────────────────────────────── */
|
||||||
|
|
||||||
|
static void chacha20_block(uint8_t out[64],
|
||||||
|
const uint8_t key[32],
|
||||||
|
uint32_t counter,
|
||||||
|
const uint8_t nonce[12])
|
||||||
|
{
|
||||||
|
uint32_t s[16], x[16];
|
||||||
|
s[ 0] = SIGMA[0];
|
||||||
|
s[ 1] = SIGMA[1];
|
||||||
|
s[ 2] = SIGMA[2];
|
||||||
|
s[ 3] = SIGMA[3];
|
||||||
|
for (int i = 0; i < 8; i++) s[4 + i] = load32_le(key + i * 4);
|
||||||
|
s[12] = counter;
|
||||||
|
s[13] = load32_le(nonce + 0);
|
||||||
|
s[14] = load32_le(nonce + 4);
|
||||||
|
s[15] = load32_le(nonce + 8);
|
||||||
|
|
||||||
|
memcpy(x, s, sizeof(x));
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i += 2) {
|
||||||
|
#define QR(a, b, c, d) \
|
||||||
|
a += b; d ^= a; d = ROTL32(d, 16); \
|
||||||
|
c += d; b ^= c; b = ROTL32(b, 12); \
|
||||||
|
a += b; d ^= a; d = ROTL32(d, 8); \
|
||||||
|
c += d; b ^= c; b = ROTL32(b, 7);
|
||||||
|
|
||||||
|
QR(x[0], x[4], x[8], x[12]);
|
||||||
|
QR(x[1], x[5], x[9], x[13]);
|
||||||
|
QR(x[2], x[6], x[10], x[14]);
|
||||||
|
QR(x[3], x[7], x[11], x[15]);
|
||||||
|
QR(x[0], x[5], x[10], x[15]);
|
||||||
|
QR(x[1], x[6], x[11], x[12]);
|
||||||
|
QR(x[2], x[7], x[8], x[13]);
|
||||||
|
QR(x[3], x[4], x[9], x[14]);
|
||||||
|
|
||||||
|
#undef QR
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 16; i++) store32_le(out + i * 4, x[i] + s[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void chacha20_ietf_xor(uint8_t *out,
|
||||||
|
const uint8_t *in,
|
||||||
|
size_t len,
|
||||||
|
const uint8_t nonce[12],
|
||||||
|
const uint8_t key[32],
|
||||||
|
uint32_t initial_counter)
|
||||||
|
{
|
||||||
|
uint8_t block[64];
|
||||||
|
uint32_t ctr = initial_counter;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
chacha20_block(block, key, ctr++, nonce);
|
||||||
|
size_t chunk = len - off;
|
||||||
|
if (chunk > 64) chunk = 64;
|
||||||
|
for (size_t i = 0; i < chunk; i++) {
|
||||||
|
out[off + i] = in[off + i] ^ block[i];
|
||||||
|
}
|
||||||
|
off += chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(block, 0, sizeof(block));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── XChaCha20 XOR ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
void rosetta_xchacha20_xor(uint8_t *out,
|
||||||
|
const uint8_t *in,
|
||||||
|
size_t len,
|
||||||
|
const uint8_t nonce[24],
|
||||||
|
const uint8_t key[32])
|
||||||
|
{
|
||||||
|
/* Step 1: derive sub-key with HChaCha20(key, nonce[0..15]) */
|
||||||
|
uint8_t subkey[32];
|
||||||
|
hchacha20(subkey, nonce, key);
|
||||||
|
|
||||||
|
/* Step 2: ChaCha20-IETF with sub-key and nonce' = [0,0,0,0, nonce[16..23]] */
|
||||||
|
uint8_t sub_nonce[12] = {0};
|
||||||
|
memcpy(sub_nonce + 4, nonce + 16, 8);
|
||||||
|
|
||||||
|
chacha20_ietf_xor(out, in, len, sub_nonce, subkey, 0);
|
||||||
|
|
||||||
|
memset(subkey, 0, sizeof(subkey));
|
||||||
|
}
|
||||||
33
app/src/main/cpp/crypto.h
Normal file
33
app/src/main/cpp/crypto.h
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#ifndef ROSETTA_CRYPTO_H
|
||||||
|
#define ROSETTA_CRYPTO_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HSalsa20 core — used by nacl.box.before() to derive shared key.
|
||||||
|
* out: 32 bytes, inp: 16 bytes (nonce, zeros for box.before), key: 32 bytes
|
||||||
|
*/
|
||||||
|
void rosetta_hsalsa20(uint8_t out[32],
|
||||||
|
const uint8_t inp[16],
|
||||||
|
const uint8_t key[32]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XChaCha20 XOR (encrypt = decrypt, symmetric stream cipher).
|
||||||
|
* out and in may overlap. nonce: 24 bytes, key: 32 bytes.
|
||||||
|
*/
|
||||||
|
void rosetta_xchacha20_xor(uint8_t *out,
|
||||||
|
const uint8_t *in,
|
||||||
|
size_t len,
|
||||||
|
const uint8_t nonce[24],
|
||||||
|
const uint8_t key[32]);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* ROSETTA_CRYPTO_H */
|
||||||
390
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
390
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* JNI bridge for Rosetta E2EE.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* 1. HSalsa20 — for nacl.box.before() compatible key derivation
|
||||||
|
* 2. XChaCha20 FrameEncryptor / FrameDecryptor — inherits DIRECTLY from
|
||||||
|
* webrtc::FrameEncryptorInterface / FrameDecryptorInterface so the
|
||||||
|
* vtable is generated by the compiler, not guessed by us.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <atomic>
|
||||||
|
#include <vector>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
/* WebRTC M125 interface stubs (exact copies of the real headers) */
|
||||||
|
#include "webrtc/api/crypto/frame_encryptor_interface.h"
|
||||||
|
#include "webrtc/api/crypto/frame_decryptor_interface.h"
|
||||||
|
|
||||||
|
#include "crypto.h"
|
||||||
|
|
||||||
|
#define TAG "RosettaE2EE"
|
||||||
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||||
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
/* ── Diagnostics file — written from native, visible in crash reports ── */
|
||||||
|
|
||||||
|
static char g_diag_path[512] = {0};
|
||||||
|
static int g_diag_fd = -1;
|
||||||
|
|
||||||
|
static void diag_write(const char *fmt, ...) {
|
||||||
|
if (g_diag_fd < 0) return;
|
||||||
|
char buf[512];
|
||||||
|
va_list ap;
|
||||||
|
va_start(ap, fmt);
|
||||||
|
int n = vsnprintf(buf, sizeof(buf), fmt, ap);
|
||||||
|
va_end(ap);
|
||||||
|
if (n > 0) write(g_diag_fd, buf, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Native crash handler — writes to file before dying ──────── */
|
||||||
|
|
||||||
|
static char g_crash_path[512] = {0};
|
||||||
|
static struct sigaction g_old_sigsegv = {};
|
||||||
|
static struct sigaction g_old_sigabrt = {};
|
||||||
|
|
||||||
|
static void native_crash_handler(int sig, siginfo_t *info, void *ctx) {
|
||||||
|
if (g_crash_path[0] != 0) {
|
||||||
|
int fd = open(g_crash_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||||
|
if (fd >= 0) {
|
||||||
|
const char *msg;
|
||||||
|
if (sig == SIGSEGV)
|
||||||
|
msg = "NATIVE CRASH: SIGSEGV (segmentation fault) in rosetta_e2ee\n"
|
||||||
|
"Fault address: see logcat for details.\n";
|
||||||
|
else if (sig == SIGABRT)
|
||||||
|
msg = "NATIVE CRASH: SIGABRT (abort) in rosetta_e2ee\n";
|
||||||
|
else
|
||||||
|
msg = "NATIVE CRASH: unknown signal in rosetta_e2ee\n";
|
||||||
|
write(fd, msg, strlen(msg));
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOGE("NATIVE CRASH sig=%d addr=%p", sig, info ? info->si_addr : nullptr);
|
||||||
|
|
||||||
|
struct sigaction *old = (sig == SIGSEGV) ? &g_old_sigsegv : &g_old_sigabrt;
|
||||||
|
sigaction(sig, old, nullptr);
|
||||||
|
raise(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
* XChaCha20 Encryptor — inherits from the REAL interface
|
||||||
|
* ════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
class XChaCha20Encryptor final : public webrtc::FrameEncryptorInterface {
|
||||||
|
public:
|
||||||
|
explicit XChaCha20Encryptor(const uint8_t key[32]) {
|
||||||
|
memcpy(key_, key, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RefCountInterface ─────────────────────────────────────── */
|
||||||
|
void AddRef() const override {
|
||||||
|
ref_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
rtc::RefCountReleaseStatus Release() const override {
|
||||||
|
if (ref_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
|
||||||
|
delete this;
|
||||||
|
return rtc::RefCountReleaseStatus::kDroppedLastRef;
|
||||||
|
}
|
||||||
|
return rtc::RefCountReleaseStatus::kOtherRefsRemained;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame format: [4-byte counter BE] + [xchacha20_xor(frame)]
|
||||||
|
*
|
||||||
|
* Nonce (24 bytes): [0,0,0,0, counter_BE_4bytes, 0,...,0]
|
||||||
|
* This matches Desktop's layout where nonce[4..7] = timestamp.
|
||||||
|
* The counter is embedded so the receiver can reconstruct the nonce
|
||||||
|
* even if frames are dropped/reordered.
|
||||||
|
*/
|
||||||
|
int Encrypt(cricket::MediaType /*media_type*/,
|
||||||
|
uint32_t /*ssrc*/,
|
||||||
|
rtc::ArrayView<const uint8_t> /*additional_data*/,
|
||||||
|
rtc::ArrayView<const uint8_t> frame,
|
||||||
|
rtc::ArrayView<uint8_t> encrypted_frame,
|
||||||
|
size_t* bytes_written) override {
|
||||||
|
const size_t HEADER = 4; // counter prefix
|
||||||
|
if (frame.size() == 0 || encrypted_frame.size() < frame.size() + HEADER) {
|
||||||
|
*bytes_written = 0;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t ctr = counter_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
// Write 4-byte counter as big-endian prefix
|
||||||
|
encrypted_frame.data()[0] = (uint8_t)(ctr >> 24);
|
||||||
|
encrypted_frame.data()[1] = (uint8_t)(ctr >> 16);
|
||||||
|
encrypted_frame.data()[2] = (uint8_t)(ctr >> 8);
|
||||||
|
encrypted_frame.data()[3] = (uint8_t)(ctr);
|
||||||
|
|
||||||
|
// Build nonce from counter (same positions as Desktop's timestamp)
|
||||||
|
uint8_t nonce[24] = {0};
|
||||||
|
nonce[4] = encrypted_frame.data()[0];
|
||||||
|
nonce[5] = encrypted_frame.data()[1];
|
||||||
|
nonce[6] = encrypted_frame.data()[2];
|
||||||
|
nonce[7] = encrypted_frame.data()[3];
|
||||||
|
|
||||||
|
rosetta_xchacha20_xor(encrypted_frame.data() + HEADER,
|
||||||
|
frame.data(), frame.size(), nonce, key_);
|
||||||
|
*bytes_written = frame.size() + HEADER;
|
||||||
|
|
||||||
|
// Diag: log first 3 frames
|
||||||
|
int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
if (n < 3) {
|
||||||
|
LOGI("ENC frame#%d: sz=%zu ctr=%u out=%zu",
|
||||||
|
n, frame.size(), ctr, frame.size() + HEADER);
|
||||||
|
diag_write("ENC frame#%d: sz=%zu ctr=%u nonce[4..7]=%02x%02x%02x%02x\n",
|
||||||
|
n, frame.size(), ctr, nonce[4], nonce[5], nonce[6], nonce[7]);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t GetMaxCiphertextByteSize(cricket::MediaType, size_t frame_size) override {
|
||||||
|
return frame_size + 4; // +4 for counter prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
~XChaCha20Encryptor() override { memset(key_, 0, 32); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
mutable std::atomic<int> ref_{0};
|
||||||
|
mutable std::atomic<uint32_t> counter_{0};
|
||||||
|
mutable std::atomic<int> diag_count_{0};
|
||||||
|
uint8_t key_[32];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
* XChaCha20 Decryptor — inherits from the REAL interface
|
||||||
|
* ════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
class XChaCha20Decryptor final : public webrtc::FrameDecryptorInterface {
|
||||||
|
public:
|
||||||
|
explicit XChaCha20Decryptor(const uint8_t key[32]) {
|
||||||
|
memcpy(key_, key, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RefCountInterface ─────────────────────────────────────── */
|
||||||
|
void AddRef() const override {
|
||||||
|
ref_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
rtc::RefCountReleaseStatus Release() const override {
|
||||||
|
if (ref_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
|
||||||
|
delete this;
|
||||||
|
return rtc::RefCountReleaseStatus::kDroppedLastRef;
|
||||||
|
}
|
||||||
|
return rtc::RefCountReleaseStatus::kOtherRefsRemained;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt frame: read 4-byte counter prefix → derive nonce → decrypt.
|
||||||
|
* If frame has no prefix (< 5 bytes or from Desktop), fallback to
|
||||||
|
* nonce derived from additional_data (RTP header) or zeros.
|
||||||
|
*/
|
||||||
|
Result Decrypt(cricket::MediaType /*media_type*/,
|
||||||
|
const std::vector<uint32_t>& /*csrcs*/,
|
||||||
|
rtc::ArrayView<const uint8_t> additional_data,
|
||||||
|
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||||
|
rtc::ArrayView<uint8_t> frame) override {
|
||||||
|
|
||||||
|
const size_t HEADER = 4;
|
||||||
|
uint8_t nonce[24] = {0};
|
||||||
|
const uint8_t *payload;
|
||||||
|
size_t payload_sz;
|
||||||
|
|
||||||
|
if (encrypted_frame.size() > HEADER) {
|
||||||
|
// Android format: [4-byte counter] + [encrypted data]
|
||||||
|
nonce[4] = encrypted_frame.data()[0];
|
||||||
|
nonce[5] = encrypted_frame.data()[1];
|
||||||
|
nonce[6] = encrypted_frame.data()[2];
|
||||||
|
nonce[7] = encrypted_frame.data()[3];
|
||||||
|
payload = encrypted_frame.data() + HEADER;
|
||||||
|
payload_sz = encrypted_frame.size() - HEADER;
|
||||||
|
} else {
|
||||||
|
// Fallback: no counter prefix
|
||||||
|
payload = encrypted_frame.data();
|
||||||
|
payload_sz = encrypted_frame.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload_sz == 0 || frame.size() < payload_sz) {
|
||||||
|
return {Result::Status::kFailedToDecrypt, 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
rosetta_xchacha20_xor(frame.data(), payload, payload_sz, nonce, key_);
|
||||||
|
|
||||||
|
// Diag: log first 3 frames
|
||||||
|
int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
if (n < 3) {
|
||||||
|
LOGI("DEC frame#%d: enc_sz=%zu payload=%zu nonce=%02x%02x%02x%02x",
|
||||||
|
n, encrypted_frame.size(), payload_sz,
|
||||||
|
nonce[4], nonce[5], nonce[6], nonce[7]);
|
||||||
|
diag_write("DEC frame#%d: enc_sz=%zu payload=%zu nonce[4..7]=%02x%02x%02x%02x\n",
|
||||||
|
n, encrypted_frame.size(), payload_sz,
|
||||||
|
nonce[4], nonce[5], nonce[6], nonce[7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {Result::Status::kOk, payload_sz};
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t GetMaxPlaintextByteSize(cricket::MediaType, size_t encrypted_frame_size) override {
|
||||||
|
return encrypted_frame_size; // >= actual (payload = enc - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
~XChaCha20Decryptor() override { memset(key_, 0, 32); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
mutable std::atomic<int> ref_{0};
|
||||||
|
mutable std::atomic<int> diag_count_{0};
|
||||||
|
uint8_t key_[32];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
* JNI exports
|
||||||
|
* ════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
/* ── HSalsa20 for nacl.box.before() ──────────────────────────── */
|
||||||
|
|
||||||
|
JNIEXPORT jbyteArray JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeHSalsa20(
|
||||||
|
JNIEnv *env, jclass, jbyteArray jRawDh)
|
||||||
|
{
|
||||||
|
jsize len = env->GetArrayLength(jRawDh);
|
||||||
|
if (len < 32) return nullptr;
|
||||||
|
|
||||||
|
auto *raw = (uint8_t *)env->GetByteArrayElements(jRawDh, nullptr);
|
||||||
|
uint8_t out[32];
|
||||||
|
uint8_t zeros[16] = {0};
|
||||||
|
|
||||||
|
rosetta_hsalsa20(out, zeros, raw);
|
||||||
|
|
||||||
|
env->ReleaseByteArrayElements(jRawDh, (jbyte *)raw, JNI_ABORT);
|
||||||
|
|
||||||
|
jbyteArray result = env->NewByteArray(32);
|
||||||
|
env->SetByteArrayRegion(result, 0, 32, (jbyte *)out);
|
||||||
|
memset(out, 0, 32);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Create / destroy encryptor ──────────────────────────────── */
|
||||||
|
|
||||||
|
JNIEXPORT jlong JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateEncryptor(
|
||||||
|
JNIEnv *env, jclass, jbyteArray jKey)
|
||||||
|
{
|
||||||
|
jsize len = env->GetArrayLength(jKey);
|
||||||
|
if (len < 32) return 0;
|
||||||
|
|
||||||
|
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
|
||||||
|
auto *enc = new XChaCha20Encryptor(key);
|
||||||
|
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
|
||||||
|
|
||||||
|
// AddRef so the pointer we hand out has ref=1.
|
||||||
|
// WebRTC's scoped_refptr will AddRef again when it takes ownership.
|
||||||
|
enc->AddRef();
|
||||||
|
|
||||||
|
LOGI("Created XChaCha20 encryptor %p", enc);
|
||||||
|
return reinterpret_cast<jlong>(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseEncryptor(
|
||||||
|
JNIEnv *, jclass, jlong ptr)
|
||||||
|
{
|
||||||
|
if (ptr == 0) return;
|
||||||
|
auto *enc = reinterpret_cast<XChaCha20Encryptor *>(ptr);
|
||||||
|
enc->Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Create / destroy decryptor ──────────────────────────────── */
|
||||||
|
|
||||||
|
JNIEXPORT jlong JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateDecryptor(
|
||||||
|
JNIEnv *env, jclass, jbyteArray jKey)
|
||||||
|
{
|
||||||
|
jsize len = env->GetArrayLength(jKey);
|
||||||
|
if (len < 32) return 0;
|
||||||
|
|
||||||
|
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
|
||||||
|
auto *dec = new XChaCha20Decryptor(key);
|
||||||
|
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
|
||||||
|
|
||||||
|
dec->AddRef();
|
||||||
|
|
||||||
|
LOGI("Created XChaCha20 decryptor %p", dec);
|
||||||
|
return reinterpret_cast<jlong>(dec);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseDecryptor(
|
||||||
|
JNIEnv *, jclass, jlong ptr)
|
||||||
|
{
|
||||||
|
if (ptr == 0) return;
|
||||||
|
auto *dec = reinterpret_cast<XChaCha20Decryptor *>(ptr);
|
||||||
|
dec->Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Install native crash handler ─────────────────────────────── */
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeInstallCrashHandler(
|
||||||
|
JNIEnv *env, jclass, jstring jPath)
|
||||||
|
{
|
||||||
|
const char *path = env->GetStringUTFChars(jPath, nullptr);
|
||||||
|
strncpy(g_crash_path, path, sizeof(g_crash_path) - 1);
|
||||||
|
env->ReleaseStringUTFChars(jPath, path);
|
||||||
|
|
||||||
|
struct sigaction sa = {};
|
||||||
|
sa.sa_sigaction = native_crash_handler;
|
||||||
|
sa.sa_flags = SA_SIGINFO;
|
||||||
|
sigemptyset(&sa.sa_mask);
|
||||||
|
|
||||||
|
sigaction(SIGSEGV, &sa, &g_old_sigsegv);
|
||||||
|
sigaction(SIGABRT, &sa, &g_old_sigabrt);
|
||||||
|
|
||||||
|
LOGI("Native crash handler installed, path=%s", g_crash_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Open diagnostics file for E2EE frame logging ────────────── */
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeOpenDiagFile(
|
||||||
|
JNIEnv *env, jclass, jstring jPath)
|
||||||
|
{
|
||||||
|
if (g_diag_fd >= 0) { close(g_diag_fd); g_diag_fd = -1; }
|
||||||
|
|
||||||
|
const char *path = env->GetStringUTFChars(jPath, nullptr);
|
||||||
|
strncpy(g_diag_path, path, sizeof(g_diag_path) - 1);
|
||||||
|
g_diag_fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||||
|
env->ReleaseStringUTFChars(jPath, path);
|
||||||
|
|
||||||
|
if (g_diag_fd >= 0) {
|
||||||
|
diag_write("=== E2EE DIAGNOSTICS ===\n");
|
||||||
|
LOGI("Diag file opened: %s", g_diag_path);
|
||||||
|
} else {
|
||||||
|
LOGE("Failed to open diag file: %s", g_diag_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCloseDiagFile(
|
||||||
|
JNIEnv *, jclass)
|
||||||
|
{
|
||||||
|
if (g_diag_fd >= 0) {
|
||||||
|
diag_write("=== END ===\n");
|
||||||
|
close(g_diag_fd);
|
||||||
|
g_diag_fd = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} /* extern "C" */
|
||||||
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Minimal stub matching WebRTC M125 api/array_view.h
|
||||||
|
#ifndef API_ARRAY_VIEW_H_
|
||||||
|
#define API_ARRAY_VIEW_H_
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace rtc {
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
class ArrayView final {
|
||||||
|
public:
|
||||||
|
constexpr ArrayView() noexcept : ptr_(nullptr), size_(0) {}
|
||||||
|
constexpr ArrayView(T* ptr, size_t size) noexcept : ptr_(ptr), size_(size) {}
|
||||||
|
constexpr T* data() const { return ptr_; }
|
||||||
|
constexpr size_t size() const { return size_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
T* ptr_;
|
||||||
|
size_t size_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace rtc
|
||||||
|
|
||||||
|
#endif // API_ARRAY_VIEW_H_
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Minimal stub matching WebRTC M125 api/crypto/frame_decryptor_interface.h
|
||||||
|
#ifndef API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||||
|
#define API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "webrtc/rtc_base/ref_count.h"
|
||||||
|
#include "webrtc/api/array_view.h"
|
||||||
|
#include "webrtc/api/media_types.h"
|
||||||
|
|
||||||
|
namespace webrtc {
|
||||||
|
|
||||||
|
class FrameDecryptorInterface : public rtc::RefCountInterface {
|
||||||
|
public:
|
||||||
|
struct Result {
|
||||||
|
enum class Status { kOk = 0, kRecoverable, kFailedToDecrypt };
|
||||||
|
|
||||||
|
Result(Status s, size_t bw) : status(s), bytes_written(bw) {}
|
||||||
|
bool IsOk() const { return status == Status::kOk; }
|
||||||
|
|
||||||
|
Status status;
|
||||||
|
size_t bytes_written;
|
||||||
|
};
|
||||||
|
|
||||||
|
virtual Result Decrypt(cricket::MediaType media_type,
|
||||||
|
const std::vector<uint32_t>& csrcs,
|
||||||
|
rtc::ArrayView<const uint8_t> additional_data,
|
||||||
|
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||||
|
rtc::ArrayView<uint8_t> frame) = 0;
|
||||||
|
|
||||||
|
virtual size_t GetMaxPlaintextByteSize(cricket::MediaType media_type,
|
||||||
|
size_t encrypted_frame_size) = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
~FrameDecryptorInterface() override {}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace webrtc
|
||||||
|
|
||||||
|
#endif // API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Minimal stub matching WebRTC M125 api/crypto/frame_encryptor_interface.h
|
||||||
|
#ifndef API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||||
|
#define API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "webrtc/rtc_base/ref_count.h"
|
||||||
|
#include "webrtc/api/array_view.h"
|
||||||
|
#include "webrtc/api/media_types.h"
|
||||||
|
|
||||||
|
namespace webrtc {
|
||||||
|
|
||||||
|
class FrameEncryptorInterface : public rtc::RefCountInterface {
|
||||||
|
public:
|
||||||
|
virtual int Encrypt(cricket::MediaType media_type,
|
||||||
|
uint32_t ssrc,
|
||||||
|
rtc::ArrayView<const uint8_t> additional_data,
|
||||||
|
rtc::ArrayView<const uint8_t> frame,
|
||||||
|
rtc::ArrayView<uint8_t> encrypted_frame,
|
||||||
|
size_t* bytes_written) = 0;
|
||||||
|
|
||||||
|
virtual size_t GetMaxCiphertextByteSize(cricket::MediaType media_type,
|
||||||
|
size_t frame_size) = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
~FrameEncryptorInterface() override {}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace webrtc
|
||||||
|
|
||||||
|
#endif // API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||||
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Minimal stub matching WebRTC M125 api/media_types.h
|
||||||
|
#ifndef API_MEDIA_TYPES_H_
|
||||||
|
#define API_MEDIA_TYPES_H_
|
||||||
|
|
||||||
|
namespace cricket {
|
||||||
|
enum MediaType {
|
||||||
|
MEDIA_TYPE_AUDIO,
|
||||||
|
MEDIA_TYPE_VIDEO,
|
||||||
|
MEDIA_TYPE_DATA,
|
||||||
|
MEDIA_TYPE_UNSUPPORTED
|
||||||
|
};
|
||||||
|
} // namespace cricket
|
||||||
|
|
||||||
|
#endif // API_MEDIA_TYPES_H_
|
||||||
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Minimal stub matching WebRTC M125 rtc_base/ref_count.h
|
||||||
|
#ifndef RTC_BASE_REF_COUNT_H_
|
||||||
|
#define RTC_BASE_REF_COUNT_H_
|
||||||
|
|
||||||
|
namespace rtc {
|
||||||
|
|
||||||
|
enum class RefCountReleaseStatus { kDroppedLastRef, kOtherRefsRemained };
|
||||||
|
|
||||||
|
// Must match the EXACT virtual layout of the real rtc::RefCountInterface.
|
||||||
|
class RefCountInterface {
|
||||||
|
public:
|
||||||
|
virtual void AddRef() const = 0;
|
||||||
|
virtual RefCountReleaseStatus Release() const = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual ~RefCountInterface() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace rtc
|
||||||
|
|
||||||
|
#endif // RTC_BASE_REF_COUNT_H_
|
||||||
@@ -19,10 +19,6 @@ import org.bouncycastle.math.ec.rfc7748.X25519
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.webrtc.AudioSource
|
import org.webrtc.AudioSource
|
||||||
import org.webrtc.AudioTrack
|
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.IceCandidate
|
||||||
import org.webrtc.MediaConstraints
|
import org.webrtc.MediaConstraints
|
||||||
import org.webrtc.PeerConnection
|
import org.webrtc.PeerConnection
|
||||||
@@ -120,10 +116,10 @@ object CallManager {
|
|||||||
private var localAudioTrack: AudioTrack? = null
|
private var localAudioTrack: AudioTrack? = null
|
||||||
private val bufferedRemoteCandidates = mutableListOf<IceCandidate>()
|
private val bufferedRemoteCandidates = mutableListOf<IceCandidate>()
|
||||||
|
|
||||||
// E2EE (FrameCryptor AES-GCM)
|
// E2EE (XChaCha20 — compatible with Desktop)
|
||||||
private var keyProvider: FrameCryptorKeyProvider? = null
|
private var sharedKeyBytes: ByteArray? = null
|
||||||
private var senderCryptor: FrameCryptor? = null
|
private var senderEncryptor: XChaCha20E2EE.Encryptor? = null
|
||||||
private var receiverCryptor: FrameCryptor? = null
|
private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null
|
||||||
|
|
||||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||||
|
|
||||||
@@ -132,6 +128,7 @@ object CallManager {
|
|||||||
initialized = true
|
initialized = true
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
CallSoundManager.initialize(context)
|
CallSoundManager.initialize(context)
|
||||||
|
XChaCha20E2EE.initWithContext(context)
|
||||||
|
|
||||||
signalWaiter = ProtocolManager.waitCallSignal { packet ->
|
signalWaiter = ProtocolManager.waitCallSignal { packet ->
|
||||||
scope.launch { handleSignalPacket(packet) }
|
scope.launch { handleSignalPacket(packet) }
|
||||||
@@ -255,16 +252,21 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleSignalPacket(packet: PacketSignalPeer) {
|
private suspend fun handleSignalPacket(packet: PacketSignalPeer) {
|
||||||
|
breadcrumb("SIG: ${packet.signalType} from=${packet.src.take(8)}… phase=${_state.value.phase}")
|
||||||
|
|
||||||
when (packet.signalType) {
|
when (packet.signalType) {
|
||||||
SignalType.END_CALL_BECAUSE_BUSY -> {
|
SignalType.END_CALL_BECAUSE_BUSY -> {
|
||||||
|
breadcrumb("SIG: peer busy → reset")
|
||||||
resetSession(reason = "User is busy", notifyPeer = false)
|
resetSession(reason = "User is busy", notifyPeer = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> {
|
SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> {
|
||||||
|
breadcrumb("SIG: peer disconnected → reset")
|
||||||
resetSession(reason = "Peer disconnected", notifyPeer = false)
|
resetSession(reason = "Peer disconnected", notifyPeer = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
SignalType.END_CALL -> {
|
SignalType.END_CALL -> {
|
||||||
|
breadcrumb("SIG: END_CALL → reset")
|
||||||
resetSession(reason = "Call ended", notifyPeer = false)
|
resetSession(reason = "Call ended", notifyPeer = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -274,16 +276,18 @@ object CallManager {
|
|||||||
val currentPeer = _state.value.peerPublicKey
|
val currentPeer = _state.value.peerPublicKey
|
||||||
val src = packet.src.trim()
|
val src = packet.src.trim()
|
||||||
if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) {
|
if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) {
|
||||||
|
breadcrumb("SIG: IGNORED (src mismatch: expected=${currentPeer.take(8)}… got=${src.take(8)}…)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
when (packet.signalType) {
|
when (packet.signalType) {
|
||||||
SignalType.CALL -> {
|
SignalType.CALL -> {
|
||||||
if (_state.value.phase != CallPhase.IDLE) {
|
if (_state.value.phase != CallPhase.IDLE) {
|
||||||
|
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
|
||||||
val callerKey = packet.src.trim()
|
val callerKey = packet.src.trim()
|
||||||
if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) {
|
if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) {
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolManager.sendCallSignal(
|
||||||
signalType = SignalType.END_CALL,
|
signalType = SignalType.END_CALL_BECAUSE_BUSY,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = callerKey
|
dst = callerKey
|
||||||
)
|
)
|
||||||
@@ -292,6 +296,7 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
val incomingPeer = packet.src.trim()
|
val incomingPeer = packet.src.trim()
|
||||||
if (incomingPeer.isBlank()) return
|
if (incomingPeer.isBlank()) return
|
||||||
|
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
|
||||||
role = CallRole.CALLEE
|
role = CallRole.CALLEE
|
||||||
resetRtcObjects()
|
resetRtcObjects()
|
||||||
setPeer(incomingPeer, "", "")
|
setPeer(incomingPeer, "", "")
|
||||||
@@ -305,11 +310,16 @@ object CallManager {
|
|||||||
resolvePeerIdentity(incomingPeer)
|
resolvePeerIdentity(incomingPeer)
|
||||||
}
|
}
|
||||||
SignalType.KEY_EXCHANGE -> {
|
SignalType.KEY_EXCHANGE -> {
|
||||||
|
breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
|
||||||
handleKeyExchange(packet)
|
handleKeyExchange(packet)
|
||||||
}
|
}
|
||||||
SignalType.CREATE_ROOM -> {
|
SignalType.CREATE_ROOM -> {
|
||||||
val incomingRoomId = packet.roomId.trim()
|
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
|
roomId = incomingRoomId
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -320,23 +330,35 @@ object CallManager {
|
|||||||
ensurePeerConnectionAndOffer()
|
ensurePeerConnectionAndOffer()
|
||||||
}
|
}
|
||||||
SignalType.ACTIVE_CALL -> Unit
|
SignalType.ACTIVE_CALL -> Unit
|
||||||
else -> Unit
|
else -> breadcrumb("SIG: unhandled ${packet.signalType}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleKeyExchange(packet: PacketSignalPeer) {
|
private suspend fun handleKeyExchange(packet: PacketSignalPeer) {
|
||||||
val peerKey = packet.src.trim().ifBlank { _state.value.peerPublicKey }
|
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)
|
setPeer(peerKey, _state.value.peerTitle, _state.value.peerUsername)
|
||||||
|
|
||||||
val peerPublicHex = packet.sharedPublic.trim()
|
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) {
|
if (role == CallRole.CALLER) {
|
||||||
generateSessionKeys()
|
generateSessionKeys()
|
||||||
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
|
val sharedKey = computeSharedSecretHex(peerPublicHex)
|
||||||
|
if (sharedKey == null) {
|
||||||
|
breadcrumb("KE: CALLER — computeSharedSecret FAILED")
|
||||||
|
return
|
||||||
|
}
|
||||||
setupE2EE(sharedKey)
|
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
|
val localPublic = localPublicKey ?: return
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolManager.sendCallSignal(
|
||||||
signalType = SignalType.KEY_EXCHANGE,
|
signalType = SignalType.KEY_EXCHANGE,
|
||||||
@@ -355,39 +377,58 @@ object CallManager {
|
|||||||
|
|
||||||
if (role == CallRole.CALLEE) {
|
if (role == CallRole.CALLEE) {
|
||||||
if (localPrivateKey == null || localPublicKey == null) {
|
if (localPrivateKey == null || localPublicKey == null) {
|
||||||
|
breadcrumb("KE: CALLEE — regenerating session keys (were null)")
|
||||||
generateSessionKeys()
|
generateSessionKeys()
|
||||||
}
|
}
|
||||||
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
|
val sharedKey = computeSharedSecretHex(peerPublicHex)
|
||||||
|
if (sharedKey == null) {
|
||||||
|
breadcrumb("KE: CALLEE — computeSharedSecret FAILED")
|
||||||
|
return
|
||||||
|
}
|
||||||
setupE2EE(sharedKey)
|
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) {
|
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
|
||||||
val phase = _state.value.phase
|
val phase = _state.value.phase
|
||||||
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) return
|
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
|
||||||
val pc = peerConnection ?: return
|
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) {
|
when (packet.signalType) {
|
||||||
WebRTCSignalType.ANSWER -> {
|
WebRTCSignalType.ANSWER -> {
|
||||||
|
breadcrumb("RTC: ANSWER received")
|
||||||
val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return
|
val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return
|
||||||
try {
|
try {
|
||||||
pc.setRemoteDescriptionAwait(answer)
|
pc.setRemoteDescriptionAwait(answer)
|
||||||
remoteDescriptionSet = true
|
remoteDescriptionSet = true
|
||||||
flushBufferedRemoteCandidates()
|
flushBufferedRemoteCandidates()
|
||||||
|
breadcrumb("RTC: ANSWER applied OK, remoteDesc=true")
|
||||||
} catch (e: Exception) {
|
} 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 -> {
|
WebRTCSignalType.ICE_CANDIDATE -> {
|
||||||
val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return
|
val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return
|
||||||
if (!remoteDescriptionSet) {
|
if (!remoteDescriptionSet) {
|
||||||
|
breadcrumb("RTC: ICE buffered (remoteDesc not set yet)")
|
||||||
bufferedRemoteCandidates.add(candidate)
|
bufferedRemoteCandidates.add(candidate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
breadcrumb("RTC: ICE added: ${candidate.sdp.take(40)}…")
|
||||||
runCatching { pc.addIceCandidate(candidate) }
|
runCatching { pc.addIceCandidate(candidate) }
|
||||||
}
|
}
|
||||||
WebRTCSignalType.OFFER -> {
|
WebRTCSignalType.OFFER -> {
|
||||||
|
breadcrumb("RTC: OFFER received (offerSent=$offerSent)")
|
||||||
val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return
|
val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return
|
||||||
try {
|
try {
|
||||||
pc.setRemoteDescriptionAwait(remoteOffer)
|
pc.setRemoteDescriptionAwait(remoteOffer)
|
||||||
@@ -399,8 +440,10 @@ object CallManager {
|
|||||||
signalType = WebRTCSignalType.ANSWER,
|
signalType = WebRTCSignalType.ANSWER,
|
||||||
sdpOrCandidate = serializeSessionDescription(answer)
|
sdpOrCandidate = serializeSessionDescription(answer)
|
||||||
)
|
)
|
||||||
|
breadcrumb("RTC: OFFER handled → ANSWER sent")
|
||||||
} catch (e: Exception) {
|
} 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() {
|
private suspend fun ensurePeerConnectionAndOffer() {
|
||||||
val peerKey = _state.value.peerPublicKey
|
val peerKey = _state.value.peerPublicKey
|
||||||
if (peerKey.isBlank() || roomId.isBlank()) return
|
if (peerKey.isBlank() || roomId.isBlank()) {
|
||||||
if (offerSent) return
|
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()
|
ensurePeerFactory()
|
||||||
val factory = peerConnectionFactory ?: return
|
val factory = peerConnectionFactory
|
||||||
val pc = peerConnection ?: createPeerConnection(factory) ?: return
|
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) {
|
if (audioSource == null) {
|
||||||
audioSource = factory.createAudioSource(MediaConstraints())
|
audioSource = factory.createAudioSource(MediaConstraints())
|
||||||
@@ -436,6 +494,7 @@ object CallManager {
|
|||||||
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
|
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
|
||||||
localAudioTrack?.setEnabled(!_state.value.isMuted)
|
localAudioTrack?.setEnabled(!_state.value.isMuted)
|
||||||
pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID))
|
pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID))
|
||||||
|
breadcrumb("PC: audio track added, attaching E2EE…")
|
||||||
attachSenderE2EE(pc)
|
attachSenderE2EE(pc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,16 +506,20 @@ object CallManager {
|
|||||||
sdpOrCandidate = serializeSessionDescription(offer)
|
sdpOrCandidate = serializeSessionDescription(offer)
|
||||||
)
|
)
|
||||||
offerSent = true
|
offerSent = true
|
||||||
|
breadcrumb("PC: OFFER sent OK")
|
||||||
} catch (e: Exception) {
|
} 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? {
|
private fun createPeerConnection(factory: PeerConnectionFactory): PeerConnection? {
|
||||||
|
breadcrumb("PC: createPeerConnection iceServers=${iceServers.size}")
|
||||||
val rtcIceServers =
|
val rtcIceServers =
|
||||||
if (iceServers.isNotEmpty()) {
|
if (iceServers.isNotEmpty()) {
|
||||||
iceServers
|
iceServers
|
||||||
} else {
|
} else {
|
||||||
|
breadcrumb("PC: no TURN servers — using Google STUN fallback")
|
||||||
listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer())
|
listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,12 +529,19 @@ object CallManager {
|
|||||||
|
|
||||||
val observer =
|
val observer =
|
||||||
object : PeerConnection.Observer {
|
object : PeerConnection.Observer {
|
||||||
override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit
|
override fun onSignalingChange(newState: PeerConnection.SignalingState?) {
|
||||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit
|
breadcrumb("PC: signalingState=$newState")
|
||||||
|
}
|
||||||
|
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||||
|
breadcrumb("PC: iceConnState=$newState")
|
||||||
|
}
|
||||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
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?) {
|
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||||
if (candidate == null) return
|
if (candidate == null) return
|
||||||
|
breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}…")
|
||||||
ProtocolManager.sendWebRtcSignal(
|
ProtocolManager.sendWebRtcSignal(
|
||||||
signalType = WebRTCSignalType.ICE_CANDIDATE,
|
signalType = WebRTCSignalType.ICE_CANDIDATE,
|
||||||
sdpOrCandidate = serializeIceCandidate(candidate)
|
sdpOrCandidate = serializeIceCandidate(candidate)
|
||||||
@@ -484,9 +554,11 @@ object CallManager {
|
|||||||
override fun onRenegotiationNeeded() = Unit
|
override fun onRenegotiationNeeded() = Unit
|
||||||
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
|
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
|
||||||
override fun onTrack(transceiver: RtpTransceiver?) {
|
override fun onTrack(transceiver: RtpTransceiver?) {
|
||||||
|
breadcrumb("PC: onTrack → attachReceiverE2EE")
|
||||||
attachReceiverE2EE(transceiver)
|
attachReceiverE2EE(transceiver)
|
||||||
}
|
}
|
||||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||||
|
breadcrumb("PC: connState=$newState")
|
||||||
when (newState) {
|
when (newState) {
|
||||||
PeerConnection.PeerConnectionState.CONNECTED -> {
|
PeerConnection.PeerConnectionState.CONNECTED -> {
|
||||||
onCallConnected()
|
onCallConnected()
|
||||||
@@ -494,8 +566,11 @@ object CallManager {
|
|||||||
PeerConnection.PeerConnectionState.DISCONNECTED,
|
PeerConnection.PeerConnectionState.DISCONNECTED,
|
||||||
PeerConnection.PeerConnectionState.FAILED,
|
PeerConnection.PeerConnectionState.FAILED,
|
||||||
PeerConnection.PeerConnectionState.CLOSED -> {
|
PeerConnection.PeerConnectionState.CLOSED -> {
|
||||||
|
// Dispatch to our scope — this callback fires on WebRTC thread
|
||||||
|
scope.launch {
|
||||||
resetSession(reason = "Connection lost", notifyPeer = false)
|
resetSession(reason = "Connection lost", notifyPeer = false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,6 +626,7 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||||
|
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
val wasActive = snapshot.phase != CallPhase.IDLE
|
val wasActive = snapshot.phase != CallPhase.IDLE
|
||||||
val peerToNotify = snapshot.peerPublicKey
|
val peerToNotify = snapshot.peerPublicKey
|
||||||
@@ -571,6 +647,7 @@ object CallManager {
|
|||||||
Log.d(TAG, reason)
|
Log.d(TAG, reason)
|
||||||
}
|
}
|
||||||
resetRtcObjects()
|
resetRtcObjects()
|
||||||
|
e2eeAvailable = true
|
||||||
role = null
|
role = null
|
||||||
roomId = ""
|
roomId = ""
|
||||||
offerSent = false
|
offerSent = false
|
||||||
@@ -585,6 +662,9 @@ object CallManager {
|
|||||||
|
|
||||||
private fun resetRtcObjects() {
|
private fun resetRtcObjects() {
|
||||||
bufferedRemoteCandidates.clear()
|
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?.setEnabled(false) }
|
||||||
runCatching { localAudioTrack?.dispose() }
|
runCatching { localAudioTrack?.dispose() }
|
||||||
runCatching { audioSource?.dispose() }
|
runCatching { audioSource?.dispose() }
|
||||||
@@ -592,7 +672,6 @@ object CallManager {
|
|||||||
localAudioTrack = null
|
localAudioTrack = null
|
||||||
audioSource = null
|
audioSource = null
|
||||||
peerConnection = null
|
peerConnection = null
|
||||||
teardownE2EE()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun flushBufferedRemoteCandidates() {
|
private fun flushBufferedRemoteCandidates() {
|
||||||
@@ -604,7 +683,10 @@ object CallManager {
|
|||||||
bufferedRemoteCandidates.clear()
|
bufferedRemoteCandidates.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── E2EE (FrameCryptor AES-GCM) ─────────────────────────────────
|
// ── E2EE (XChaCha20 — compatible with Desktop) ──────────────────
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var e2eeAvailable = true
|
||||||
|
|
||||||
private fun setupE2EE(sharedKeyHex: String) {
|
private fun setupE2EE(sharedKeyHex: String) {
|
||||||
val keyBytes = sharedKeyHex.hexToBytes()
|
val keyBytes = sharedKeyHex.hexToBytes()
|
||||||
@@ -612,53 +694,107 @@ object CallManager {
|
|||||||
Log.e(TAG, "E2EE: invalid key (${keyBytes?.size ?: 0} bytes)")
|
Log.e(TAG, "E2EE: invalid key (${keyBytes?.size ?: 0} bytes)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val kp = FrameCryptorFactory.createFrameCryptorKeyProvider(
|
sharedKeyBytes = keyBytes.copyOf(32)
|
||||||
/* sharedKey */ true,
|
// Open native diagnostics file for frame-level logging
|
||||||
/* ratchetSalt */ ByteArray(0),
|
try {
|
||||||
/* ratchetWindowSize */ 0,
|
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||||
/* uncryptedMagicBytes */ ByteArray(0),
|
if (!dir.exists()) dir.mkdirs()
|
||||||
/* failureTolerance */ 0,
|
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
||||||
/* keyRingSize */ 1,
|
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||||
/* discardFrameWhenCryptorNotReady */ false
|
} catch (_: Throwable) {}
|
||||||
)
|
Log.i(TAG, "E2EE key ready (XChaCha20)")
|
||||||
kp.setSharedKey(0, keyBytes.copyOf(32))
|
}
|
||||||
keyProvider = kp
|
|
||||||
Log.i(TAG, "E2EE key provider created (AES-GCM)")
|
/** 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) {
|
private fun attachSenderE2EE(pc: PeerConnection) {
|
||||||
val factory = peerConnectionFactory ?: return
|
if (!e2eeAvailable) return
|
||||||
val kp = keyProvider ?: return
|
val key = sharedKeyBytes ?: return
|
||||||
val sender = pc.senders.firstOrNull() ?: return
|
val sender = pc.senders.firstOrNull() ?: return
|
||||||
|
|
||||||
senderCryptor = FrameCryptorFactory.createFrameCryptorForRtpSender(
|
try {
|
||||||
factory, sender, "caller", FrameCryptorAlgorithm.AES_GCM, kp
|
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
||||||
)
|
val enc = XChaCha20E2EE.Encryptor(key)
|
||||||
senderCryptor?.setEnabled(true)
|
breadcrumb("2. encryptor created")
|
||||||
Log.i(TAG, "E2EE sender cryptor attached")
|
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?) {
|
private fun attachReceiverE2EE(transceiver: RtpTransceiver?) {
|
||||||
val factory = peerConnectionFactory ?: return
|
if (!e2eeAvailable) return
|
||||||
val kp = keyProvider ?: return
|
val key = sharedKeyBytes ?: return
|
||||||
val receiver = transceiver?.receiver ?: return
|
val receiver = transceiver?.receiver ?: return
|
||||||
|
|
||||||
receiverCryptor = FrameCryptorFactory.createFrameCryptorForRtpReceiver(
|
try {
|
||||||
factory, receiver, "callee", FrameCryptorAlgorithm.AES_GCM, kp
|
breadcrumb("6. decryptor: creating…")
|
||||||
)
|
val dec = XChaCha20E2EE.Decryptor(key)
|
||||||
receiverCryptor?.setEnabled(true)
|
breadcrumb("7. decryptor created")
|
||||||
Log.i(TAG, "E2EE receiver cryptor attached")
|
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() {
|
private fun teardownE2EE() {
|
||||||
runCatching { senderCryptor?.setEnabled(false) }
|
// Release our ref. WebRTC holds its own ref via scoped_refptr.
|
||||||
runCatching { senderCryptor?.dispose() }
|
// After our Release: WebRTC ref remains. On peerConnection.close()
|
||||||
runCatching { receiverCryptor?.setEnabled(false) }
|
// WebRTC releases its ref → ref=0 → native object deleted.
|
||||||
runCatching { receiverCryptor?.dispose() }
|
runCatching { senderEncryptor?.dispose() }
|
||||||
runCatching { keyProvider?.dispose() }
|
runCatching { receiverDecryptor?.dispose() }
|
||||||
senderCryptor = null
|
senderEncryptor = null
|
||||||
receiverCryptor = null
|
receiverDecryptor = null
|
||||||
keyProvider = null
|
sharedKeyBytes?.let { it.fill(0) }
|
||||||
|
sharedKeyBytes = null
|
||||||
|
runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateSessionKeys() {
|
private fun generateSessionKeys() {
|
||||||
@@ -674,10 +810,27 @@ object CallManager {
|
|||||||
val privateKey = localPrivateKey ?: return null
|
val privateKey = localPrivateKey ?: return null
|
||||||
val peerPublic = peerPublicHex.hexToBytes() ?: return null
|
val peerPublic = peerPublicHex.hexToBytes() ?: return null
|
||||||
if (peerPublic.size != 32) return null
|
if (peerPublic.size != 32) return null
|
||||||
val shared = ByteArray(32)
|
val rawDh = ByteArray(32)
|
||||||
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, shared, 0)
|
breadcrumb("KE: X25519 agreement…")
|
||||||
if (!ok) return null
|
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
|
||||||
return shared.toHex()
|
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 {
|
private fun serializeSessionDescription(description: SessionDescription): String {
|
||||||
|
|||||||
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal 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()
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ import androidx.compose.material.icons.filled.MicOff
|
|||||||
import androidx.compose.material.icons.filled.Videocam
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
import androidx.compose.material.icons.filled.VideocamOff
|
import androidx.compose.material.icons.filled.VideocamOff
|
||||||
import androidx.compose.material.icons.filled.VolumeOff
|
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.material.icons.filled.VolumeUp
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -93,17 +96,28 @@ fun CallOverlay(
|
|||||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
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) {
|
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "\uD83D\uDD12 Encrypted",
|
text = "\uD83D\uDD12 Encrypted",
|
||||||
color = Color.White.copy(alpha = 0.4f),
|
color = Color.White.copy(alpha = 0.4f),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(top = 12.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// QR grid icon — tap to show popover
|
||||||
|
if (state.keyCast.isNotBlank()) {
|
||||||
|
EncryptionKeyButton(keyHex = state.keyCast)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Center content: rings + avatar + name + status ──
|
// ── 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 ──
|
// ── 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)
|
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(
|
* QR icon in top-right corner — tap to show encryption key dropdown.
|
||||||
"\uD83D\uDE00", "\uD83D\uDE0E", "\uD83D\uDE80", "\uD83D\uDD12",
|
* 1:1 match with Desktop's IconQrcode + Popover.
|
||||||
"\uD83C\uDF1F", "\uD83C\uDF08", "\uD83D\uDC8E", "\uD83C\uDF40",
|
*/
|
||||||
"\uD83D\uDD25", "\uD83C\uDF3A", "\uD83E\uDD8B", "\uD83C\uDF0D",
|
@Composable
|
||||||
"\uD83C\uDF89", "\uD83E\uDD84", "\uD83C\uDF52", "\uD83D\uDCA1"
|
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)
|
||||||
)
|
)
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class CrashReportManager private constructor(private val context: Context) : Thr
|
|||||||
val crashDir = File(context.filesDir, CRASH_DIR)
|
val crashDir = File(context.filesDir, CRASH_DIR)
|
||||||
if (!crashDir.exists()) return emptyList()
|
if (!crashDir.exists()) return emptyList()
|
||||||
|
|
||||||
return crashDir.listFiles()
|
val reports = crashDir.listFiles()
|
||||||
?.filter { it.extension == "txt" }
|
?.filter { it.extension == "txt" }
|
||||||
?.sortedByDescending { it.lastModified() }
|
?.sortedByDescending { it.lastModified() }
|
||||||
?.map { file ->
|
?.map { file ->
|
||||||
@@ -54,7 +54,21 @@ class CrashReportManager private constructor(private val context: Context) : Thr
|
|||||||
timestamp = file.lastModified(),
|
timestamp = file.lastModified(),
|
||||||
content = file.readText()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user