Compare commits
7 Commits
31db795c56
...
new-server
| Author | SHA1 | Date | |
|---|---|---|---|
| c9fa12a690 | |||
| ec541a2c0c | |||
| 454402938c | |||
| 83f6b49ba3 | |||
| b663450db5 | |||
| 9cca071bd8 | |||
| 0af4e6587e |
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.3.1"
|
val rosettaVersionName = "1.3.2"
|
||||||
val rosettaVersionCode = 33 // Increment on each release
|
val rosettaVersionCode = 34 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
|
#include <time.h>
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <android/log.h>
|
#include <android/log.h>
|
||||||
@@ -34,6 +35,10 @@
|
|||||||
|
|
||||||
static char g_diag_path[512] = {0};
|
static char g_diag_path[512] = {0};
|
||||||
static int g_diag_fd = -1;
|
static int g_diag_fd = -1;
|
||||||
|
static std::atomic<int> g_diag_event_count{0};
|
||||||
|
static constexpr int kDiagEventLimit = 4000;
|
||||||
|
static constexpr int kDiagFrameLimit = 400;
|
||||||
|
static constexpr size_t kFrameHashSampleBytes = 320;
|
||||||
|
|
||||||
static void diag_write(const char *fmt, ...) {
|
static void diag_write(const char *fmt, ...) {
|
||||||
if (g_diag_fd < 0) return;
|
if (g_diag_fd < 0) return;
|
||||||
@@ -45,6 +50,50 @@ static void diag_write(const char *fmt, ...) {
|
|||||||
if (n > 0) write(g_diag_fd, buf, n);
|
if (n > 0) write(g_diag_fd, buf, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void diag_event(const char *fmt, ...) {
|
||||||
|
if (g_diag_fd < 0) return;
|
||||||
|
const int idx = g_diag_event_count.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
if (idx >= kDiagEventLimit) return;
|
||||||
|
char buf[640];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline uint32_t fnv1a32(const uint8_t* data, size_t len, size_t sample_limit = 0) {
|
||||||
|
if (!data || len == 0) return 0;
|
||||||
|
const size_t n = (sample_limit > 0 && len > sample_limit) ? sample_limit : len;
|
||||||
|
uint32_t h = 2166136261u;
|
||||||
|
for (size_t i = 0; i < n; ++i) {
|
||||||
|
h ^= data[i];
|
||||||
|
h *= 16777619u;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline uint32_t key_fingerprint32(const uint8_t key[32]) {
|
||||||
|
return fnv1a32(key, 32, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline uint32_t nonce_ts32(const uint8_t nonce[24]) {
|
||||||
|
return ((uint32_t)nonce[4] << 24) |
|
||||||
|
((uint32_t)nonce[5] << 16) |
|
||||||
|
((uint32_t)nonce[6] << 8) |
|
||||||
|
((uint32_t)nonce[7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char* media_type_name(cricket::MediaType media_type) {
|
||||||
|
switch (media_type) {
|
||||||
|
case cricket::MEDIA_TYPE_AUDIO: return "audio";
|
||||||
|
case cricket::MEDIA_TYPE_VIDEO: return "video";
|
||||||
|
case cricket::MEDIA_TYPE_DATA: return "data";
|
||||||
|
case cricket::MEDIA_TYPE_UNSUPPORTED: return "unsupported";
|
||||||
|
default: return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── RTP helpers (for cases when additional_data is empty) ───── */
|
/* ── RTP helpers (for cases when additional_data is empty) ───── */
|
||||||
|
|
||||||
struct ParsedRtpPacket {
|
struct ParsedRtpPacket {
|
||||||
@@ -72,6 +121,17 @@ struct GeneratedTsState {
|
|||||||
uint32_t next_step = 960; // 20 ms @ 48 kHz (default Opus packetization)
|
uint32_t next_step = 960; // 20 ms @ 48 kHz (default Opus packetization)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct AdditionalTsState {
|
||||||
|
bool initialized = false;
|
||||||
|
uint64_t base_timestamp = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SenderTsOffsetState {
|
||||||
|
bool initialized = false;
|
||||||
|
bool enabled = false;
|
||||||
|
uint64_t offset = 0;
|
||||||
|
};
|
||||||
|
|
||||||
static inline uint16_t load16_be(const uint8_t* p) {
|
static inline uint16_t load16_be(const uint8_t* p) {
|
||||||
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
|
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
|
||||||
}
|
}
|
||||||
@@ -83,6 +143,17 @@ static inline uint32_t load32_be(const uint8_t* p) {
|
|||||||
((uint32_t)p[3]);
|
((uint32_t)p[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static inline uint64_t load64_be(const uint8_t* p) {
|
||||||
|
return ((uint64_t)p[0] << 56) |
|
||||||
|
((uint64_t)p[1] << 48) |
|
||||||
|
((uint64_t)p[2] << 40) |
|
||||||
|
((uint64_t)p[3] << 32) |
|
||||||
|
((uint64_t)p[4] << 24) |
|
||||||
|
((uint64_t)p[5] << 16) |
|
||||||
|
((uint64_t)p[6] << 8) |
|
||||||
|
((uint64_t)p[7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static bool parse_rtp_packet(const uint8_t* data, size_t len, ParsedRtpPacket* out) {
|
static bool parse_rtp_packet(const uint8_t* data, size_t len, ParsedRtpPacket* out) {
|
||||||
if (!data || !out || len < 12) return false;
|
if (!data || !out || len < 12) return false;
|
||||||
@@ -133,6 +204,8 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
|
|||||||
state->probe_ssrc = packet.ssrc;
|
state->probe_ssrc = packet.ssrc;
|
||||||
state->probe_sequence = packet.sequence;
|
state->probe_sequence = packet.sequence;
|
||||||
state->probe_timestamp = packet.timestamp;
|
state->probe_timestamp = packet.timestamp;
|
||||||
|
diag_event("RTP probe-start ssrc=%u seq=%u ts=%u hdr=%zu\n",
|
||||||
|
packet.ssrc, packet.sequence, packet.timestamp, packet.header_size);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +224,12 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
|
|||||||
state->ssrc = packet.ssrc;
|
state->ssrc = packet.ssrc;
|
||||||
state->last_sequence = packet.sequence;
|
state->last_sequence = packet.sequence;
|
||||||
state->last_timestamp = packet.timestamp;
|
state->last_timestamp = packet.timestamp;
|
||||||
|
diag_event("RTP probe-lock ssrc=%u seq=%u ts=%u hdr=%zu\n",
|
||||||
|
packet.ssrc, packet.sequence, packet.timestamp, packet.header_size);
|
||||||
} else {
|
} else {
|
||||||
if (packet.ssrc != state->ssrc) {
|
if (packet.ssrc != state->ssrc) {
|
||||||
|
diag_event("RTP probe-unlock reason=ssrc-change old=%u new=%u seq=%u ts=%u\n",
|
||||||
|
state->ssrc, packet.ssrc, packet.sequence, packet.timestamp);
|
||||||
state->locked = false;
|
state->locked = false;
|
||||||
state->has_probe = true;
|
state->has_probe = true;
|
||||||
state->probe_ssrc = packet.ssrc;
|
state->probe_ssrc = packet.ssrc;
|
||||||
@@ -168,6 +245,8 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
|
|||||||
state->last_timestamp = packet.timestamp;
|
state->last_timestamp = packet.timestamp;
|
||||||
} else if (seq_delta != 0) {
|
} else if (seq_delta != 0) {
|
||||||
// Not plausible for a continuous stream: re-probe.
|
// Not plausible for a continuous stream: re-probe.
|
||||||
|
diag_event("RTP probe-unlock reason=seq-jump delta=%u ssrc=%u last_seq=%u seq=%u ts=%u\n",
|
||||||
|
seq_delta, packet.ssrc, state->last_sequence, packet.sequence, packet.timestamp);
|
||||||
state->locked = false;
|
state->locked = false;
|
||||||
state->has_probe = true;
|
state->has_probe = true;
|
||||||
state->probe_ssrc = packet.ssrc;
|
state->probe_ssrc = packet.ssrc;
|
||||||
@@ -188,14 +267,39 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
|
|||||||
static bool fill_nonce_from_additional_data(const uint8_t* data,
|
static bool fill_nonce_from_additional_data(const uint8_t* data,
|
||||||
size_t len,
|
size_t len,
|
||||||
uint8_t nonce[24],
|
uint8_t nonce[24],
|
||||||
bool* used_rtp_header) {
|
bool* used_rtp_header,
|
||||||
|
AdditionalTsState* ts_state,
|
||||||
|
bool* used_relative_ts) {
|
||||||
if (used_rtp_header) *used_rtp_header = false;
|
if (used_rtp_header) *used_rtp_header = false;
|
||||||
|
if (used_relative_ts) *used_relative_ts = false;
|
||||||
if (!data || len < 8) return false;
|
if (!data || len < 8) return false;
|
||||||
|
|
||||||
// Desktop-compatible path: additional_data contains encoded frame timestamp
|
// Desktop-compatible path: additional_data contains encoded frame timestamp
|
||||||
// as 8-byte BE value. Use it directly as nonce[0..7].
|
// as 8-byte BE value. On Android sender/receiver can have different absolute
|
||||||
|
// base in some pipelines; ts_state enables optional relative fallback.
|
||||||
|
// Primary desktop-compatible mode uses absolute timestamp (ts_state == nullptr).
|
||||||
if (len == 8) {
|
if (len == 8) {
|
||||||
memcpy(nonce, data, 8);
|
uint64_t ts64 = load64_be(data);
|
||||||
|
uint64_t ts_rel = ts64;
|
||||||
|
if (ts_state != nullptr) {
|
||||||
|
if (!ts_state->initialized) {
|
||||||
|
ts_state->initialized = true;
|
||||||
|
ts_state->base_timestamp = ts64;
|
||||||
|
} else if (ts64 < ts_state->base_timestamp) {
|
||||||
|
// New stream or reset; avoid huge wrapped nonce drift.
|
||||||
|
ts_state->base_timestamp = ts64;
|
||||||
|
}
|
||||||
|
ts_rel = ts64 - ts_state->base_timestamp;
|
||||||
|
if (used_relative_ts) *used_relative_ts = true;
|
||||||
|
}
|
||||||
|
nonce[0] = (uint8_t)(ts_rel >> 56);
|
||||||
|
nonce[1] = (uint8_t)(ts_rel >> 48);
|
||||||
|
nonce[2] = (uint8_t)(ts_rel >> 40);
|
||||||
|
nonce[3] = (uint8_t)(ts_rel >> 32);
|
||||||
|
nonce[4] = (uint8_t)(ts_rel >> 24);
|
||||||
|
nonce[5] = (uint8_t)(ts_rel >> 16);
|
||||||
|
nonce[6] = (uint8_t)(ts_rel >> 8);
|
||||||
|
nonce[7] = (uint8_t)(ts_rel);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +322,18 @@ static bool fill_nonce_from_additional_data(const uint8_t* data,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool is_plausible_opus_packet(const uint8_t* packet, size_t len);
|
||||||
|
|
||||||
|
static bool is_plausible_decrypted_audio_frame(const uint8_t* data, size_t len) {
|
||||||
|
if (!data || len == 0) return false;
|
||||||
|
|
||||||
|
ParsedRtpPacket packet;
|
||||||
|
if (parse_rtp_packet(data, len, &packet) && packet.header_size < len) {
|
||||||
|
return is_plausible_opus_packet(data + packet.header_size, len - packet.header_size);
|
||||||
|
}
|
||||||
|
return is_plausible_opus_packet(data, len);
|
||||||
|
}
|
||||||
|
|
||||||
static inline void fill_nonce_from_ts32(uint32_t ts, uint8_t nonce[24]) {
|
static inline void fill_nonce_from_ts32(uint32_t ts, uint8_t nonce[24]) {
|
||||||
nonce[4] = (uint8_t)(ts >> 24);
|
nonce[4] = (uint8_t)(ts >> 24);
|
||||||
nonce[5] = (uint8_t)(ts >> 16);
|
nonce[5] = (uint8_t)(ts >> 16);
|
||||||
@@ -225,6 +341,25 @@ static inline void fill_nonce_from_ts32(uint32_t ts, uint8_t nonce[24]) {
|
|||||||
nonce[7] = (uint8_t)(ts);
|
nonce[7] = (uint8_t)(ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static inline void fill_nonce_from_ts64(uint64_t ts, uint8_t nonce[24]) {
|
||||||
|
nonce[0] = (uint8_t)(ts >> 56);
|
||||||
|
nonce[1] = (uint8_t)(ts >> 48);
|
||||||
|
nonce[2] = (uint8_t)(ts >> 40);
|
||||||
|
nonce[3] = (uint8_t)(ts >> 32);
|
||||||
|
nonce[4] = (uint8_t)(ts >> 24);
|
||||||
|
nonce[5] = (uint8_t)(ts >> 16);
|
||||||
|
nonce[6] = (uint8_t)(ts >> 8);
|
||||||
|
nonce[7] = (uint8_t)(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline uint64_t monotonic_48k_ticks() {
|
||||||
|
struct timespec ts {};
|
||||||
|
if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0;
|
||||||
|
const uint64_t sec = (uint64_t)ts.tv_sec;
|
||||||
|
const uint64_t nsec = (uint64_t)ts.tv_nsec;
|
||||||
|
return sec * 48000ULL + (nsec * 48000ULL) / 1000000000ULL;
|
||||||
|
}
|
||||||
|
|
||||||
static inline uint32_t opus_base_frame_samples(uint8_t config) {
|
static inline uint32_t opus_base_frame_samples(uint8_t config) {
|
||||||
// RFC 6716 TOC config mapping at 48 kHz.
|
// RFC 6716 TOC config mapping at 48 kHz.
|
||||||
if (config <= 11) {
|
if (config <= 11) {
|
||||||
@@ -263,18 +398,94 @@ static uint32_t infer_opus_packet_duration_samples(const uint8_t* packet, size_t
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool decode_opus_frame_len(const uint8_t* packet,
|
||||||
|
size_t len,
|
||||||
|
size_t* offset,
|
||||||
|
size_t* frame_len) {
|
||||||
|
if (!packet || !offset || !frame_len) return false;
|
||||||
|
if (*offset >= len) return false;
|
||||||
|
|
||||||
|
const uint8_t b0 = packet[*offset];
|
||||||
|
(*offset)++;
|
||||||
|
if (b0 < 252) {
|
||||||
|
*frame_len = b0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (*offset >= len) return false;
|
||||||
|
const uint8_t b1 = packet[*offset];
|
||||||
|
(*offset)++;
|
||||||
|
*frame_len = (size_t)b1 * 4u + (size_t)b0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool is_plausible_opus_packet(const uint8_t* packet, size_t len) {
|
static bool is_plausible_opus_packet(const uint8_t* packet, size_t len) {
|
||||||
if (!packet || len == 0 || len > 2000) return false;
|
if (!packet || len == 0 || len > 2000) return false;
|
||||||
|
|
||||||
const uint8_t toc = packet[0];
|
const uint8_t toc = packet[0];
|
||||||
const uint8_t config = (uint8_t)(toc >> 3);
|
const uint8_t config = (uint8_t)(toc >> 3);
|
||||||
if (config > 31) return false;
|
if (config > 31) return false;
|
||||||
|
|
||||||
const uint8_t frame_code = (uint8_t)(toc & 0x03);
|
const uint8_t frame_code = (uint8_t)(toc & 0x03);
|
||||||
if (frame_code != 3) return true;
|
const size_t payload_len = len - 1;
|
||||||
|
|
||||||
|
if (frame_code == 0) {
|
||||||
|
// 1 frame, full payload
|
||||||
|
return payload_len >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame_code == 1) {
|
||||||
|
// 2 CBR frames, equal sizes.
|
||||||
|
if (payload_len < 2) return false;
|
||||||
|
return (payload_len % 2) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame_code == 2) {
|
||||||
|
// 2 VBR frames
|
||||||
|
size_t off = 1;
|
||||||
|
size_t len1 = 0;
|
||||||
|
if (!decode_opus_frame_len(packet, len, &off, &len1)) return false;
|
||||||
|
if (len1 == 0) return false;
|
||||||
|
if (off + len1 >= len) return false; // need non-empty second frame
|
||||||
|
const size_t len2 = len - off - len1;
|
||||||
|
if (len2 == 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frame_code == 3: arbitrary number of frames
|
||||||
if (len < 2) return false;
|
if (len < 2) return false;
|
||||||
const uint8_t frame_count = (uint8_t)(packet[1] & 0x3F);
|
const uint8_t ch = packet[1];
|
||||||
|
const bool vbr = (ch & 0x80) != 0;
|
||||||
|
const bool has_padding = (ch & 0x40) != 0;
|
||||||
|
const uint8_t frame_count = (uint8_t)(ch & 0x3F);
|
||||||
if (frame_count == 0 || frame_count > 48) return false;
|
if (frame_count == 0 || frame_count > 48) return false;
|
||||||
|
|
||||||
const uint32_t total = opus_base_frame_samples(config) * (uint32_t)frame_count;
|
const uint32_t total = opus_base_frame_samples(config) * (uint32_t)frame_count;
|
||||||
return total <= 5760;
|
if (total > 5760) return false;
|
||||||
|
|
||||||
|
// Padding bit is rarely used in live voice. Rejecting it improves
|
||||||
|
// discrimination between valid Opus and random decrypted noise.
|
||||||
|
if (has_padding) return false;
|
||||||
|
|
||||||
|
if (!vbr) {
|
||||||
|
const size_t data_len = len - 2;
|
||||||
|
if (data_len < (size_t)frame_count) return false;
|
||||||
|
return (data_len % frame_count) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VBR: parse lengths for first N-1 frames; last frame consumes rest.
|
||||||
|
size_t off = 2;
|
||||||
|
size_t consumed = 0;
|
||||||
|
for (size_t i = 0; i + 1 < frame_count; ++i) {
|
||||||
|
size_t flen = 0;
|
||||||
|
if (!decode_opus_frame_len(packet, len, &off, &flen)) return false;
|
||||||
|
if (flen == 0) return false;
|
||||||
|
consumed += flen;
|
||||||
|
if (off + consumed >= len) return false;
|
||||||
|
}
|
||||||
|
const size_t remaining = len - off;
|
||||||
|
if (remaining <= consumed) return false;
|
||||||
|
const size_t last = remaining - consumed;
|
||||||
|
return last > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Native crash handler — writes to file before dying ──────── */
|
/* ── Native crash handler — writes to file before dying ──────── */
|
||||||
@@ -314,8 +525,13 @@ class XChaCha20Encryptor final : public webrtc::FrameEncryptorInterface {
|
|||||||
public:
|
public:
|
||||||
explicit XChaCha20Encryptor(const uint8_t key[32]) {
|
explicit XChaCha20Encryptor(const uint8_t key[32]) {
|
||||||
memcpy(key_, key, 32);
|
memcpy(key_, key, 32);
|
||||||
|
key_fingerprint_ = key_fingerprint32(key_);
|
||||||
|
LOGI("ENC init ptr=%p key_fp=%08x", this, key_fingerprint_);
|
||||||
|
diag_event("ENC init ptr=%p key_fp=%08x\n", this, key_fingerprint_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t KeyFingerprint() const { return key_fingerprint_; }
|
||||||
|
|
||||||
/* ── RefCountInterface ─────────────────────────────────────── */
|
/* ── RefCountInterface ─────────────────────────────────────── */
|
||||||
void AddRef() const override {
|
void AddRef() const override {
|
||||||
ref_.fetch_add(1, std::memory_order_relaxed);
|
ref_.fetch_add(1, std::memory_order_relaxed);
|
||||||
@@ -344,8 +560,8 @@ public:
|
|||||||
* If RTP header is found inside frame, we leave header bytes unencrypted
|
* If RTP header is found inside frame, we leave header bytes unencrypted
|
||||||
* and encrypt only payload (desktop-compatible).
|
* and encrypt only payload (desktop-compatible).
|
||||||
*/
|
*/
|
||||||
int Encrypt(cricket::MediaType /*media_type*/,
|
int Encrypt(cricket::MediaType media_type,
|
||||||
uint32_t /*ssrc*/,
|
uint32_t ssrc,
|
||||||
rtc::ArrayView<const uint8_t> additional_data,
|
rtc::ArrayView<const uint8_t> additional_data,
|
||||||
rtc::ArrayView<const uint8_t> frame,
|
rtc::ArrayView<const uint8_t> frame,
|
||||||
rtc::ArrayView<uint8_t> encrypted_frame,
|
rtc::ArrayView<uint8_t> encrypted_frame,
|
||||||
@@ -360,15 +576,19 @@ public:
|
|||||||
bool nonce_from_generated_ts = false;
|
bool nonce_from_generated_ts = false;
|
||||||
bool nonce_from_additional_data = false;
|
bool nonce_from_additional_data = false;
|
||||||
bool additional_was_rtp_header = false;
|
bool additional_was_rtp_header = false;
|
||||||
|
bool additional_used_mono_offset = false;
|
||||||
uint32_t generated_ts_used = 0;
|
uint32_t generated_ts_used = 0;
|
||||||
|
|
||||||
// Build nonce from RTP timestamp in additional_data (preferred).
|
// Build nonce from RTP timestamp in additional_data (preferred).
|
||||||
uint8_t nonce[24] = {0};
|
uint8_t nonce[24] = {0};
|
||||||
|
bool additional_used_relative_ts = false;
|
||||||
nonce_from_additional_data = fill_nonce_from_additional_data(
|
nonce_from_additional_data = fill_nonce_from_additional_data(
|
||||||
additional_data.data(),
|
additional_data.data(),
|
||||||
additional_data.size(),
|
additional_data.size(),
|
||||||
nonce,
|
nonce,
|
||||||
&additional_was_rtp_header);
|
&additional_was_rtp_header,
|
||||||
|
nullptr,
|
||||||
|
&additional_used_relative_ts);
|
||||||
if (!nonce_from_additional_data) {
|
if (!nonce_from_additional_data) {
|
||||||
nonce_from_rtp_header =
|
nonce_from_rtp_header =
|
||||||
fill_nonce_from_rtp_frame(frame.data(), frame.size(), &rtp_probe_, nonce, &header_size);
|
fill_nonce_from_rtp_frame(frame.data(), frame.size(), &rtp_probe_, nonce, &header_size);
|
||||||
@@ -381,26 +601,41 @@ public:
|
|||||||
nonce_from_generated_ts = true;
|
nonce_from_generated_ts = true;
|
||||||
generated_ts_used = generated_ts_.next_timestamp;
|
generated_ts_used = generated_ts_.next_timestamp;
|
||||||
fill_nonce_from_ts32(generated_ts_used, nonce);
|
fill_nonce_from_ts32(generated_ts_used, nonce);
|
||||||
|
diag_event("ENC fallback=generated-ts mt=%s ssrc=%u frame_sz=%zu ad_sz=%zu gen_ts=%u\n",
|
||||||
|
media_type_name(media_type), ssrc, frame.size(), additional_data.size(), generated_ts_used);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nonce_from_rtp_header && header_size <= frame.size()) {
|
// Some Android sender pipelines expose stream-relative ad8 timestamps
|
||||||
// Keep RTP header clear, encrypt payload only.
|
// (0, 960, 1920, ...), while desktop receiver expects an absolute base.
|
||||||
if (header_size > 0) {
|
// For interop, add a monotonic 48k offset once when first ad8 is tiny.
|
||||||
memcpy(encrypted_frame.data(), frame.data(), header_size);
|
if (nonce_from_additional_data &&
|
||||||
|
additional_data.size() == 8 &&
|
||||||
|
!additional_was_rtp_header &&
|
||||||
|
additional_data.data() != nullptr) {
|
||||||
|
const uint64_t ad_ts64 = load64_be(additional_data.data());
|
||||||
|
if (!sender_ts_offset_.initialized) {
|
||||||
|
sender_ts_offset_.initialized = true;
|
||||||
|
// Keep pure raw-abs mode by default; desktop is the source of truth.
|
||||||
|
sender_ts_offset_.enabled = false;
|
||||||
|
sender_ts_offset_.offset = 0ULL;
|
||||||
|
diag_event("ENC ad8-base init ssrc=%u ad_ts=%llu use_mono=%d mono_off=%llu\n",
|
||||||
|
ssrc,
|
||||||
|
(unsigned long long)ad_ts64,
|
||||||
|
sender_ts_offset_.enabled ? 1 : 0,
|
||||||
|
(unsigned long long)sender_ts_offset_.offset);
|
||||||
|
}
|
||||||
|
if (sender_ts_offset_.enabled) {
|
||||||
|
const uint64_t ts_adj = ad_ts64 + sender_ts_offset_.offset;
|
||||||
|
fill_nonce_from_ts64(ts_adj, nonce);
|
||||||
|
additional_used_mono_offset = true;
|
||||||
}
|
}
|
||||||
const size_t payload_size = frame.size() - header_size;
|
|
||||||
rosetta_xchacha20_xor(
|
|
||||||
encrypted_frame.data() + header_size,
|
|
||||||
frame.data() + header_size,
|
|
||||||
payload_size,
|
|
||||||
nonce,
|
|
||||||
key_);
|
|
||||||
} else {
|
|
||||||
// Legacy path: frame is payload-only.
|
|
||||||
rosetta_xchacha20_xor(encrypted_frame.data(),
|
|
||||||
frame.data(), frame.size(), nonce, key_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Desktop createEncodedStreams encrypts full encoded chunk.
|
||||||
|
// To stay wire-compatible, do not preserve any leading RTP-like bytes.
|
||||||
|
rosetta_xchacha20_xor(encrypted_frame.data(),
|
||||||
|
frame.data(), frame.size(), nonce, key_);
|
||||||
*bytes_written = frame.size();
|
*bytes_written = frame.size();
|
||||||
|
|
||||||
if (nonce_from_generated_ts) {
|
if (nonce_from_generated_ts) {
|
||||||
@@ -409,23 +644,48 @@ public:
|
|||||||
generated_ts_.next_timestamp = generated_ts_used + step;
|
generated_ts_.next_timestamp = generated_ts_used + step;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diag: log first 3 frames
|
// Diag: log first frames with enough context for crash analysis.
|
||||||
int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
|
int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
|
||||||
if (n < 3) {
|
if (n < kDiagFrameLimit) {
|
||||||
|
uint8_t ad_prefix[8] = {0};
|
||||||
|
const size_t ad_copy = additional_data.size() < sizeof(ad_prefix)
|
||||||
|
? additional_data.size()
|
||||||
|
: sizeof(ad_prefix);
|
||||||
|
if (ad_copy > 0) memcpy(ad_prefix, additional_data.data(), ad_copy);
|
||||||
|
ParsedRtpPacket rtp{};
|
||||||
|
const bool has_rtp = parse_rtp_packet(frame.data(), frame.size(), &rtp);
|
||||||
|
const bool opus_plausible =
|
||||||
|
has_rtp && rtp.header_size < frame.size()
|
||||||
|
? is_plausible_opus_packet(frame.data() + rtp.header_size, frame.size() - rtp.header_size)
|
||||||
|
: is_plausible_opus_packet(frame.data(), frame.size());
|
||||||
|
const uint32_t in_hash = fnv1a32(frame.data(), frame.size(), kFrameHashSampleBytes);
|
||||||
|
const uint32_t out_hash = fnv1a32(encrypted_frame.data(), frame.size(), kFrameHashSampleBytes);
|
||||||
const char* mode =
|
const char* mode =
|
||||||
nonce_from_rtp_header
|
nonce_from_rtp_header
|
||||||
? "rtp"
|
? "rtp"
|
||||||
: (nonce_from_generated_ts
|
: (nonce_from_generated_ts
|
||||||
? "gen"
|
? "gen"
|
||||||
: (nonce_from_additional_data
|
: (nonce_from_additional_data
|
||||||
? (additional_was_rtp_header ? "ad-rtp" : "raw-abs")
|
? (additional_was_rtp_header
|
||||||
|
? "ad-rtp"
|
||||||
|
: (additional_used_mono_offset
|
||||||
|
? "raw-abs+mono"
|
||||||
|
: (additional_used_relative_ts ? "raw-rel" : "raw-abs")))
|
||||||
: "raw-abs"));
|
: "raw-abs"));
|
||||||
LOGI("ENC frame#%d: sz=%zu ad=%zu hdr=%zu mode=%s nonce=%02x%02x%02x%02x",
|
LOGI("ENC frame#%d mt=%s ssrc=%u sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u rtp_ok=%d rtp_seq=%u rtp_ts=%u rtp_ssrc=%u opus_ok=%d key_fp=%08x in_h=%08x out_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x",
|
||||||
n, frame.size(), additional_data.size(), header_size, mode,
|
n, media_type_name(media_type), ssrc, frame.size(), additional_data.size(), header_size, mode,
|
||||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
|
||||||
diag_write("ENC frame#%d: sz=%zu ad=%zu hdr=%zu mode=%s nonce[4..7]=%02x%02x%02x%02x\n",
|
has_rtp ? 1 : 0, has_rtp ? rtp.sequence : 0, has_rtp ? rtp.timestamp : 0, has_rtp ? rtp.ssrc : 0,
|
||||||
n, frame.size(), additional_data.size(), header_size, mode,
|
opus_plausible ? 1 : 0, key_fingerprint_, in_hash, out_hash,
|
||||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
|
||||||
|
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
|
||||||
|
diag_write("ENC frame#%d mt=%s ssrc=%u sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u rtp_ok=%d rtp_seq=%u rtp_ts=%u rtp_ssrc=%u opus_ok=%d key_fp=%08x in_h=%08x out_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x\n",
|
||||||
|
n, media_type_name(media_type), ssrc, frame.size(), additional_data.size(), header_size, mode,
|
||||||
|
nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
|
||||||
|
has_rtp ? 1 : 0, has_rtp ? rtp.sequence : 0, has_rtp ? rtp.timestamp : 0, has_rtp ? rtp.ssrc : 0,
|
||||||
|
opus_plausible ? 1 : 0, key_fingerprint_, in_hash, out_hash,
|
||||||
|
ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
|
||||||
|
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -435,13 +695,18 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
~XChaCha20Encryptor() override { memset(key_, 0, 32); }
|
~XChaCha20Encryptor() override {
|
||||||
|
diag_event("ENC destroy ptr=%p key_fp=%08x\n", this, key_fingerprint_);
|
||||||
|
memset(key_, 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
mutable std::atomic<int> ref_{0};
|
mutable std::atomic<int> ref_{0};
|
||||||
mutable std::atomic<int> diag_count_{0};
|
mutable std::atomic<int> diag_count_{0};
|
||||||
mutable RtpProbeState rtp_probe_;
|
mutable RtpProbeState rtp_probe_;
|
||||||
mutable GeneratedTsState generated_ts_;
|
mutable GeneratedTsState generated_ts_;
|
||||||
|
mutable SenderTsOffsetState sender_ts_offset_;
|
||||||
|
uint32_t key_fingerprint_ = 0;
|
||||||
uint8_t key_[32];
|
uint8_t key_[32];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -453,8 +718,13 @@ class XChaCha20Decryptor final : public webrtc::FrameDecryptorInterface {
|
|||||||
public:
|
public:
|
||||||
explicit XChaCha20Decryptor(const uint8_t key[32]) {
|
explicit XChaCha20Decryptor(const uint8_t key[32]) {
|
||||||
memcpy(key_, key, 32);
|
memcpy(key_, key, 32);
|
||||||
|
key_fingerprint_ = key_fingerprint32(key_);
|
||||||
|
LOGI("DEC init ptr=%p key_fp=%08x", this, key_fingerprint_);
|
||||||
|
diag_event("DEC init ptr=%p key_fp=%08x\n", this, key_fingerprint_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t KeyFingerprint() const { return key_fingerprint_; }
|
||||||
|
|
||||||
/* ── RefCountInterface ─────────────────────────────────────── */
|
/* ── RefCountInterface ─────────────────────────────────────── */
|
||||||
void AddRef() const override {
|
void AddRef() const override {
|
||||||
ref_.fetch_add(1, std::memory_order_relaxed);
|
ref_.fetch_add(1, std::memory_order_relaxed);
|
||||||
@@ -474,8 +744,8 @@ public:
|
|||||||
* - if RTP header is present inside encrypted_frame (fallback path),
|
* - if RTP header is present inside encrypted_frame (fallback path),
|
||||||
* keep header bytes untouched and decrypt payload only.
|
* keep header bytes untouched and decrypt payload only.
|
||||||
*/
|
*/
|
||||||
Result Decrypt(cricket::MediaType /*media_type*/,
|
Result Decrypt(cricket::MediaType media_type,
|
||||||
const std::vector<uint32_t>& /*csrcs*/,
|
const std::vector<uint32_t>& csrcs,
|
||||||
rtc::ArrayView<const uint8_t> additional_data,
|
rtc::ArrayView<const uint8_t> additional_data,
|
||||||
rtc::ArrayView<const uint8_t> encrypted_frame,
|
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||||
rtc::ArrayView<uint8_t> frame) override {
|
rtc::ArrayView<uint8_t> frame) override {
|
||||||
@@ -485,12 +755,17 @@ public:
|
|||||||
bool nonce_from_generated_ts = false;
|
bool nonce_from_generated_ts = false;
|
||||||
bool nonce_from_additional_data = false;
|
bool nonce_from_additional_data = false;
|
||||||
bool additional_was_rtp_header = false;
|
bool additional_was_rtp_header = false;
|
||||||
|
bool additional_used_relative_ts = false;
|
||||||
|
bool used_additional_relative_fallback = false;
|
||||||
|
bool used_plain_passthrough = false;
|
||||||
uint32_t generated_ts_used = 0;
|
uint32_t generated_ts_used = 0;
|
||||||
nonce_from_additional_data = fill_nonce_from_additional_data(
|
nonce_from_additional_data = fill_nonce_from_additional_data(
|
||||||
additional_data.data(),
|
additional_data.data(),
|
||||||
additional_data.size(),
|
additional_data.size(),
|
||||||
nonce,
|
nonce,
|
||||||
&additional_was_rtp_header);
|
&additional_was_rtp_header,
|
||||||
|
nullptr,
|
||||||
|
&additional_used_relative_ts);
|
||||||
if (!nonce_from_additional_data) {
|
if (!nonce_from_additional_data) {
|
||||||
nonce_from_rtp_header =
|
nonce_from_rtp_header =
|
||||||
fill_nonce_from_rtp_frame(encrypted_frame.data(), encrypted_frame.size(), &rtp_probe_, nonce, &header_size);
|
fill_nonce_from_rtp_frame(encrypted_frame.data(), encrypted_frame.size(), &rtp_probe_, nonce, &header_size);
|
||||||
@@ -503,6 +778,8 @@ public:
|
|||||||
nonce_from_generated_ts = true;
|
nonce_from_generated_ts = true;
|
||||||
generated_ts_used = generated_ts_.next_timestamp;
|
generated_ts_used = generated_ts_.next_timestamp;
|
||||||
fill_nonce_from_ts32(generated_ts_used, nonce);
|
fill_nonce_from_ts32(generated_ts_used, nonce);
|
||||||
|
diag_event("DEC fallback=generated-ts mt=%s csrcs=%zu enc_sz=%zu ad_sz=%zu gen_ts=%u\n",
|
||||||
|
media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size(), generated_ts_used);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,19 +789,54 @@ public:
|
|||||||
|
|
||||||
bool used_generated_resync = false;
|
bool used_generated_resync = false;
|
||||||
|
|
||||||
if (nonce_from_rtp_header && header_size <= encrypted_frame.size()) {
|
// Desktop createEncodedStreams decrypts full encoded chunk.
|
||||||
if (header_size > 0) {
|
rosetta_xchacha20_xor(frame.data(), encrypted_frame.data(), encrypted_frame.size(), nonce, key_);
|
||||||
memcpy(frame.data(), encrypted_frame.data(), header_size);
|
|
||||||
|
if (nonce_from_additional_data) {
|
||||||
|
bool plausible = is_plausible_decrypted_audio_frame(frame.data(), encrypted_frame.size());
|
||||||
|
|
||||||
|
// Fallback for Android pipelines where additional_data timestamps are
|
||||||
|
// stream-relative while remote side uses a different absolute base.
|
||||||
|
if (!plausible && additional_data.size() == 8 && !additional_was_rtp_header) {
|
||||||
|
uint8_t nonce_rel[24] = {0};
|
||||||
|
bool rel_used = false;
|
||||||
|
if (fill_nonce_from_additional_data(
|
||||||
|
additional_data.data(),
|
||||||
|
additional_data.size(),
|
||||||
|
nonce_rel,
|
||||||
|
nullptr,
|
||||||
|
&additional_rel_ts_state_,
|
||||||
|
&rel_used) &&
|
||||||
|
rel_used) {
|
||||||
|
std::vector<uint8_t> candidate(encrypted_frame.size());
|
||||||
|
rosetta_xchacha20_xor(
|
||||||
|
candidate.data(),
|
||||||
|
encrypted_frame.data(),
|
||||||
|
encrypted_frame.size(),
|
||||||
|
nonce_rel,
|
||||||
|
key_);
|
||||||
|
if (is_plausible_decrypted_audio_frame(candidate.data(), candidate.size())) {
|
||||||
|
memcpy(frame.data(), candidate.data(), candidate.size());
|
||||||
|
memcpy(nonce, nonce_rel, sizeof(nonce));
|
||||||
|
plausible = true;
|
||||||
|
used_additional_relative_fallback = true;
|
||||||
|
additional_used_relative_ts = true;
|
||||||
|
diag_event("DEC fallback=relative-ad-ts mt=%s csrcs=%zu enc_sz=%zu ad_sz=%zu nonce_ts=%u\n",
|
||||||
|
media_type_name(media_type), csrcs.size(), encrypted_frame.size(),
|
||||||
|
additional_data.size(), nonce_ts32(nonce));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If payload already looks like valid Opus, keep plaintext.
|
||||||
|
// This protects interop when peer stream is unexpectedly unencrypted.
|
||||||
|
if (!plausible &&
|
||||||
|
is_plausible_decrypted_audio_frame(encrypted_frame.data(), encrypted_frame.size())) {
|
||||||
|
memcpy(frame.data(), encrypted_frame.data(), encrypted_frame.size());
|
||||||
|
used_plain_passthrough = true;
|
||||||
|
diag_event("DEC fallback=plain-passthrough mt=%s csrcs=%zu enc_sz=%zu ad_sz=%zu\n",
|
||||||
|
media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size());
|
||||||
}
|
}
|
||||||
const size_t payload_size = encrypted_frame.size() - header_size;
|
|
||||||
rosetta_xchacha20_xor(
|
|
||||||
frame.data() + header_size,
|
|
||||||
encrypted_frame.data() + header_size,
|
|
||||||
payload_size,
|
|
||||||
nonce,
|
|
||||||
key_);
|
|
||||||
} else {
|
|
||||||
rosetta_xchacha20_xor(frame.data(), encrypted_frame.data(), encrypted_frame.size(), nonce, key_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nonce_from_generated_ts) {
|
if (nonce_from_generated_ts) {
|
||||||
@@ -548,6 +860,9 @@ public:
|
|||||||
generated_ts_used = ts_try;
|
generated_ts_used = ts_try;
|
||||||
used_generated_resync = true;
|
used_generated_resync = true;
|
||||||
plausible = true;
|
plausible = true;
|
||||||
|
diag_event("DEC fallback=generated-resync mt=%s csrcs=%zu enc_sz=%zu ts_try=%u step=%u probe=%u\n",
|
||||||
|
media_type_name(media_type), csrcs.size(), encrypted_frame.size(),
|
||||||
|
ts_try, generated_ts_.next_step, i);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,25 +873,68 @@ public:
|
|||||||
generated_ts_.next_timestamp = generated_ts_used + step;
|
generated_ts_.next_timestamp = generated_ts_used + step;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diag: log first 3 frames
|
// Diag: log first frames with enough context for crash analysis.
|
||||||
int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
|
int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
|
||||||
if (n < 3) {
|
if (n < kDiagFrameLimit) {
|
||||||
|
uint8_t ad_prefix[8] = {0};
|
||||||
|
const size_t ad_copy = additional_data.size() < sizeof(ad_prefix)
|
||||||
|
? additional_data.size()
|
||||||
|
: sizeof(ad_prefix);
|
||||||
|
if (ad_copy > 0) memcpy(ad_prefix, additional_data.data(), ad_copy);
|
||||||
|
ParsedRtpPacket enc_rtp{};
|
||||||
|
ParsedRtpPacket dec_rtp{};
|
||||||
|
const bool has_enc_rtp = parse_rtp_packet(encrypted_frame.data(), encrypted_frame.size(), &enc_rtp);
|
||||||
|
const bool has_dec_rtp = parse_rtp_packet(frame.data(), encrypted_frame.size(), &dec_rtp);
|
||||||
|
const bool dec_plausible = is_plausible_decrypted_audio_frame(frame.data(), encrypted_frame.size());
|
||||||
|
const uint32_t enc_hash = fnv1a32(encrypted_frame.data(), encrypted_frame.size(), kFrameHashSampleBytes);
|
||||||
|
const uint32_t dec_hash = fnv1a32(frame.data(), encrypted_frame.size(), kFrameHashSampleBytes);
|
||||||
const char* mode = nullptr;
|
const char* mode = nullptr;
|
||||||
if (nonce_from_rtp_header) {
|
if (nonce_from_rtp_header) {
|
||||||
mode = "rtp";
|
mode = "rtp";
|
||||||
} else if (nonce_from_generated_ts) {
|
} else if (nonce_from_generated_ts) {
|
||||||
mode = used_generated_resync ? "gen-resync" : "gen";
|
mode = used_generated_resync ? "gen-resync" : "gen";
|
||||||
} else if (nonce_from_additional_data) {
|
} else if (nonce_from_additional_data) {
|
||||||
mode = additional_was_rtp_header ? "ad-rtp" : "raw-abs";
|
if (used_plain_passthrough) mode = "raw-plain";
|
||||||
|
else if (additional_was_rtp_header) mode = "ad-rtp";
|
||||||
|
else if (used_additional_relative_fallback) mode = "raw-rel-fb";
|
||||||
|
else mode = additional_used_relative_ts ? "raw-rel" : "raw-abs";
|
||||||
} else {
|
} else {
|
||||||
mode = "raw-abs";
|
mode = "raw-abs";
|
||||||
}
|
}
|
||||||
LOGI("DEC frame#%d: enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce=%02x%02x%02x%02x",
|
uint32_t bad_streak = 0;
|
||||||
n, encrypted_frame.size(), additional_data.size(), header_size, mode,
|
if (!dec_plausible) {
|
||||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
bad_streak = bad_audio_streak_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
diag_write("DEC frame#%d: enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce[4..7]=%02x%02x%02x%02x\n",
|
if (bad_streak == 1 || bad_streak == 3 || bad_streak == 10 || (bad_streak % 50) == 0) {
|
||||||
n, encrypted_frame.size(), additional_data.size(), header_size, mode,
|
diag_event("DEC degraded mt=%s csrcs=%zu mode=%s bad_streak=%u nonce_ts=%u key_fp=%08x\n",
|
||||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
media_type_name(media_type), csrcs.size(), mode, bad_streak,
|
||||||
|
nonce_ts32(nonce), key_fingerprint_);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const uint32_t prev_bad = bad_audio_streak_.exchange(0, std::memory_order_relaxed);
|
||||||
|
if (prev_bad >= 3) {
|
||||||
|
diag_event("DEC recovered mt=%s csrcs=%zu mode=%s prev_bad_streak=%u nonce_ts=%u key_fp=%08x\n",
|
||||||
|
media_type_name(media_type), csrcs.size(), mode, prev_bad,
|
||||||
|
nonce_ts32(nonce), key_fingerprint_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOGI("DEC frame#%d mt=%s csrcs=%zu enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u dec_ok=%d bad_streak=%u enc_rtp=%d enc_seq=%u enc_ts=%u enc_ssrc=%u dec_rtp=%d dec_seq=%u dec_ts=%u dec_ssrc=%u key_fp=%08x enc_h=%08x dec_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x",
|
||||||
|
n, media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size(), header_size, mode,
|
||||||
|
nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
|
||||||
|
dec_plausible ? 1 : 0, bad_streak,
|
||||||
|
has_enc_rtp ? 1 : 0, has_enc_rtp ? enc_rtp.sequence : 0, has_enc_rtp ? enc_rtp.timestamp : 0, has_enc_rtp ? enc_rtp.ssrc : 0,
|
||||||
|
has_dec_rtp ? 1 : 0, has_dec_rtp ? dec_rtp.sequence : 0, has_dec_rtp ? dec_rtp.timestamp : 0, has_dec_rtp ? dec_rtp.ssrc : 0,
|
||||||
|
key_fingerprint_, enc_hash, dec_hash,
|
||||||
|
ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
|
||||||
|
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
|
||||||
|
diag_write("DEC frame#%d mt=%s csrcs=%zu enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u dec_ok=%d bad_streak=%u enc_rtp=%d enc_seq=%u enc_ts=%u enc_ssrc=%u dec_rtp=%d dec_seq=%u dec_ts=%u dec_ssrc=%u key_fp=%08x enc_h=%08x dec_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x\n",
|
||||||
|
n, media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size(), header_size, mode,
|
||||||
|
nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
|
||||||
|
dec_plausible ? 1 : 0, bad_streak,
|
||||||
|
has_enc_rtp ? 1 : 0, has_enc_rtp ? enc_rtp.sequence : 0, has_enc_rtp ? enc_rtp.timestamp : 0, has_enc_rtp ? enc_rtp.ssrc : 0,
|
||||||
|
has_dec_rtp ? 1 : 0, has_dec_rtp ? dec_rtp.sequence : 0, has_dec_rtp ? dec_rtp.timestamp : 0, has_dec_rtp ? dec_rtp.ssrc : 0,
|
||||||
|
key_fingerprint_, enc_hash, dec_hash,
|
||||||
|
ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
|
||||||
|
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {Result::Status::kOk, encrypted_frame.size()};
|
return {Result::Status::kOk, encrypted_frame.size()};
|
||||||
@@ -587,13 +945,20 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
~XChaCha20Decryptor() override { memset(key_, 0, 32); }
|
~XChaCha20Decryptor() override {
|
||||||
|
diag_event("DEC destroy ptr=%p key_fp=%08x bad_streak=%u\n",
|
||||||
|
this, key_fingerprint_, bad_audio_streak_.load(std::memory_order_relaxed));
|
||||||
|
memset(key_, 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
mutable std::atomic<int> ref_{0};
|
mutable std::atomic<int> ref_{0};
|
||||||
mutable std::atomic<int> diag_count_{0};
|
mutable std::atomic<int> diag_count_{0};
|
||||||
|
mutable std::atomic<uint32_t> bad_audio_streak_{0};
|
||||||
mutable RtpProbeState rtp_probe_;
|
mutable RtpProbeState rtp_probe_;
|
||||||
mutable GeneratedTsState generated_ts_;
|
mutable GeneratedTsState generated_ts_;
|
||||||
|
mutable AdditionalTsState additional_rel_ts_state_;
|
||||||
|
uint32_t key_fingerprint_ = 0;
|
||||||
uint8_t key_[32];
|
uint8_t key_[32];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -633,9 +998,14 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateEncryptor(
|
|||||||
JNIEnv *env, jclass, jbyteArray jKey)
|
JNIEnv *env, jclass, jbyteArray jKey)
|
||||||
{
|
{
|
||||||
jsize len = env->GetArrayLength(jKey);
|
jsize len = env->GetArrayLength(jKey);
|
||||||
if (len < 32) return 0;
|
if (len < 32) {
|
||||||
|
LOGE("Create encryptor failed: key length=%d (<32)", (int)len);
|
||||||
|
diag_event("ENC create-failed key_len=%d\n", (int)len);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
|
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
|
||||||
|
const uint32_t key_fp = key_fingerprint32(key);
|
||||||
auto *enc = new XChaCha20Encryptor(key);
|
auto *enc = new XChaCha20Encryptor(key);
|
||||||
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
|
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
|
||||||
|
|
||||||
@@ -643,7 +1013,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateEncryptor(
|
|||||||
// WebRTC's scoped_refptr will AddRef again when it takes ownership.
|
// WebRTC's scoped_refptr will AddRef again when it takes ownership.
|
||||||
enc->AddRef();
|
enc->AddRef();
|
||||||
|
|
||||||
LOGI("Created XChaCha20 encryptor %p", enc);
|
LOGI("Created XChaCha20 encryptor %p key_fp=%08x", enc, key_fp);
|
||||||
|
diag_event("ENC created ptr=%p key_fp=%08x key_len=%d\n", enc, key_fp, (int)len);
|
||||||
return reinterpret_cast<jlong>(enc);
|
return reinterpret_cast<jlong>(enc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,6 +1024,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseEncryptor(
|
|||||||
{
|
{
|
||||||
if (ptr == 0) return;
|
if (ptr == 0) return;
|
||||||
auto *enc = reinterpret_cast<XChaCha20Encryptor *>(ptr);
|
auto *enc = reinterpret_cast<XChaCha20Encryptor *>(ptr);
|
||||||
|
LOGI("Release XChaCha20 encryptor %p key_fp=%08x", enc, enc->KeyFingerprint());
|
||||||
|
diag_event("ENC release ptr=%p key_fp=%08x\n", enc, enc->KeyFingerprint());
|
||||||
enc->Release();
|
enc->Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,15 +1036,21 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateDecryptor(
|
|||||||
JNIEnv *env, jclass, jbyteArray jKey)
|
JNIEnv *env, jclass, jbyteArray jKey)
|
||||||
{
|
{
|
||||||
jsize len = env->GetArrayLength(jKey);
|
jsize len = env->GetArrayLength(jKey);
|
||||||
if (len < 32) return 0;
|
if (len < 32) {
|
||||||
|
LOGE("Create decryptor failed: key length=%d (<32)", (int)len);
|
||||||
|
diag_event("DEC create-failed key_len=%d\n", (int)len);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
|
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
|
||||||
|
const uint32_t key_fp = key_fingerprint32(key);
|
||||||
auto *dec = new XChaCha20Decryptor(key);
|
auto *dec = new XChaCha20Decryptor(key);
|
||||||
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
|
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
|
||||||
|
|
||||||
dec->AddRef();
|
dec->AddRef();
|
||||||
|
|
||||||
LOGI("Created XChaCha20 decryptor %p", dec);
|
LOGI("Created XChaCha20 decryptor %p key_fp=%08x", dec, key_fp);
|
||||||
|
diag_event("DEC created ptr=%p key_fp=%08x key_len=%d\n", dec, key_fp, (int)len);
|
||||||
return reinterpret_cast<jlong>(dec);
|
return reinterpret_cast<jlong>(dec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,6 +1060,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseDecryptor(
|
|||||||
{
|
{
|
||||||
if (ptr == 0) return;
|
if (ptr == 0) return;
|
||||||
auto *dec = reinterpret_cast<XChaCha20Decryptor *>(ptr);
|
auto *dec = reinterpret_cast<XChaCha20Decryptor *>(ptr);
|
||||||
|
LOGI("Release XChaCha20 decryptor %p key_fp=%08x", dec, dec->KeyFingerprint());
|
||||||
|
diag_event("DEC release ptr=%p key_fp=%08x\n", dec, dec->KeyFingerprint());
|
||||||
dec->Release();
|
dec->Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,6 +1093,7 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeOpenDiagFile(
|
|||||||
JNIEnv *env, jclass, jstring jPath)
|
JNIEnv *env, jclass, jstring jPath)
|
||||||
{
|
{
|
||||||
if (g_diag_fd >= 0) { close(g_diag_fd); g_diag_fd = -1; }
|
if (g_diag_fd >= 0) { close(g_diag_fd); g_diag_fd = -1; }
|
||||||
|
g_diag_event_count.store(0, std::memory_order_relaxed);
|
||||||
|
|
||||||
const char *path = env->GetStringUTFChars(jPath, nullptr);
|
const char *path = env->GetStringUTFChars(jPath, nullptr);
|
||||||
strncpy(g_diag_path, path, sizeof(g_diag_path) - 1);
|
strncpy(g_diag_path, path, sizeof(g_diag_path) - 1);
|
||||||
@@ -719,8 +1101,9 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeOpenDiagFile(
|
|||||||
env->ReleaseStringUTFChars(jPath, path);
|
env->ReleaseStringUTFChars(jPath, path);
|
||||||
|
|
||||||
if (g_diag_fd >= 0) {
|
if (g_diag_fd >= 0) {
|
||||||
diag_write("=== E2EE DIAGNOSTICS ===\n");
|
diag_write("=== E2EE DIAGNOSTICS pid=%d ===\n", (int)getpid());
|
||||||
LOGI("Diag file opened: %s", g_diag_path);
|
LOGI("Diag file opened: %s", g_diag_path);
|
||||||
|
diag_event("DIAG open path=%s\n", g_diag_path);
|
||||||
} else {
|
} else {
|
||||||
LOGE("Failed to open diag file: %s", g_diag_path);
|
LOGE("Failed to open diag file: %s", g_diag_path);
|
||||||
}
|
}
|
||||||
@@ -731,6 +1114,7 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCloseDiagFile(
|
|||||||
JNIEnv *, jclass)
|
JNIEnv *, jclass)
|
||||||
{
|
{
|
||||||
if (g_diag_fd >= 0) {
|
if (g_diag_fd >= 0) {
|
||||||
|
diag_event("DIAG close path=%s\n", g_diag_path);
|
||||||
diag_write("=== END ===\n");
|
diag_write("=== END ===\n");
|
||||||
close(g_diag_fd);
|
close(g_diag_fd);
|
||||||
g_diag_fd = -1;
|
g_diag_fd = -1;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
||||||
@@ -110,19 +110,3 @@ fun AnimatedKeyboardTransition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Алиас для обратной совместимости
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SimpleAnimatedKeyboardTransition(
|
|
||||||
coordinator: KeyboardTransitionCoordinator,
|
|
||||||
showEmojiPicker: Boolean,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
AnimatedKeyboardTransition(
|
|
||||||
coordinator = coordinator,
|
|
||||||
showEmojiPicker = showEmojiPicker,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
var currentState by mutableStateOf(TransitionState.IDLE)
|
var currentState by mutableStateOf(TransitionState.IDLE)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var transitionProgress by mutableFloatStateOf(0f)
|
|
||||||
private set
|
|
||||||
|
|
||||||
// ============ Высоты ============
|
// ============ Высоты ============
|
||||||
|
|
||||||
var keyboardHeight by mutableStateOf(0.dp)
|
var keyboardHeight by mutableStateOf(0.dp)
|
||||||
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
// Используется для отключения imePadding пока Box виден
|
// Используется для отключения imePadding пока Box виден
|
||||||
var isEmojiBoxVisible by mutableStateOf(false)
|
var isEmojiBoxVisible by mutableStateOf(false)
|
||||||
|
|
||||||
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
|
||||||
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
|
||||||
|
|
||||||
// 📊 Для умного логирования (не каждый фрейм)
|
// 📊 Для умного логирования (не каждый фрейм)
|
||||||
private var lastLogTime = 0L
|
private var lastLogTime = 0L
|
||||||
private var lastLoggedHeight = -1f
|
private var lastLoggedHeight = -1f
|
||||||
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
currentState = TransitionState.IDLE
|
currentState = TransitionState.IDLE
|
||||||
isTransitioning = false
|
isTransitioning = false
|
||||||
|
|
||||||
// Очищаем pending callback - больше не нужен
|
|
||||||
pendingShowEmojiCallback = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Главный метод: Emoji → Keyboard ============
|
// ============ Главный метод: Emoji → Keyboard ============
|
||||||
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
* плавно скрыть emoji.
|
* плавно скрыть emoji.
|
||||||
*/
|
*/
|
||||||
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
||||||
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
|
|
||||||
if (pendingShowEmojiCallback != null) {
|
|
||||||
pendingShowEmojiCallback = null
|
|
||||||
}
|
|
||||||
|
|
||||||
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
||||||
isTransitioning = true
|
isTransitioning = true
|
||||||
|
|
||||||
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновить высоту emoji панели. */
|
|
||||||
fun updateEmojiHeight(height: Dp) {
|
|
||||||
if (height > 0.dp && height != emojiHeight) {
|
|
||||||
emojiHeight = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Синхронизировать высоты (emoji = keyboard).
|
* Синхронизировать высоты (emoji = keyboard).
|
||||||
*
|
*
|
||||||
@@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
|
|
||||||
* максимум из двух.
|
|
||||||
*/
|
|
||||||
fun getReservedHeight(): Dp {
|
|
||||||
return when {
|
|
||||||
isKeyboardVisible -> keyboardHeight
|
|
||||||
isEmojiVisible -> emojiHeight
|
|
||||||
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
|
|
||||||
else -> 0.dp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Проверка, можно ли начать новый переход. */
|
|
||||||
fun canStartTransition(): Boolean {
|
|
||||||
return !isTransitioning
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сброс состояния (для отладки). */
|
|
||||||
fun reset() {
|
|
||||||
currentState = TransitionState.IDLE
|
|
||||||
isTransitioning = false
|
|
||||||
isKeyboardVisible = false
|
|
||||||
isEmojiVisible = false
|
|
||||||
transitionProgress = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Логирование текущего состояния. */
|
|
||||||
fun logState() {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Composable для создания и запоминания coordinator'а. */
|
/** Composable для создания и запоминания coordinator'а. */
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Оптимизация sync и protocol logging
|
Защищенные звонки и диагностика E2EE
|
||||||
- Устранены лаги при CONNECTING/SYNCING: heartbeat-логи ограничены и больше не забивают UI
|
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
|
||||||
- Добавлен fail-safe для handshake state: поврежденное/неизвестное значение больше не трактуется как успешный handshake
|
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
|
||||||
|
- В Crash Reports добавлена кнопка копирования полного лога одним действием
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -87,6 +88,12 @@ object CallManager {
|
|||||||
private const val TAG = "CallManager"
|
private const val TAG = "CallManager"
|
||||||
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
|
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
|
||||||
private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream"
|
private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream"
|
||||||
|
private const val BREADCRUMB_FILE_NAME = "e2ee_breadcrumb.txt"
|
||||||
|
private const val DIAG_FILE_NAME = "e2ee_diag.txt"
|
||||||
|
private const val NATIVE_CRASH_FILE_NAME = "native_crash.txt"
|
||||||
|
private const val TAIL_LINES = 300
|
||||||
|
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||||
|
private const val MAX_LOG_PREFIX = 180
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
@@ -103,6 +110,11 @@ object CallManager {
|
|||||||
private var roomId: String = ""
|
private var roomId: String = ""
|
||||||
private var offerSent = false
|
private var offerSent = false
|
||||||
private var remoteDescriptionSet = false
|
private var remoteDescriptionSet = false
|
||||||
|
private var callSessionId: String = ""
|
||||||
|
private var callStartedAtMs: Long = 0L
|
||||||
|
private var keyExchangeSent = false
|
||||||
|
private var createRoomSent = false
|
||||||
|
private var lastPeerSharedPublicHex: String = ""
|
||||||
|
|
||||||
private var localPrivateKey: ByteArray? = null
|
private var localPrivateKey: ByteArray? = null
|
||||||
private var localPublicKey: ByteArray? = null
|
private var localPublicKey: ByteArray? = null
|
||||||
@@ -124,8 +136,12 @@ object CallManager {
|
|||||||
|
|
||||||
// E2EE (XChaCha20 — compatible with Desktop)
|
// E2EE (XChaCha20 — compatible with Desktop)
|
||||||
private var sharedKeyBytes: ByteArray? = null
|
private var sharedKeyBytes: ByteArray? = null
|
||||||
private var senderEncryptor: XChaCha20E2EE.Encryptor? = null
|
private val senderEncryptors = LinkedHashMap<String, XChaCha20E2EE.Encryptor>()
|
||||||
private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null
|
private val receiverDecryptors = LinkedHashMap<String, XChaCha20E2EE.Decryptor>()
|
||||||
|
private var pendingAudioSenderForE2ee: RtpSender? = null
|
||||||
|
private var lastRemoteOfferFingerprint: String = ""
|
||||||
|
private var lastLocalOfferFingerprint: String = ""
|
||||||
|
private var e2eeRebindJob: Job? = null
|
||||||
|
|
||||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||||
|
|
||||||
@@ -176,7 +192,9 @@ object CallManager {
|
|||||||
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
||||||
|
|
||||||
resetSession(reason = null, notifyPeer = false)
|
resetSession(reason = null, notifyPeer = false)
|
||||||
|
beginCallSession("outgoing:${targetKey.take(8)}")
|
||||||
role = CallRole.CALLER
|
role = CallRole.CALLER
|
||||||
|
generateSessionKeys()
|
||||||
setPeer(targetKey, user.title, user.username)
|
setPeer(targetKey, user.title, user.username)
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -190,6 +208,7 @@ object CallManager {
|
|||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = targetKey
|
dst = targetKey
|
||||||
)
|
)
|
||||||
|
breadcrumbState("startOutgoingCall")
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
||||||
return CallActionResult.STARTED
|
return CallActionResult.STARTED
|
||||||
}
|
}
|
||||||
@@ -210,6 +229,7 @@ object CallManager {
|
|||||||
dst = snapshot.peerPublicKey,
|
dst = snapshot.peerPublicKey,
|
||||||
sharedPublic = localPublic.toHex()
|
sharedPublic = localPublic.toHex()
|
||||||
)
|
)
|
||||||
|
keyExchangeSent = true
|
||||||
|
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -217,6 +237,7 @@ object CallManager {
|
|||||||
statusText = "Exchanging keys..."
|
statusText = "Exchanging keys..."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
breadcrumbState("acceptIncomingCall")
|
||||||
return CallActionResult.STARTED
|
return CallActionResult.STARTED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +329,7 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
val incomingPeer = packet.src.trim()
|
val incomingPeer = packet.src.trim()
|
||||||
if (incomingPeer.isBlank()) return
|
if (incomingPeer.isBlank()) return
|
||||||
|
beginCallSession("incoming:${incomingPeer.take(8)}")
|
||||||
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
|
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
|
||||||
role = CallRole.CALLEE
|
role = CallRole.CALLEE
|
||||||
resetRtcObjects()
|
resetRtcObjects()
|
||||||
@@ -359,30 +381,45 @@ object CallManager {
|
|||||||
breadcrumb("KE: ABORT — sharedPublic blank")
|
breadcrumb("KE: ABORT — sharedPublic blank")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val duplicatePeerKey = lastPeerSharedPublicHex.equals(peerPublicHex, ignoreCase = true)
|
||||||
|
if (duplicatePeerKey && sharedKeyBytes != null) {
|
||||||
|
breadcrumb("KE: duplicate peer key ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}…")
|
breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}…")
|
||||||
|
lastPeerSharedPublicHex = peerPublicHex
|
||||||
|
|
||||||
if (role == CallRole.CALLER) {
|
if (role == CallRole.CALLER) {
|
||||||
generateSessionKeys()
|
if (localPrivateKey == null || localPublicKey == null) {
|
||||||
|
breadcrumb("KE: CALLER — generating session keys (were null)")
|
||||||
|
generateSessionKeys()
|
||||||
|
}
|
||||||
val sharedKey = computeSharedSecretHex(peerPublicHex)
|
val sharedKey = computeSharedSecretHex(peerPublicHex)
|
||||||
if (sharedKey == null) {
|
if (sharedKey == null) {
|
||||||
breadcrumb("KE: CALLER — computeSharedSecret FAILED")
|
breadcrumb("KE: CALLER — computeSharedSecret FAILED")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setupE2EE(sharedKey)
|
setupE2EE(sharedKey)
|
||||||
breadcrumb("KE: CALLER — E2EE ready, sending KEY_EXCHANGE + CREATE_ROOM")
|
breadcrumb("KE: CALLER — E2EE ready, sending missing signaling packets")
|
||||||
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") }
|
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") }
|
||||||
val localPublic = localPublicKey ?: return
|
val localPublic = localPublicKey ?: return
|
||||||
ProtocolManager.sendCallSignal(
|
if (!keyExchangeSent) {
|
||||||
signalType = SignalType.KEY_EXCHANGE,
|
ProtocolManager.sendCallSignal(
|
||||||
src = ownPublicKey,
|
signalType = SignalType.KEY_EXCHANGE,
|
||||||
dst = peerKey,
|
src = ownPublicKey,
|
||||||
sharedPublic = localPublic.toHex()
|
dst = peerKey,
|
||||||
)
|
sharedPublic = localPublic.toHex()
|
||||||
ProtocolManager.sendCallSignal(
|
)
|
||||||
signalType = SignalType.CREATE_ROOM,
|
keyExchangeSent = true
|
||||||
src = ownPublicKey,
|
}
|
||||||
dst = peerKey
|
if (!createRoomSent) {
|
||||||
)
|
ProtocolManager.sendCallSignal(
|
||||||
|
signalType = SignalType.CREATE_ROOM,
|
||||||
|
src = ownPublicKey,
|
||||||
|
dst = peerKey
|
||||||
|
)
|
||||||
|
createRoomSent = true
|
||||||
|
}
|
||||||
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -406,6 +443,7 @@ object CallManager {
|
|||||||
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
|
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
|
||||||
webRtcSignalMutex.withLock {
|
webRtcSignalMutex.withLock {
|
||||||
val phase = _state.value.phase
|
val phase = _state.value.phase
|
||||||
|
breadcrumb("RTC: packet=${packet.signalType} payloadLen=${packet.sdpOrCandidate.length} phase=$phase")
|
||||||
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
|
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
|
||||||
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
|
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
|
||||||
return@withLock
|
return@withLock
|
||||||
@@ -435,6 +473,7 @@ object CallManager {
|
|||||||
pc.setRemoteDescriptionAwait(answer)
|
pc.setRemoteDescriptionAwait(answer)
|
||||||
remoteDescriptionSet = true
|
remoteDescriptionSet = true
|
||||||
flushBufferedRemoteCandidates()
|
flushBufferedRemoteCandidates()
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
|
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
|
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
|
||||||
@@ -457,12 +496,23 @@ object CallManager {
|
|||||||
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
|
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
val offerFingerprint = remoteOffer.description.shortFingerprintHex(10)
|
||||||
|
val phaseNow = _state.value.phase
|
||||||
|
if (offerFingerprint == lastLocalOfferFingerprint) {
|
||||||
|
breadcrumb("RTC: OFFER loopback ignored fp=$offerFingerprint")
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
if (phaseNow == CallPhase.ACTIVE && offerFingerprint == lastRemoteOfferFingerprint) {
|
||||||
|
breadcrumb("RTC: OFFER duplicate in ACTIVE ignored fp=$offerFingerprint")
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
|
breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
|
||||||
try {
|
try {
|
||||||
pc.setRemoteDescriptionAwait(remoteOffer)
|
pc.setRemoteDescriptionAwait(remoteOffer)
|
||||||
remoteDescriptionSet = true
|
remoteDescriptionSet = true
|
||||||
flushBufferedRemoteCandidates()
|
flushBufferedRemoteCandidates()
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
|
||||||
val stateAfterRemote = pc.signalingState()
|
val stateAfterRemote = pc.signalingState()
|
||||||
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
|
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
|
||||||
@@ -478,6 +528,8 @@ object CallManager {
|
|||||||
signalType = WebRTCSignalType.ANSWER,
|
signalType = WebRTCSignalType.ANSWER,
|
||||||
sdpOrCandidate = serializeSessionDescription(answer)
|
sdpOrCandidate = serializeSessionDescription(answer)
|
||||||
)
|
)
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
lastRemoteOfferFingerprint = offerFingerprint
|
||||||
breadcrumb("RTC: OFFER handled → ANSWER sent")
|
breadcrumb("RTC: OFFER handled → ANSWER sent")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
breadcrumb("RTC: OFFER FAILED — ${e.message}")
|
breadcrumb("RTC: OFFER FAILED — ${e.message}")
|
||||||
@@ -529,6 +581,7 @@ object CallManager {
|
|||||||
if (audioSource == null) {
|
if (audioSource == null) {
|
||||||
audioSource = factory.createAudioSource(MediaConstraints())
|
audioSource = factory.createAudioSource(MediaConstraints())
|
||||||
}
|
}
|
||||||
|
var senderToAttach: RtpSender? = null
|
||||||
if (localAudioTrack == null) {
|
if (localAudioTrack == null) {
|
||||||
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)
|
||||||
@@ -538,13 +591,27 @@ object CallManager {
|
|||||||
listOf(LOCAL_MEDIA_STREAM_ID)
|
listOf(LOCAL_MEDIA_STREAM_ID)
|
||||||
)
|
)
|
||||||
val transceiver = pc.addTransceiver(localAudioTrack, txInit)
|
val transceiver = pc.addTransceiver(localAudioTrack, txInit)
|
||||||
breadcrumb("PC: audio transceiver added, attaching E2EE…")
|
senderToAttach = transceiver?.sender
|
||||||
attachSenderE2EE(transceiver?.sender)
|
pendingAudioSenderForE2ee = senderToAttach
|
||||||
|
breadcrumb("PC: audio transceiver added (E2EE attach deferred)")
|
||||||
|
} else {
|
||||||
|
senderToAttach =
|
||||||
|
runCatching {
|
||||||
|
pc.senders.firstOrNull { sender ->
|
||||||
|
sender.track()?.kind() == "audio"
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
if (senderToAttach != null) {
|
||||||
|
pendingAudioSenderForE2ee = senderToAttach
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
attachSenderE2EE(pendingAudioSenderForE2ee ?: senderToAttach)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val offer = pc.createOfferAwait()
|
val offer = pc.createOfferAwait()
|
||||||
pc.setLocalDescriptionAwait(offer)
|
pc.setLocalDescriptionAwait(offer)
|
||||||
|
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
|
||||||
|
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
|
||||||
ProtocolManager.sendWebRtcSignal(
|
ProtocolManager.sendWebRtcSignal(
|
||||||
signalType = WebRTCSignalType.OFFER,
|
signalType = WebRTCSignalType.OFFER,
|
||||||
sdpOrCandidate = serializeSessionDescription(offer)
|
sdpOrCandidate = serializeSessionDescription(offer)
|
||||||
@@ -599,10 +666,12 @@ object CallManager {
|
|||||||
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")
|
breadcrumb("PC: onTrack → attachReceiverE2EE")
|
||||||
attachReceiverE2EE(transceiver)
|
attachReceiverE2EE(transceiver?.receiver)
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
}
|
}
|
||||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||||
breadcrumb("PC: connState=$newState")
|
breadcrumb("PC: connState=$newState")
|
||||||
|
breadcrumbState("onConnectionChange:$newState")
|
||||||
when (newState) {
|
when (newState) {
|
||||||
PeerConnection.PeerConnectionState.CONNECTED -> {
|
PeerConnection.PeerConnectionState.CONNECTED -> {
|
||||||
disconnectResetJob?.cancel()
|
disconnectResetJob?.cancel()
|
||||||
@@ -721,6 +790,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}")
|
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||||
|
breadcrumbState("resetSession")
|
||||||
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
|
||||||
@@ -747,8 +817,17 @@ object CallManager {
|
|||||||
roomId = ""
|
roomId = ""
|
||||||
offerSent = false
|
offerSent = false
|
||||||
remoteDescriptionSet = false
|
remoteDescriptionSet = false
|
||||||
|
keyExchangeSent = false
|
||||||
|
createRoomSent = false
|
||||||
|
lastPeerSharedPublicHex = ""
|
||||||
|
lastRemoteOfferFingerprint = ""
|
||||||
|
lastLocalOfferFingerprint = ""
|
||||||
|
e2eeRebindJob?.cancel()
|
||||||
|
e2eeRebindJob = null
|
||||||
localPrivateKey = null
|
localPrivateKey = null
|
||||||
localPublicKey = null
|
localPublicKey = null
|
||||||
|
callSessionId = ""
|
||||||
|
callStartedAtMs = 0L
|
||||||
durationJob?.cancel()
|
durationJob?.cancel()
|
||||||
durationJob = null
|
durationJob = null
|
||||||
disconnectResetJob?.cancel()
|
disconnectResetJob?.cancel()
|
||||||
@@ -792,6 +871,7 @@ object CallManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sharedKeyBytes = keyBytes.copyOf(32)
|
sharedKeyBytes = keyBytes.copyOf(32)
|
||||||
|
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
|
||||||
// Open native diagnostics file for frame-level logging
|
// Open native diagnostics file for frame-level logging
|
||||||
try {
|
try {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||||
@@ -799,40 +879,209 @@ object CallManager {
|
|||||||
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
||||||
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
|
// If sender track already exists, bind encryptor now.
|
||||||
|
val existingSender =
|
||||||
|
pendingAudioSenderForE2ee
|
||||||
|
?: runCatching {
|
||||||
|
peerConnection?.senders?.firstOrNull { sender -> sender.track()?.kind() == "audio" }
|
||||||
|
}.getOrNull()
|
||||||
|
if (existingSender != null) {
|
||||||
|
attachSenderE2EE(existingSender)
|
||||||
|
}
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
startE2EERebindLoopIfNeeded()
|
||||||
Log.i(TAG, "E2EE key ready (XChaCha20)")
|
Log.i(TAG, "E2EE key ready (XChaCha20)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startE2EERebindLoopIfNeeded() {
|
||||||
|
if (e2eeRebindJob?.isActive == true) return
|
||||||
|
e2eeRebindJob =
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1500L)
|
||||||
|
if (!e2eeAvailable || sharedKeyBytes == null) continue
|
||||||
|
val phaseNow = _state.value.phase
|
||||||
|
if (phaseNow != CallPhase.CONNECTING && phaseNow != CallPhase.ACTIVE) continue
|
||||||
|
val pc = peerConnection ?: continue
|
||||||
|
val sender =
|
||||||
|
runCatching {
|
||||||
|
pc.senders.firstOrNull { it.track()?.kind() == "audio" }
|
||||||
|
}.getOrNull()
|
||||||
|
if (sender != null) {
|
||||||
|
attachSenderE2EE(sender)
|
||||||
|
}
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachReceiverE2EEFromPeerConnection() {
|
||||||
|
val pc = peerConnection ?: return
|
||||||
|
runCatching {
|
||||||
|
var fromReceivers = 0
|
||||||
|
var fromTransceivers = 0
|
||||||
|
pc.receivers.forEach { receiver ->
|
||||||
|
if (isAudioReceiver(receiver)) {
|
||||||
|
attachReceiverE2EE(receiver)
|
||||||
|
fromReceivers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc.transceivers.forEach { transceiver ->
|
||||||
|
val receiver = transceiver.receiver ?: return@forEach
|
||||||
|
if (isAudioReceiver(receiver)) {
|
||||||
|
attachReceiverE2EE(receiver)
|
||||||
|
fromTransceivers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
breadcrumb("E2EE: scan receivers attached recv=$fromReceivers tx=$fromTransceivers totalMap=${receiverDecryptors.size}")
|
||||||
|
}.onFailure {
|
||||||
|
breadcrumb("E2EE: attachReceiverE2EEFromPeerConnection failed: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
|
/** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
|
||||||
private fun breadcrumb(step: String) {
|
private fun breadcrumb(step: String) {
|
||||||
try {
|
try {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
val dir = ensureCrashReportsDir() ?: return
|
||||||
if (!dir.exists()) dir.mkdirs()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
val f = java.io.File(dir, "e2ee_breadcrumb.txt")
|
val f = java.io.File(dir, BREADCRUMB_FILE_NAME)
|
||||||
// Reset file at start of key exchange
|
// Reset file at start of key exchange
|
||||||
if (step.startsWith("KE:") && step.contains("agreement")) {
|
if (step.startsWith("KE:") && step.contains("agreement")) {
|
||||||
f.writeText("")
|
f.writeText("")
|
||||||
}
|
}
|
||||||
f.appendText("${System.currentTimeMillis()} $step\n")
|
val sidPrefix = if (callSessionId.isNotBlank()) "[sid=$callSessionId] " else ""
|
||||||
|
f.appendText("${System.currentTimeMillis()} $sidPrefix$step\n")
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save a full crash report to crash_reports/ */
|
/** Save a full crash report to crash_reports/ */
|
||||||
private fun saveCrashReport(title: String, error: Throwable) {
|
private fun saveCrashReport(title: String, error: Throwable) {
|
||||||
try {
|
try {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
val dir = ensureCrashReportsDir() ?: return
|
||||||
if (!dir.exists()) dir.mkdirs()
|
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 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 f = java.io.File(dir, "crash_e2ee_$ts.txt")
|
||||||
val sw = java.io.StringWriter()
|
val sw = java.io.StringWriter()
|
||||||
error.printStackTrace(java.io.PrintWriter(sw))
|
error.printStackTrace(java.io.PrintWriter(sw))
|
||||||
f.writeText("=== E2EE CRASH REPORT ===\n$title\n\nType: ${error.javaClass.name}\nMessage: ${error.message}\n\n$sw")
|
val breadcrumbTail = readFileTail(java.io.File(dir, BREADCRUMB_FILE_NAME), TAIL_LINES)
|
||||||
|
val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES)
|
||||||
|
val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES)
|
||||||
|
val protocolTail =
|
||||||
|
ProtocolManager.debugLogs.value
|
||||||
|
.takeLast(PROTOCOL_LOG_TAIL_LINES)
|
||||||
|
.joinToString("\n")
|
||||||
|
f.writeText(
|
||||||
|
buildString {
|
||||||
|
appendLine("=== E2EE CRASH REPORT ===")
|
||||||
|
appendLine(title)
|
||||||
|
appendLine()
|
||||||
|
appendLine("Time: $ts")
|
||||||
|
appendLine("Type: ${error.javaClass.name}")
|
||||||
|
appendLine("Message: ${error.message}")
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- CALL SNAPSHOT ---")
|
||||||
|
appendLine(buildStateSnapshot())
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- STACKTRACE ---")
|
||||||
|
appendLine(sw.toString())
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- NATIVE CRASH (tail) ---")
|
||||||
|
appendLine(nativeCrash)
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- E2EE DIAG (tail) ---")
|
||||||
|
appendLine(diagTail)
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- E2EE BREADCRUMB (tail) ---")
|
||||||
|
appendLine(breadcrumbTail)
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- PROTOCOL LOGS (tail) ---")
|
||||||
|
appendLine(if (protocolTail.isBlank()) "<empty>" else protocolTail)
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun beginCallSession(seed: String) {
|
||||||
|
val bytes = ByteArray(4)
|
||||||
|
secureRandom.nextBytes(bytes)
|
||||||
|
val random = bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
callSessionId = "${seed.take(8)}-$random"
|
||||||
|
callStartedAtMs = System.currentTimeMillis()
|
||||||
|
breadcrumb("SESSION: begin seed=$seed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureCrashReportsDir(): java.io.File? {
|
||||||
|
val context = appContext ?: return null
|
||||||
|
return java.io.File(context.filesDir, "crash_reports").apply { if (!exists()) mkdirs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFileTail(file: java.io.File, maxLines: Int): String {
|
||||||
|
if (!file.exists()) return "<missing: ${file.name}>"
|
||||||
|
return runCatching {
|
||||||
|
val lines = file.readLines()
|
||||||
|
val tail = if (lines.size <= maxLines) lines else lines.takeLast(maxLines)
|
||||||
|
if (tail.isEmpty()) "<empty: ${file.name}>" else tail.joinToString("\n")
|
||||||
|
}.getOrElse { "<read-failed: ${file.name}: ${it.message}>" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStateSnapshot(): String {
|
||||||
|
val st = _state.value
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val age = if (callStartedAtMs > 0L) now - callStartedAtMs else -1L
|
||||||
|
val pc = peerConnection
|
||||||
|
val pcSig = runCatching { pc?.signalingState() }.getOrNull()
|
||||||
|
val pcIce = runCatching { pc?.iceConnectionState() }.getOrNull()
|
||||||
|
val pcConn = runCatching { pc?.connectionState() }.getOrNull()
|
||||||
|
val pcLocal = runCatching { pc?.localDescription?.type?.canonicalForm() }.getOrDefault("-")
|
||||||
|
val pcRemote = runCatching { pc?.remoteDescription?.type?.canonicalForm() }.getOrDefault("-")
|
||||||
|
val senders = runCatching { pc?.senders?.size ?: 0 }.getOrDefault(-1)
|
||||||
|
val receivers = runCatching { pc?.receivers?.size ?: 0 }.getOrDefault(-1)
|
||||||
|
return buildString {
|
||||||
|
append("sid=").append(if (callSessionId.isBlank()) "<none>" else callSessionId)
|
||||||
|
append(" ageMs=").append(age)
|
||||||
|
append(" phase=").append(st.phase)
|
||||||
|
append(" role=").append(role)
|
||||||
|
append(" peer=").append(st.peerPublicKey.take(12))
|
||||||
|
append(" room=").append(roomId.take(16))
|
||||||
|
append(" offerSent=").append(offerSent)
|
||||||
|
append(" remoteDescSet=").append(remoteDescriptionSet)
|
||||||
|
append(" e2eeAvail=").append(e2eeAvailable)
|
||||||
|
append(" keyBytes=").append(sharedKeyBytes?.size ?: 0)
|
||||||
|
append(" pc(sig=").append(pcSig)
|
||||||
|
append(",ice=").append(pcIce)
|
||||||
|
append(",conn=").append(pcConn)
|
||||||
|
append(",local=").append(pcLocal)
|
||||||
|
append(",remote=").append(pcRemote)
|
||||||
|
append(",senders=").append(senders)
|
||||||
|
append(",receivers=").append(receivers)
|
||||||
|
append(")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun breadcrumbState(marker: String) {
|
||||||
|
breadcrumb("STATE[$marker] ${buildStateSnapshot()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun senderMapKey(sender: RtpSender): String {
|
||||||
|
val id = runCatching { sender.id() }.getOrNull().orEmpty()
|
||||||
|
return if (id.isNotBlank()) "sid:$id" else "sender@${System.identityHashCode(sender)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun receiverMapKey(receiver: RtpReceiver): String {
|
||||||
|
val id = runCatching { receiver.id() }.getOrNull().orEmpty()
|
||||||
|
return if (id.isNotBlank()) "rid:$id" else "recv@${System.identityHashCode(receiver)}"
|
||||||
|
}
|
||||||
|
|
||||||
private fun attachSenderE2EE(sender: RtpSender?) {
|
private fun attachSenderE2EE(sender: RtpSender?) {
|
||||||
if (!e2eeAvailable) return
|
if (!e2eeAvailable) return
|
||||||
val key = sharedKeyBytes ?: return
|
val key = sharedKeyBytes ?: return
|
||||||
if (sender == null) return
|
if (sender == null) return
|
||||||
|
val mapKey = senderMapKey(sender)
|
||||||
|
val existing = senderEncryptors[mapKey]
|
||||||
|
if (existing != null) {
|
||||||
|
runCatching { sender.setFrameEncryptor(existing) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
||||||
@@ -847,7 +1096,8 @@ object CallManager {
|
|||||||
breadcrumb("4. calling sender.setFrameEncryptor…")
|
breadcrumb("4. calling sender.setFrameEncryptor…")
|
||||||
sender.setFrameEncryptor(enc)
|
sender.setFrameEncryptor(enc)
|
||||||
breadcrumb("5. setFrameEncryptor OK!")
|
breadcrumb("5. setFrameEncryptor OK!")
|
||||||
senderEncryptor = enc
|
senderEncryptors[mapKey] = enc
|
||||||
|
pendingAudioSenderForE2ee = null
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
saveCrashReport("attachSenderE2EE failed", e)
|
saveCrashReport("attachSenderE2EE failed", e)
|
||||||
Log.e(TAG, "E2EE: sender encryptor failed", e)
|
Log.e(TAG, "E2EE: sender encryptor failed", e)
|
||||||
@@ -855,10 +1105,21 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachReceiverE2EE(transceiver: RtpTransceiver?) {
|
private fun isAudioReceiver(receiver: RtpReceiver?): Boolean {
|
||||||
|
if (receiver == null) return false
|
||||||
|
return runCatching { receiver.track()?.kind() == "audio" }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachReceiverE2EE(receiver: RtpReceiver?) {
|
||||||
if (!e2eeAvailable) return
|
if (!e2eeAvailable) return
|
||||||
val key = sharedKeyBytes ?: return
|
val key = sharedKeyBytes ?: return
|
||||||
val receiver = transceiver?.receiver ?: return
|
if (receiver == null) return
|
||||||
|
val mapKey = receiverMapKey(receiver)
|
||||||
|
val existing = receiverDecryptors[mapKey]
|
||||||
|
if (existing != null) {
|
||||||
|
runCatching { receiver.setFrameDecryptor(existing) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
breadcrumb("6. decryptor: creating…")
|
breadcrumb("6. decryptor: creating…")
|
||||||
@@ -873,7 +1134,7 @@ object CallManager {
|
|||||||
breadcrumb("9. calling receiver.setFrameDecryptor…")
|
breadcrumb("9. calling receiver.setFrameDecryptor…")
|
||||||
receiver.setFrameDecryptor(dec)
|
receiver.setFrameDecryptor(dec)
|
||||||
breadcrumb("10. setFrameDecryptor OK!")
|
breadcrumb("10. setFrameDecryptor OK!")
|
||||||
receiverDecryptor = dec
|
receiverDecryptors[mapKey] = dec
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
saveCrashReport("attachReceiverE2EE failed", e)
|
saveCrashReport("attachReceiverE2EE failed", e)
|
||||||
Log.e(TAG, "E2EE: receiver decryptor failed", e)
|
Log.e(TAG, "E2EE: receiver decryptor failed", e)
|
||||||
@@ -885,10 +1146,15 @@ object CallManager {
|
|||||||
// Release our ref. WebRTC holds its own ref via scoped_refptr.
|
// Release our ref. WebRTC holds its own ref via scoped_refptr.
|
||||||
// After our Release: WebRTC ref remains. On peerConnection.close()
|
// After our Release: WebRTC ref remains. On peerConnection.close()
|
||||||
// WebRTC releases its ref → ref=0 → native object deleted.
|
// WebRTC releases its ref → ref=0 → native object deleted.
|
||||||
runCatching { senderEncryptor?.dispose() }
|
senderEncryptors.values.forEach { enc ->
|
||||||
runCatching { receiverDecryptor?.dispose() }
|
runCatching { enc.dispose() }
|
||||||
senderEncryptor = null
|
}
|
||||||
receiverDecryptor = null
|
receiverDecryptors.values.forEach { dec ->
|
||||||
|
runCatching { dec.dispose() }
|
||||||
|
}
|
||||||
|
senderEncryptors.clear()
|
||||||
|
receiverDecryptors.clear()
|
||||||
|
pendingAudioSenderForE2ee = null
|
||||||
sharedKeyBytes?.let { it.fill(0) }
|
sharedKeyBytes?.let { it.fill(0) }
|
||||||
sharedKeyBytes = null
|
sharedKeyBytes = null
|
||||||
runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
|
runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
|
||||||
@@ -896,11 +1162,12 @@ object CallManager {
|
|||||||
|
|
||||||
private fun generateSessionKeys() {
|
private fun generateSessionKeys() {
|
||||||
val privateKey = ByteArray(32)
|
val privateKey = ByteArray(32)
|
||||||
secureRandom.nextBytes(privateKey)
|
X25519.generatePrivateKey(secureRandom, privateKey)
|
||||||
val publicKey = ByteArray(32)
|
val publicKey = ByteArray(32)
|
||||||
X25519.generatePublicKey(privateKey, 0, publicKey, 0)
|
X25519.generatePublicKey(privateKey, 0, publicKey, 0)
|
||||||
localPrivateKey = privateKey
|
localPrivateKey = privateKey
|
||||||
localPublicKey = publicKey
|
localPublicKey = publicKey
|
||||||
|
breadcrumb("KE: local keypair pub=${publicKey.shortHex()} privFp=${privateKey.fingerprintHex(6)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeSharedSecretHex(peerPublicHex: String): String? {
|
private fun computeSharedSecretHex(peerPublicHex: String): String? {
|
||||||
@@ -908,17 +1175,17 @@ object CallManager {
|
|||||||
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 rawDh = ByteArray(32)
|
val rawDh = ByteArray(32)
|
||||||
breadcrumb("KE: X25519 agreement…")
|
breadcrumb("KE: X25519 agreement with peerPub=${peerPublic.shortHex()}…")
|
||||||
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
|
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
breadcrumb("KE: X25519 FAILED")
|
breadcrumb("KE: X25519 FAILED")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
breadcrumb("KE: X25519 OK, calling HSalsa20…")
|
breadcrumb("KE: X25519 OK rawDhFp=${rawDh.fingerprintHex(8)}, calling HSalsa20…")
|
||||||
return try {
|
return try {
|
||||||
val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
|
val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
|
||||||
rawDh.fill(0)
|
rawDh.fill(0)
|
||||||
breadcrumb("KE: HSalsa20 OK, key ready")
|
breadcrumb("KE: HSalsa20 OK keyFp=${naclShared.fingerprintHex(8)}")
|
||||||
naclShared.toHex()
|
naclShared.toHex()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
saveCrashReport("HSalsa20 failed", e)
|
saveCrashReport("HSalsa20 failed", e)
|
||||||
@@ -943,6 +1210,12 @@ object CallManager {
|
|||||||
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
|
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
|
||||||
val sdp = json.getString("sdp")
|
val sdp = json.getString("sdp")
|
||||||
SessionDescription(type, sdp)
|
SessionDescription(type, sdp)
|
||||||
|
}.onFailure { error ->
|
||||||
|
val preview = raw.replace('\n', ' ').replace('\r', ' ')
|
||||||
|
breadcrumb(
|
||||||
|
"RTC: parseSessionDescription FAILED len=${raw.length} " +
|
||||||
|
"preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
|
||||||
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,6 +1234,12 @@ object CallManager {
|
|||||||
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
|
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
|
||||||
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
|
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
|
||||||
IceCandidate(sdpMid, sdpMLineIndex, candidate)
|
IceCandidate(sdpMid, sdpMLineIndex, candidate)
|
||||||
|
}.onFailure { error ->
|
||||||
|
val preview = raw.replace('\n', ' ').replace('\r', ' ')
|
||||||
|
breadcrumb(
|
||||||
|
"RTC: parseIceCandidate FAILED len=${raw.length} " +
|
||||||
|
"preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
|
||||||
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,6 +1255,16 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
private fun ByteArray.shortHex(bytes: Int = 6): String =
|
||||||
|
take(bytes.coerceAtMost(size)).joinToString("") { "%02x".format(it) }
|
||||||
|
private fun ByteArray.fingerprintHex(bytes: Int = 8): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(this)
|
||||||
|
return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
private fun String.shortFingerprintHex(bytes: Int = 8): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray(Charsets.UTF_8))
|
||||||
|
return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.hexToBytes(): ByteArray? {
|
private fun String.hexToBytes(): ByteArray? {
|
||||||
val clean = trim().lowercase()
|
val clean = trim().lowercase()
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.rosetta.messenger.network
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push Token packet (ID: 0x0A) - DEPRECATED
|
|
||||||
* Старый формат, заменен на PacketPushNotification (0x10)
|
|
||||||
*/
|
|
||||||
class PacketPushToken : Packet() {
|
|
||||||
var privateKey: String = ""
|
|
||||||
var publicKey: String = ""
|
|
||||||
var pushToken: String = ""
|
|
||||||
var platform: String = "android" // "android" или "ios"
|
|
||||||
|
|
||||||
override fun getPacketId(): Int = 0x0A
|
|
||||||
|
|
||||||
override fun receive(stream: Stream) {
|
|
||||||
privateKey = stream.readString()
|
|
||||||
publicKey = stream.readString()
|
|
||||||
pushToken = stream.readString()
|
|
||||||
platform = stream.readString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun send(): Stream {
|
|
||||||
val stream = Stream()
|
|
||||||
stream.writeInt16(getPacketId())
|
|
||||||
stream.writeString(privateKey)
|
|
||||||
stream.writeString(publicKey)
|
|
||||||
stream.writeString(pushToken)
|
|
||||||
stream.writeString(platform)
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ class Protocol(
|
|||||||
private const val TAG = "RosettaProtocol"
|
private const val TAG = "RosettaProtocol"
|
||||||
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
||||||
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
||||||
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
private const val MIN_PACKET_ID_BITS = 16 // Stream.readInt16() reads exactly 16 bits
|
||||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||||
|
|||||||
@@ -1,163 +1,332 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary stream for protocol packets
|
* Binary stream for protocol packets.
|
||||||
* Matches the React Native implementation exactly
|
* Ported from desktop/dev stream.ts implementation.
|
||||||
*/
|
*/
|
||||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||||
private var _stream = mutableListOf<Int>()
|
private var stream: ByteArray
|
||||||
private var _readPointer = 0
|
private var readPointer = 0 // bits
|
||||||
private var _writePointer = 0
|
private var writePointer = 0 // bits
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
if (stream.isEmpty()) {
|
||||||
|
this.stream = ByteArray(0)
|
||||||
|
} else {
|
||||||
|
this.stream = stream.copyOf()
|
||||||
|
this.writePointer = this.stream.size shl 3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStream(): ByteArray {
|
fun getStream(): ByteArray {
|
||||||
return _stream.map { it.toByte() }.toByteArray()
|
return stream.copyOf(length())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getReadPointerBits(): Int = _readPointer
|
fun setStream(stream: ByteArray = ByteArray(0)) {
|
||||||
|
if (stream.isEmpty()) {
|
||||||
fun getTotalBits(): Int = _stream.size * 8
|
this.stream = ByteArray(0)
|
||||||
|
this.readPointer = 0
|
||||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
this.writePointer = 0
|
||||||
|
return
|
||||||
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
|
|
||||||
|
|
||||||
fun setStream(stream: ByteArray) {
|
|
||||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
|
||||||
_readPointer = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeInt8(value: Int) {
|
|
||||||
val negationBit = if (value < 0) 1 else 0
|
|
||||||
val int8Value = Math.abs(value) and 0xFF
|
|
||||||
|
|
||||||
ensureCapacity(_writePointer shr 3)
|
|
||||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
|
|
||||||
_writePointer++
|
|
||||||
|
|
||||||
for (i in 0 until 8) {
|
|
||||||
val bit = (int8Value shr (7 - i)) and 1
|
|
||||||
ensureCapacity(_writePointer shr 3)
|
|
||||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
|
||||||
_writePointer++
|
|
||||||
}
|
}
|
||||||
|
this.stream = stream.copyOf()
|
||||||
|
this.readPointer = 0
|
||||||
|
this.writePointer = this.stream.size shl 3
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readInt8(): Int {
|
fun getBuffer(): ByteArray = getStream()
|
||||||
var value = 0
|
|
||||||
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
|
||||||
_readPointer++
|
|
||||||
|
|
||||||
for (i in 0 until 8) {
|
fun isEmpty(): Boolean = writePointer == 0
|
||||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
|
||||||
value = value or (bit shl (7 - i))
|
|
||||||
_readPointer++
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (negationBit == 1) -value else value
|
fun length(): Int = (writePointer + 7) shr 3
|
||||||
}
|
|
||||||
|
fun getReadPointerBits(): Int = readPointer
|
||||||
|
|
||||||
|
fun getTotalBits(): Int = writePointer
|
||||||
|
|
||||||
|
fun getRemainingBits(): Int = writePointer - readPointer
|
||||||
|
|
||||||
|
fun hasRemainingBits(): Boolean = readPointer < writePointer
|
||||||
|
|
||||||
fun writeBit(value: Int) {
|
fun writeBit(value: Int) {
|
||||||
val bit = value and 1
|
writeBits((value and 1).toULong(), 1)
|
||||||
ensureCapacity(_writePointer shr 3)
|
|
||||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
|
||||||
_writePointer++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBit(): Int {
|
fun readBit(): Int = readBits(1).toInt()
|
||||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
|
||||||
_readPointer++
|
|
||||||
return bit
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeBoolean(value: Boolean) {
|
fun writeBoolean(value: Boolean) {
|
||||||
writeBit(if (value) 1 else 0)
|
writeBit(if (value) 1 else 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBoolean(): Boolean {
|
fun readBoolean(): Boolean = readBit() == 1
|
||||||
return readBit() == 1
|
|
||||||
|
fun writeByte(value: Int) {
|
||||||
|
writeUInt8(value and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readByte(): Int {
|
||||||
|
val value = readUInt8()
|
||||||
|
return if (value >= 0x80) value - 0x100 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeUInt8(value: Int) {
|
||||||
|
val v = value and 0xFF
|
||||||
|
|
||||||
|
if ((writePointer and 7) == 0) {
|
||||||
|
reserveBits(8)
|
||||||
|
stream[writePointer shr 3] = v.toByte()
|
||||||
|
writePointer += 8
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBits(v.toULong(), 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readUInt8(): Int {
|
||||||
|
if (remainingBits() < 8L) {
|
||||||
|
throw IllegalStateException("Not enough bits to read UInt8")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((readPointer and 7) == 0) {
|
||||||
|
val value = stream[readPointer shr 3].toInt() and 0xFF
|
||||||
|
readPointer += 8
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return readBits(8).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeInt8(value: Int) {
|
||||||
|
writeUInt8(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readInt8(): Int {
|
||||||
|
val value = readUInt8()
|
||||||
|
return if (value >= 0x80) value - 0x100 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeUInt16(value: Int) {
|
||||||
|
val v = value and 0xFFFF
|
||||||
|
writeUInt8((v ushr 8) and 0xFF)
|
||||||
|
writeUInt8(v and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readUInt16(): Int {
|
||||||
|
val hi = readUInt8()
|
||||||
|
val lo = readUInt8()
|
||||||
|
return (hi shl 8) or lo
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt16(value: Int) {
|
fun writeInt16(value: Int) {
|
||||||
writeInt8(value shr 8)
|
writeUInt16(value)
|
||||||
writeInt8(value and 0xFF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readInt16(): Int {
|
fun readInt16(): Int {
|
||||||
val high = readInt8() shl 8
|
val value = readUInt16()
|
||||||
return high or readInt8()
|
return if (value >= 0x8000) value - 0x10000 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeUInt32(value: Long) {
|
||||||
|
if (value < 0L || value > 0xFFFF_FFFFL) {
|
||||||
|
throw IllegalArgumentException("UInt32 out of range: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
||||||
|
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
||||||
|
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
||||||
|
writeUInt8((value and 0xFF).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readUInt32(): Long {
|
||||||
|
val b1 = readUInt8().toLong()
|
||||||
|
val b2 = readUInt8().toLong()
|
||||||
|
val b3 = readUInt8().toLong()
|
||||||
|
val b4 = readUInt8().toLong()
|
||||||
|
return ((b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4) and 0xFFFF_FFFFL
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt32(value: Int) {
|
fun writeInt32(value: Int) {
|
||||||
writeInt16(value shr 16)
|
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
||||||
writeInt16(value and 0xFFFF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readInt32(): Int {
|
fun readInt32(): Int = readUInt32().toInt()
|
||||||
val high = readInt16() shl 16
|
|
||||||
return high or readInt16()
|
fun writeUInt64(value: ULong) {
|
||||||
|
writeUInt8(((value shr 56) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 48) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 40) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 32) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 24) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 16) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 8) and 0xFFu).toInt())
|
||||||
|
writeUInt8((value and 0xFFu).toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt64(value: Long) {
|
fun readUInt64(): ULong {
|
||||||
val high = (value shr 32).toInt()
|
val high = readUInt32().toULong()
|
||||||
val low = (value and 0xFFFFFFFF).toInt()
|
val low = readUInt32().toULong()
|
||||||
writeInt32(high)
|
|
||||||
writeInt32(low)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readInt64(): Long {
|
|
||||||
val high = readInt32().toLong()
|
|
||||||
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
|
||||||
return (high shl 32) or low
|
return (high shl 32) or low
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeString(value: String) {
|
fun writeInt64(value: Long) {
|
||||||
writeInt32(value.length)
|
writeUInt64(value.toULong())
|
||||||
for (char in value) {
|
}
|
||||||
writeInt16(char.code)
|
|
||||||
|
fun readInt64(): Long = readUInt64().toLong()
|
||||||
|
|
||||||
|
fun writeFloat32(value: Float) {
|
||||||
|
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL
|
||||||
|
writeUInt32(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readFloat32(): Float {
|
||||||
|
val bits = readUInt32().toInt()
|
||||||
|
return Float.fromBits(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeString(value: String?) {
|
||||||
|
val str = value ?: ""
|
||||||
|
writeUInt32(str.length.toLong())
|
||||||
|
|
||||||
|
if (str.isEmpty()) return
|
||||||
|
|
||||||
|
reserveBits(str.length.toLong() * 16L)
|
||||||
|
for (i in str.indices) {
|
||||||
|
writeUInt16(str[i].code and 0xFFFF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readString(): String {
|
fun readString(): String {
|
||||||
val length = readInt32()
|
val len = readUInt32()
|
||||||
// Desktop parity + safety: don't trust malformed string length.
|
if (len > Int.MAX_VALUE.toLong()) {
|
||||||
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
throw IllegalStateException("String length too large: $len")
|
||||||
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"RosettaStream",
|
|
||||||
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
val sb = StringBuilder()
|
|
||||||
for (i in 0 until length) {
|
val requiredBits = len * 16L
|
||||||
sb.append(readInt16().toChar())
|
if (requiredBits > remainingBits()) {
|
||||||
|
throw IllegalStateException("Not enough bits to read string")
|
||||||
}
|
}
|
||||||
return sb.toString()
|
|
||||||
|
val chars = CharArray(len.toInt())
|
||||||
|
for (i in chars.indices) {
|
||||||
|
chars[i] = readUInt16().toChar()
|
||||||
|
}
|
||||||
|
return String(chars)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeBytes(value: ByteArray) {
|
fun writeBytes(value: ByteArray?) {
|
||||||
writeInt32(value.size)
|
val bytes = value ?: ByteArray(0)
|
||||||
for (byte in value) {
|
writeUInt32(bytes.size.toLong())
|
||||||
writeInt8(byte.toInt())
|
if (bytes.isEmpty()) return
|
||||||
|
|
||||||
|
reserveBits(bytes.size.toLong() * 8L)
|
||||||
|
|
||||||
|
if ((writePointer and 7) == 0) {
|
||||||
|
val byteIndex = writePointer shr 3
|
||||||
|
ensureCapacity(byteIndex + bytes.size - 1)
|
||||||
|
System.arraycopy(bytes, 0, stream, byteIndex, bytes.size)
|
||||||
|
writePointer += bytes.size shl 3
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (b in bytes) {
|
||||||
|
writeUInt8(b.toInt() and 0xFF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBytes(): ByteArray {
|
fun readBytes(): ByteArray {
|
||||||
val length = readInt32()
|
val len = readUInt32()
|
||||||
val bytes = ByteArray(length)
|
if (len == 0L) return ByteArray(0)
|
||||||
for (i in 0 until length) {
|
if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
|
||||||
bytes[i] = readInt8().toByte()
|
|
||||||
|
val requiredBits = len * 8L
|
||||||
|
if (requiredBits > remainingBits()) {
|
||||||
|
return ByteArray(0)
|
||||||
}
|
}
|
||||||
return bytes
|
|
||||||
|
val out = ByteArray(len.toInt())
|
||||||
|
|
||||||
|
if ((readPointer and 7) == 0) {
|
||||||
|
val byteIndex = readPointer shr 3
|
||||||
|
System.arraycopy(stream, byteIndex, out, 0, out.size)
|
||||||
|
readPointer += out.size shl 3
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in out.indices) {
|
||||||
|
out[i] = readUInt8().toByte()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
|
||||||
|
|
||||||
|
private fun writeBits(value: ULong, bits: Int) {
|
||||||
|
if (bits <= 0) return
|
||||||
|
|
||||||
|
reserveBits(bits.toLong())
|
||||||
|
|
||||||
|
for (i in bits - 1 downTo 0) {
|
||||||
|
val bit = ((value shr i) and 1u).toInt()
|
||||||
|
val byteIndex = writePointer shr 3
|
||||||
|
val shift = 7 - (writePointer and 7)
|
||||||
|
|
||||||
|
if (bit == 1) {
|
||||||
|
stream[byteIndex] = (stream[byteIndex].toInt() or (1 shl shift)).toByte()
|
||||||
|
} else {
|
||||||
|
stream[byteIndex] = (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
writePointer++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBits(bits: Int): ULong {
|
||||||
|
if (bits <= 0) return 0u
|
||||||
|
if (remainingBits() < bits.toLong()) {
|
||||||
|
throw IllegalStateException("Not enough bits to read")
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = 0uL
|
||||||
|
repeat(bits) {
|
||||||
|
val bit = (stream[readPointer shr 3].toInt() ushr (7 - (readPointer and 7))) and 1
|
||||||
|
value = (value shl 1) or bit.toULong()
|
||||||
|
readPointer++
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reserveBits(bitsToWrite: Long) {
|
||||||
|
if (bitsToWrite <= 0L) return
|
||||||
|
|
||||||
|
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
|
||||||
|
if (lastBitIndex < 0L) {
|
||||||
|
throw IllegalStateException("Bit index overflow")
|
||||||
|
}
|
||||||
|
|
||||||
|
val byteIndex = lastBitIndex ushr 3
|
||||||
|
if (byteIndex > Int.MAX_VALUE.toLong()) {
|
||||||
|
throw IllegalStateException("Stream too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCapacity(byteIndex.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureCapacity(index: Int) {
|
private fun ensureCapacity(index: Int) {
|
||||||
while (_stream.size <= index) {
|
val requiredSize = index + 1
|
||||||
_stream.add(0)
|
if (requiredSize <= stream.size) return
|
||||||
|
|
||||||
|
var newSize = if (stream.isEmpty()) 32 else stream.size
|
||||||
|
while (newSize < requiredSize) {
|
||||||
|
if (newSize > (Int.MAX_VALUE shr 1)) {
|
||||||
|
newSize = requiredSize
|
||||||
|
break
|
||||||
|
}
|
||||||
|
newSize = newSize shl 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val next = ByteArray(newSize)
|
||||||
|
System.arraycopy(stream, 0, next, 0, stream.size)
|
||||||
|
stream = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
package com.rosetta.messenger.providers
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
|
||||||
import com.rosetta.messenger.database.DatabaseService
|
|
||||||
import com.rosetta.messenger.database.DecryptedAccountData
|
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth state management - matches React Native architecture
|
|
||||||
*/
|
|
||||||
sealed class AuthStatus {
|
|
||||||
object Loading : AuthStatus()
|
|
||||||
object Unauthenticated : AuthStatus()
|
|
||||||
data class Authenticated(val account: DecryptedAccountData) : AuthStatus()
|
|
||||||
data class Locked(val publicKey: String) : AuthStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AuthStateData(
|
|
||||||
val status: AuthStatus = AuthStatus.Loading,
|
|
||||||
val hasExistingAccounts: Boolean = false,
|
|
||||||
val availableAccounts: List<String> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
class AuthStateManager(
|
|
||||||
private val context: Context,
|
|
||||||
private val scope: CoroutineScope
|
|
||||||
) {
|
|
||||||
private val databaseService = DatabaseService.getInstance(context)
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AuthStateData())
|
|
||||||
val state: StateFlow<AuthStateData> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var currentDecryptedAccount: DecryptedAccountData? = null
|
|
||||||
|
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Кэш списка аккаунтов для UI
|
|
||||||
private var accountsCache: List<String>? = null
|
|
||||||
private var lastAccountsLoadTime = 0L
|
|
||||||
private val accountsCacheTTL = 5000L // 5 секунд
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "AuthStateManager"
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
loadAccounts()
|
|
||||||
checkAuthStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadAccounts() = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Используем кэш если он свежий
|
|
||||||
val currentTime = System.currentTimeMillis()
|
|
||||||
if (accountsCache != null && (currentTime - lastAccountsLoadTime) < accountsCacheTTL) {
|
|
||||||
_state.update { it.copy(
|
|
||||||
hasExistingAccounts = accountsCache!!.isNotEmpty(),
|
|
||||||
availableAccounts = accountsCache!!
|
|
||||||
)}
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
|
|
||||||
val accounts = databaseService.getAllEncryptedAccounts()
|
|
||||||
val hasAccounts = accounts.isNotEmpty()
|
|
||||||
val accountKeys = accounts.map { it.publicKey }
|
|
||||||
|
|
||||||
// Обновляем кэш
|
|
||||||
accountsCache = accountKeys
|
|
||||||
lastAccountsLoadTime = currentTime
|
|
||||||
|
|
||||||
_state.update { it.copy(
|
|
||||||
hasExistingAccounts = hasAccounts,
|
|
||||||
availableAccounts = accountKeys
|
|
||||||
)}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkAuthStatus() {
|
|
||||||
try {
|
|
||||||
val hasAccounts = databaseService.hasAccounts()
|
|
||||||
if (!hasAccounts) {
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
} else {
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new account from seed phrase
|
|
||||||
* Matches createAccountFromSeedPhrase from React Native
|
|
||||||
* 🚀 ОПТИМИЗАЦИЯ: Dispatchers.Default для CPU-интенсивной криптографии
|
|
||||||
*/
|
|
||||||
suspend fun createAccount(
|
|
||||||
seedPhrase: List<String>,
|
|
||||||
password: String
|
|
||||||
): Result<DecryptedAccountData> = withContext(Dispatchers.Default) {
|
|
||||||
try {
|
|
||||||
// Step 1: Generate key pair from seed phrase (using BIP39)
|
|
||||||
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
|
|
||||||
|
|
||||||
// Step 2: Generate private key hash for protocol
|
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
|
|
||||||
|
|
||||||
// Step 3: Encrypt private key with password
|
|
||||||
val encryptedPrivateKey = CryptoManager.encryptWithPassword(
|
|
||||||
keyPair.privateKey, password
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 4: Encrypt seed phrase with password
|
|
||||||
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(
|
|
||||||
seedPhrase.joinToString(" "), password
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 5: Save to database
|
|
||||||
val saved = withContext(Dispatchers.IO) {
|
|
||||||
databaseService.saveEncryptedAccount(
|
|
||||||
publicKey = keyPair.publicKey,
|
|
||||||
privateKeyEncrypted = encryptedPrivateKey,
|
|
||||||
seedPhraseEncrypted = encryptedSeedPhrase
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!saved) {
|
|
||||||
return@withContext Result.failure(Exception("Failed to save account to database"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Create decrypted account object
|
|
||||||
val decryptedAccount = DecryptedAccountData(
|
|
||||||
publicKey = keyPair.publicKey,
|
|
||||||
privateKey = keyPair.privateKey,
|
|
||||||
privateKeyHash = privateKeyHash,
|
|
||||||
seedPhrase = seedPhrase
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 7: Update state and reload accounts
|
|
||||||
currentDecryptedAccount = decryptedAccount
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Authenticated(decryptedAccount)
|
|
||||||
)}
|
|
||||||
|
|
||||||
loadAccounts()
|
|
||||||
|
|
||||||
// Initialize MessageRepository BEFORE connecting/authenticating
|
|
||||||
// so incoming messages from server are stored under the correct account
|
|
||||||
ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey)
|
|
||||||
|
|
||||||
// Step 8: Connect and authenticate with protocol
|
|
||||||
ProtocolManager.connect()
|
|
||||||
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
|
|
||||||
ProtocolManager.reconnectNowIfNeeded("auth_state_create")
|
|
||||||
|
|
||||||
Result.success(decryptedAccount)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlock account with password
|
|
||||||
* Matches loginWithPassword from React Native
|
|
||||||
*/
|
|
||||||
suspend fun unlock(
|
|
||||||
publicKey: String,
|
|
||||||
password: String
|
|
||||||
): Result<DecryptedAccountData> = withContext(Dispatchers.Default) {
|
|
||||||
try {
|
|
||||||
// Decrypt account from database
|
|
||||||
val decryptedAccount = withContext(Dispatchers.IO) {
|
|
||||||
databaseService.decryptAccount(publicKey, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decryptedAccount == null) {
|
|
||||||
return@withContext Result.failure(Exception("Invalid password or account not found"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last used timestamp
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
databaseService.updateLastUsed(publicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
currentDecryptedAccount = decryptedAccount
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Authenticated(decryptedAccount)
|
|
||||||
)}
|
|
||||||
|
|
||||||
// Initialize MessageRepository BEFORE connecting/authenticating
|
|
||||||
// so incoming messages from server are stored under the correct account
|
|
||||||
ProtocolManager.initializeAccount(decryptedAccount.publicKey, decryptedAccount.privateKey)
|
|
||||||
|
|
||||||
// Connect and authenticate with protocol
|
|
||||||
ProtocolManager.connect()
|
|
||||||
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
|
|
||||||
ProtocolManager.reconnectNowIfNeeded("auth_state_unlock")
|
|
||||||
|
|
||||||
Result.success(decryptedAccount)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout - clears decrypted account from memory
|
|
||||||
*/
|
|
||||||
fun logout() {
|
|
||||||
currentDecryptedAccount = null
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete account from database
|
|
||||||
*/
|
|
||||||
suspend fun deleteAccount(publicKey: String): Result<Unit> = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val success = databaseService.deleteAccount(publicKey)
|
|
||||||
if (!success) {
|
|
||||||
return@withContext Result.failure(Exception("Failed to delete account"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// If deleting current account, logout
|
|
||||||
if (currentDecryptedAccount?.publicKey == publicKey) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAccounts()
|
|
||||||
Result.success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current decrypted account (if authenticated)
|
|
||||||
*/
|
|
||||||
fun getCurrentAccount(): DecryptedAccountData? = currentDecryptedAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberAuthState(context: Context): AuthStateManager {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
return remember(context) {
|
|
||||||
AuthStateManager(context, scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ProvideAuthState(
|
|
||||||
authState: AuthStateManager,
|
|
||||||
content: @Composable (AuthStateData) -> Unit
|
|
||||||
) {
|
|
||||||
val state by authState.state.collectAsState()
|
|
||||||
content(state)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +0,0 @@
|
|||||||
package com.rosetta.messenger.ui.chats.components
|
|
||||||
|
|
||||||
import android.view.HapticFeedbackConstants
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
|
||||||
import compose.icons.TablerIcons
|
|
||||||
import compose.icons.tablericons.Bug
|
|
||||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🐛 BottomSheet для отображения debug логов протокола
|
|
||||||
*
|
|
||||||
* Показывает логи отправки/получения сообщений для дебага.
|
|
||||||
* Использует ProtocolManager.debugLogs как источник данных.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun DebugLogsBottomSheet(
|
|
||||||
logs: List<String>,
|
|
||||||
isDarkTheme: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onClearLogs: () -> Unit
|
|
||||||
) {
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val view = LocalView.current
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
|
||||||
|
|
||||||
// Haptic feedback при открытии
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Авто-скролл вниз при новых логах
|
|
||||||
LaunchedEffect(logs.size) {
|
|
||||||
if (logs.isNotEmpty()) {
|
|
||||||
listState.animateScrollToItem(logs.size - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Плавное затемнение статус бара
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
if (!view.isInEditMode) {
|
|
||||||
val window = (view.context as? android.app.Activity)?.window
|
|
||||||
val originalStatusBarColor = window?.statusBarColor ?: 0
|
|
||||||
val scrimColor = android.graphics.Color.argb(153, 0, 0, 0)
|
|
||||||
|
|
||||||
val fadeInAnimator = android.animation.ValueAnimator.ofArgb(originalStatusBarColor, scrimColor).apply {
|
|
||||||
duration = 200
|
|
||||||
addUpdateListener { animator ->
|
|
||||||
window?.statusBarColor = animator.animatedValue as Int
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fadeInAnimator.start()
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
val fadeOutAnimator = android.animation.ValueAnimator.ofArgb(scrimColor, originalStatusBarColor).apply {
|
|
||||||
duration = 150
|
|
||||||
addUpdateListener { animator ->
|
|
||||||
window?.statusBarColor = animator.animatedValue as Int
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fadeOutAnimator.start()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onDispose { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissWithAnimation() {
|
|
||||||
scope.launch {
|
|
||||||
sheetState.hide()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { dismissWithAnimation() },
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = backgroundColor,
|
|
||||||
scrimColor = Color.Black.copy(alpha = 0.6f),
|
|
||||||
dragHandle = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(36.dp)
|
|
||||||
.height(5.dp)
|
|
||||||
.clip(RoundedCornerShape(2.5.dp))
|
|
||||||
.background(if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
|
||||||
modifier = Modifier.statusBarsPadding()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Иконка и заголовок
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
TablerIcons.Bug,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = PrimaryBlue,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "Debug Logs",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${logs.size} log entries",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = secondaryTextColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки
|
|
||||||
Row {
|
|
||||||
IconButton(onClick = onClearLogs) {
|
|
||||||
Icon(
|
|
||||||
painter = TelegramIcons.Delete,
|
|
||||||
contentDescription = "Clear logs",
|
|
||||||
tint = secondaryTextColor.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier.size(22.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { dismissWithAnimation() }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Close",
|
|
||||||
tint = secondaryTextColor.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier.size(22.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
|
||||||
|
|
||||||
// Контент
|
|
||||||
if (logs.isEmpty()) {
|
|
||||||
// Empty state
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(200.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No logs yet.\nLogs will appear here during messaging.",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = secondaryTextColor,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Список логов
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 300.dp, max = 500.dp)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
items(logs) { log ->
|
|
||||||
DebugLogItem(log = log, isDarkTheme = isDarkTheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Элемент лога с цветовой кодировкой
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun DebugLogItem(
|
|
||||||
log: String,
|
|
||||||
isDarkTheme: Boolean
|
|
||||||
) {
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val successColor = Color(0xFF34C759)
|
|
||||||
val errorColor = Color(0xFFFF3B30)
|
|
||||||
val purpleColor = Color(0xFFAF52DE)
|
|
||||||
val heartbeatColor = Color(0xFFFF9500)
|
|
||||||
val messageColor = PrimaryBlue
|
|
||||||
|
|
||||||
// Определяем цвет по содержимому лога
|
|
||||||
val logColor = when {
|
|
||||||
log.contains("✅") || log.contains("SUCCESS") -> successColor
|
|
||||||
log.contains("❌") || log.contains("ERROR") || log.contains("FAILED") -> errorColor
|
|
||||||
log.contains("🔄") || log.contains("STATE") -> purpleColor
|
|
||||||
log.contains("💓") || log.contains("💔") -> heartbeatColor
|
|
||||||
log.contains("📥") || log.contains("📤") || log.contains("📨") -> messageColor
|
|
||||||
else -> textColor.copy(alpha = 0.85f)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = log,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = logColor,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 2.dp, horizontal = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package com.rosetta.messenger.ui.components.metaball
|
|||||||
import android.graphics.ColorMatrixColorFilter
|
import android.graphics.ColorMatrixColorFilter
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.util.Log
|
|
||||||
import android.graphics.RenderEffect
|
import android.graphics.RenderEffect
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -11,13 +11,10 @@ import android.view.Gravity
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -25,13 +22,11 @@ import androidx.compose.foundation.layout.offset
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -48,10 +43,10 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
|
|||||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only log in explicit debug mode to keep production scroll clean.
|
|
||||||
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
|
||||||
LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
|
|
||||||
if (debugLogsEnabled) {
|
|
||||||
Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
|
|
||||||
Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
|
val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
|
||||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
||||||
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||||
}
|
}
|
||||||
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
||||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||||
}
|
}
|
||||||
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
||||||
|
|
||||||
@@ -1162,153 +1148,6 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DEBUG: Temporary toggle to force a specific rendering path.
|
|
||||||
* Set forceMode to test different paths on your device:
|
|
||||||
* - null: auto-detect (default production behavior)
|
|
||||||
* - "gpu": force GPU path (requires API 31+)
|
|
||||||
* - "cpu": force CPU bitmap path
|
|
||||||
* - "compat": force compat/noop path
|
|
||||||
*
|
|
||||||
* Set forceNoNotch = true to simulate no-notch device (black bar fallback).
|
|
||||||
*
|
|
||||||
* TODO: Remove before release!
|
|
||||||
*/
|
|
||||||
object MetaballDebug {
|
|
||||||
var forceMode: String? = null // "gpu", "cpu", "compat", or null
|
|
||||||
var forceNoNotch: Boolean = false // true = pretend no notch exists
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DEBUG: Floating panel with buttons to switch metaball rendering path.
|
|
||||||
* Place inside a Box (e.g. profile header) — it aligns to bottom-center.
|
|
||||||
* TODO: Remove before release!
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MetaballDebugPanel(modifier: Modifier = Modifier) {
|
|
||||||
var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) }
|
|
||||||
var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) }
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val perfClass = remember { DevicePerformanceClass.get(context) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
.background(
|
|
||||||
ComposeColor.Black.copy(alpha = 0.75f),
|
|
||||||
RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
// Title
|
|
||||||
Text(
|
|
||||||
text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass",
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mode buttons row
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat")
|
|
||||||
modes.forEach { (mode, label) ->
|
|
||||||
val isSelected = currentMode == mode
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(
|
|
||||||
if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f)
|
|
||||||
)
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
.clickable {
|
|
||||||
MetaballDebug.forceMode = mode
|
|
||||||
currentMode = mode
|
|
||||||
}
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-notch toggle
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Force no-notch (black bar)",
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = noNotch,
|
|
||||||
onCheckedChange = {
|
|
||||||
MetaballDebug.forceNoNotch = it
|
|
||||||
noNotch = it
|
|
||||||
},
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = ComposeColor(0xFF4CAF50),
|
|
||||||
checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current active path info
|
|
||||||
val activePath = when (currentMode) {
|
|
||||||
"gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!"
|
|
||||||
"cpu" -> "CPU (forced)"
|
|
||||||
"compat" -> "Compat (forced)"
|
|
||||||
else -> when {
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
|
||||||
else -> "CPU (auto)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "Active: $activePath" + if (noNotch) " + no-notch" else "",
|
|
||||||
color = ComposeColor(0xFF4CAF50),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
|
|
||||||
// Notch detection info
|
|
||||||
val view = LocalView.current
|
|
||||||
val notchRes = remember { NotchInfoUtils.getInfo(context) }
|
|
||||||
val notchCutout = remember(view) { NotchInfoUtils.getInfoFromCutout(view) }
|
|
||||||
val notchSource = when {
|
|
||||||
notchRes != null -> "resource"
|
|
||||||
notchCutout != null -> "DisplayCutout"
|
|
||||||
else -> "NONE"
|
|
||||||
}
|
|
||||||
val activeNotch = notchRes ?: notchCutout
|
|
||||||
Text(
|
|
||||||
text = "Notch: $notchSource" +
|
|
||||||
if (activeNotch != null) " | ${activeNotch.bounds.width().toInt()}x${activeNotch.bounds.height().toInt()}" +
|
|
||||||
" circle=${activeNotch.isLikelyCircle}" else " (black bar fallback!)",
|
|
||||||
color = if (activeNotch != null) ComposeColor(0xFF4CAF50) else ComposeColor(0xFFFF5722),
|
|
||||||
fontSize = 10.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
|
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
|
||||||
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
||||||
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
||||||
|
|
||||||
// Debug: log which path is selected
|
|
||||||
val selectedPath = when (MetaballDebug.forceMode) {
|
|
||||||
"gpu" -> "GPU (forced)"
|
|
||||||
"cpu" -> "CPU (forced)"
|
|
||||||
"compat" -> "Compat (forced)"
|
|
||||||
else -> when {
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
|
||||||
else -> "CPU (auto)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
|
||||||
LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) {
|
|
||||||
if (debugLogsEnabled) {
|
|
||||||
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve actual mode
|
// Resolve actual mode
|
||||||
val useGpu = when (MetaballDebug.forceMode) {
|
val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
val useCpu = !useGpu
|
||||||
"cpu" -> false
|
|
||||||
"compat" -> false
|
|
||||||
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
|
||||||
}
|
|
||||||
val useCpu = when (MetaballDebug.forceMode) {
|
|
||||||
"gpu" -> false
|
|
||||||
"cpu" -> true
|
|
||||||
"compat" -> false
|
|
||||||
else -> !useGpu
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
useGpu -> {
|
useGpu -> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.rosetta.messenger.ui.crashlogs
|
package com.rosetta.messenger.ui.crashlogs
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -8,13 +9,16 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -263,6 +267,8 @@ private fun CrashDetailScreen(
|
|||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -274,6 +280,14 @@ private fun CrashDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
clipboardManager.setText(AnnotatedString(crashReport.content))
|
||||||
|
Toast.makeText(context, "Full log copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = "Copy Full Log")
|
||||||
|
}
|
||||||
IconButton(onClick = { /* TODO: Share */ }) {
|
IconButton(onClick = { /* TODO: Share */ }) {
|
||||||
Icon(Icons.Default.Share, contentDescription = "Share")
|
Icon(Icons.Default.Share, contentDescription = "Share")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ Stock `io.github.webrtc-sdk:android:125.6422.07` can call audio frame encryptor
|
|||||||
`additional_data` (`ad=0`), so nonce derivation based on timestamp is unavailable.
|
`additional_data` (`ad=0`), so nonce derivation based on timestamp is unavailable.
|
||||||
|
|
||||||
Desktop uses frame timestamp for nonce. This patch aligns Android with that approach by passing
|
Desktop uses frame timestamp for nonce. This patch aligns Android with that approach by passing
|
||||||
an 8-byte big-endian timestamp payload in `additional_data`:
|
an 8-byte big-endian timestamp payload in `additional_data` (absolute RTP timestamp,
|
||||||
|
including sender start offset):
|
||||||
|
|
||||||
- bytes `0..3` = `0`
|
- bytes `0..3` = `0`
|
||||||
- bytes `4..7` = RTP timestamp (big-endian)
|
- bytes `4..7` = RTP timestamp (big-endian)
|
||||||
@@ -18,10 +19,14 @@ an 8-byte big-endian timestamp payload in `additional_data`:
|
|||||||
|
|
||||||
- `build_custom_webrtc.sh` — reproducible build script
|
- `build_custom_webrtc.sh` — reproducible build script
|
||||||
- `patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch` — WebRTC patch
|
- `patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch` — WebRTC patch
|
||||||
|
- `patches/0002-android-build-on-mac-host.patch` — allows Android target build on macOS host
|
||||||
|
- `patches/0003-macos-host-java-ijar.patch` — enables host tools (`ijar`/`jdk`) on macOS
|
||||||
|
- `patches/0004-macos-linker-missing-L-dirs.patch` — skips invalid host `-L...` paths for lld
|
||||||
|
- `patches/0005-macos-server-utils-socket.patch` — handles macOS socket errno in Android Java compile helper
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Recommended on Linux (macOS can work but is less predictable for long WebRTC builds).
|
Recommended on Linux (macOS is supported via additional patches in this folder).
|
||||||
|
|
||||||
Bootstrap `depot_tools` first:
|
Bootstrap `depot_tools` first:
|
||||||
|
|
||||||
@@ -47,6 +52,7 @@ Optional env vars:
|
|||||||
- `SYNC_JOBS` — `gclient sync` jobs (default: `1`, safer for googlesource limits)
|
- `SYNC_JOBS` — `gclient sync` jobs (default: `1`, safer for googlesource limits)
|
||||||
- `SYNC_RETRIES` — sync retry attempts (default: `8`)
|
- `SYNC_RETRIES` — sync retry attempts (default: `8`)
|
||||||
- `SYNC_RETRY_BASE_SEC` — base retry delay in seconds (default: `20`)
|
- `SYNC_RETRY_BASE_SEC` — base retry delay in seconds (default: `20`)
|
||||||
|
- `MAC_ANDROID_NDK_ROOT` — local Android NDK path on macOS (default: `~/Library/Android/sdk/ndk/27.1.12297006`)
|
||||||
|
|
||||||
## Troubleshooting (HTTP 429 / RESOURCE_EXHAUSTED)
|
## Troubleshooting (HTTP 429 / RESOURCE_EXHAUSTED)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROSETTA_ANDROID_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
ROSETTA_ANDROID_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
PATCH_FILE="${SCRIPT_DIR}/patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch"
|
PATCH_FILES=(
|
||||||
|
"${SCRIPT_DIR}/patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch"
|
||||||
|
"${SCRIPT_DIR}/patches/0002-android-build-on-mac-host.patch"
|
||||||
|
"${SCRIPT_DIR}/patches/0003-macos-host-java-ijar.patch"
|
||||||
|
"${SCRIPT_DIR}/patches/0004-macos-linker-missing-L-dirs.patch"
|
||||||
|
"${SCRIPT_DIR}/patches/0005-macos-server-utils-socket.patch"
|
||||||
|
)
|
||||||
|
|
||||||
# Default target: WebRTC M125 family used by app dependency 125.6422.07.
|
# Default target: WebRTC M125 family used by app dependency 125.6422.07.
|
||||||
WEBRTC_BRANCH="${WEBRTC_BRANCH:-branch-heads/6422}"
|
WEBRTC_BRANCH="${WEBRTC_BRANCH:-branch-heads/6422}"
|
||||||
@@ -132,21 +138,63 @@ sync_with_retry
|
|||||||
|
|
||||||
echo "[webrtc-custom] applying Rosetta patch..."
|
echo "[webrtc-custom] applying Rosetta patch..."
|
||||||
git reset --hard
|
git reset --hard
|
||||||
git apply --check "${PATCH_FILE}"
|
for patch in "${PATCH_FILES[@]}"; do
|
||||||
git apply "${PATCH_FILE}"
|
echo "[webrtc-custom] apply $(basename "${patch}")"
|
||||||
|
git apply --check "${patch}"
|
||||||
|
git apply "${patch}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# macOS host tweaks:
|
||||||
|
# - point third_party/jdk/current to local JDK
|
||||||
|
# - use locally installed Android NDK (darwin toolchain)
|
||||||
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||||
|
if [[ -z "${JAVA_HOME:-}" ]]; then
|
||||||
|
JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${JAVA_HOME:-}" || ! -d "${JAVA_HOME}" ]]; then
|
||||||
|
echo "[webrtc-custom] ERROR: JAVA_HOME not found on macOS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
JAVA_HOME_CANDIDATE="${JAVA_HOME}"
|
||||||
|
if [[ ! -f "${JAVA_HOME_CANDIDATE}/conf/logging.properties" ]] && [[ -d "${JAVA_HOME_CANDIDATE}/libexec/openjdk.jdk/Contents/Home" ]]; then
|
||||||
|
JAVA_HOME_CANDIDATE="${JAVA_HOME_CANDIDATE}/libexec/openjdk.jdk/Contents/Home"
|
||||||
|
fi
|
||||||
|
if [[ ! -f "${JAVA_HOME_CANDIDATE}/conf/logging.properties" ]]; then
|
||||||
|
echo "[webrtc-custom] ERROR: invalid JAVA_HOME (conf/logging.properties not found): ${JAVA_HOME}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
JAVA_HOME="${JAVA_HOME_CANDIDATE}"
|
||||||
|
ln -sfn "${JAVA_HOME}" "${WEBRTC_SRC}/third_party/jdk/current"
|
||||||
|
echo "[webrtc-custom] macOS JDK linked: ${WEBRTC_SRC}/third_party/jdk/current -> ${JAVA_HOME}"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$(dirname "${OUT_AAR}")"
|
mkdir -p "$(dirname "${OUT_AAR}")"
|
||||||
|
|
||||||
echo "[webrtc-custom] building AAR (this can take a while)..."
|
echo "[webrtc-custom] building AAR (this can take a while)..."
|
||||||
|
GN_ARGS=(
|
||||||
|
is_debug=false
|
||||||
|
is_component_build=false
|
||||||
|
rtc_include_tests=false
|
||||||
|
rtc_build_examples=false
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||||
|
MAC_ANDROID_NDK_ROOT="${MAC_ANDROID_NDK_ROOT:-$HOME/Library/Android/sdk/ndk/27.1.12297006}"
|
||||||
|
if [[ ! -d "${MAC_ANDROID_NDK_ROOT}" ]]; then
|
||||||
|
echo "[webrtc-custom] ERROR: Android NDK not found at ${MAC_ANDROID_NDK_ROOT}"
|
||||||
|
echo "[webrtc-custom] Set MAC_ANDROID_NDK_ROOT to your local NDK path."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
GN_ARGS+=("android_ndk_root=\"${MAC_ANDROID_NDK_ROOT}\"")
|
||||||
|
GN_ARGS+=("android_ndk_version=\"27.1.12297006\"")
|
||||||
|
echo "[webrtc-custom] macOS Android NDK: ${MAC_ANDROID_NDK_ROOT}"
|
||||||
|
fi
|
||||||
|
|
||||||
python3 tools_webrtc/android/build_aar.py \
|
python3 tools_webrtc/android/build_aar.py \
|
||||||
--build-dir out_rosetta_aar \
|
--build-dir out_rosetta_aar \
|
||||||
--output "${OUT_AAR}" \
|
--output "${OUT_AAR}" \
|
||||||
--arch "${ARCHS[@]}" \
|
--arch "${ARCHS[@]}" \
|
||||||
--extra-gn-args \
|
--extra-gn-args "${GN_ARGS[@]}"
|
||||||
is_debug=false \
|
|
||||||
is_component_build=false \
|
|
||||||
rtc_include_tests=false \
|
|
||||||
rtc_build_examples=false
|
|
||||||
|
|
||||||
echo "[webrtc-custom] done"
|
echo "[webrtc-custom] done"
|
||||||
echo "[webrtc-custom] AAR: ${OUT_AAR}"
|
echo "[webrtc-custom] AAR: ${OUT_AAR}"
|
||||||
|
|||||||
@@ -25,22 +25,24 @@ index 17cf859ed8..b9d9ab14c8 100644
|
|||||||
decrypted_audio_payload);
|
decrypted_audio_payload);
|
||||||
|
|
||||||
diff --git a/audio/channel_send.cc b/audio/channel_send.cc
|
diff --git a/audio/channel_send.cc b/audio/channel_send.cc
|
||||||
index 4a2700177b..93283c2e78 100644
|
index 4a2700177b..7ebb501704 100644
|
||||||
--- a/audio/channel_send.cc
|
--- a/audio/channel_send.cc
|
||||||
+++ b/audio/channel_send.cc
|
+++ b/audio/channel_send.cc
|
||||||
@@ -320,10 +320,21 @@ int32_t ChannelSend::SendRtpAudio(AudioFrameType frameType,
|
@@ -320,10 +320,23 @@ int32_t ChannelSend::SendRtpAudio(AudioFrameType frameType,
|
||||||
|
|
||||||
// Encrypt the audio payload into the buffer.
|
// Encrypt the audio payload into the buffer.
|
||||||
size_t bytes_written = 0;
|
size_t bytes_written = 0;
|
||||||
|
+ const uint32_t additional_data_timestamp =
|
||||||
|
+ rtp_timestamp_without_offset + rtp_rtcp_->StartTimestamp();
|
||||||
+ const uint8_t additional_data_bytes[8] = {
|
+ const uint8_t additional_data_bytes[8] = {
|
||||||
+ 0,
|
+ 0,
|
||||||
+ 0,
|
+ 0,
|
||||||
+ 0,
|
+ 0,
|
||||||
+ 0,
|
+ 0,
|
||||||
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 24) & 0xff),
|
+ static_cast<uint8_t>((additional_data_timestamp >> 24) & 0xff),
|
||||||
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 16) & 0xff),
|
+ static_cast<uint8_t>((additional_data_timestamp >> 16) & 0xff),
|
||||||
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 8) & 0xff),
|
+ static_cast<uint8_t>((additional_data_timestamp >> 8) & 0xff),
|
||||||
+ static_cast<uint8_t>(rtp_timestamp_without_offset & 0xff),
|
+ static_cast<uint8_t>(additional_data_timestamp & 0xff),
|
||||||
+ };
|
+ };
|
||||||
+
|
+
|
||||||
int encrypt_status = frame_encryptor_->Encrypt(
|
int encrypt_status = frame_encryptor_->Encrypt(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
diff --git a/build/config/BUILDCONFIG.gn b/build/config/BUILDCONFIG.gn
|
||||||
|
index 26fad5adf..7a614f334 100644
|
||||||
|
--- a/build/config/BUILDCONFIG.gn
|
||||||
|
+++ b/build/config/BUILDCONFIG.gn
|
||||||
|
@@ -239,7 +239,8 @@ if (host_toolchain == "") {
|
||||||
|
_default_toolchain = ""
|
||||||
|
|
||||||
|
if (target_os == "android") {
|
||||||
|
- assert(host_os == "linux", "Android builds are only supported on Linux.")
|
||||||
|
+ assert(host_os == "linux" || host_os == "mac",
|
||||||
|
+ "Android builds are only supported on Linux/macOS.")
|
||||||
|
_default_toolchain = "//build/toolchain/android:android_clang_$target_cpu"
|
||||||
|
} else if (target_os == "chromeos" || target_os == "linux") {
|
||||||
|
# See comments in build/toolchain/cros/BUILD.gn about board compiles.
|
||||||
|
diff --git a/build/config/android/config.gni b/build/config/android/config.gni
|
||||||
|
index 427739d70..6a5ab0594 100644
|
||||||
|
--- a/build/config/android/config.gni
|
||||||
|
+++ b/build/config/android/config.gni
|
||||||
|
@@ -327,7 +327,7 @@ if (is_android || is_chromeos) {
|
||||||
|
|
||||||
|
# Defines the name the Android build gives to the current host CPU
|
||||||
|
# architecture, which is different than the names GN uses.
|
||||||
|
- if (host_cpu == "x64") {
|
||||||
|
+ if (host_cpu == "x64" || host_cpu == "arm64") {
|
||||||
|
android_host_arch = "x86_64"
|
||||||
|
} else if (host_cpu == "x86") {
|
||||||
|
android_host_arch = "x86"
|
||||||
34
tools/webrtc-custom/patches/0003-macos-host-java-ijar.patch
Normal file
34
tools/webrtc-custom/patches/0003-macos-host-java-ijar.patch
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
diff --git a/third_party/ijar/BUILD.gn b/third_party/ijar/BUILD.gn
|
||||||
|
index 8dc9fe21cf8..49c50e6636f 100644
|
||||||
|
--- a/third_party/ijar/BUILD.gn
|
||||||
|
+++ b/third_party/ijar/BUILD.gn
|
||||||
|
@@ -4,7 +4,7 @@
|
||||||
|
|
||||||
|
# A tool that removes all non-interface-specific parts from a .jar file.
|
||||||
|
|
||||||
|
-if (is_linux || is_chromeos) {
|
||||||
|
+if (is_linux || is_chromeos || is_mac) {
|
||||||
|
config("ijar_compiler_flags") {
|
||||||
|
if (is_clang) {
|
||||||
|
cflags = [
|
||||||
|
diff --git a/third_party/jdk/BUILD.gn b/third_party/jdk/BUILD.gn
|
||||||
|
index e003eef94d7..ec49922942b 100644
|
||||||
|
--- a/third_party/jdk/BUILD.gn
|
||||||
|
+++ b/third_party/jdk/BUILD.gn
|
||||||
|
@@ -3,10 +3,12 @@
|
||||||
|
# found in the LICENSE file.
|
||||||
|
|
||||||
|
config("jdk") {
|
||||||
|
- include_dirs = [
|
||||||
|
- "current/include",
|
||||||
|
- "current/include/linux",
|
||||||
|
- ]
|
||||||
|
+ include_dirs = [ "current/include" ]
|
||||||
|
+ if (host_os == "mac") {
|
||||||
|
+ include_dirs += [ "current/include/darwin" ]
|
||||||
|
+ } else {
|
||||||
|
+ include_dirs += [ "current/include/linux" ]
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
|
||||||
|
group("java_data") {
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
diff --git a/build/toolchain/apple/linker_driver.py b/build/toolchain/apple/linker_driver.py
|
||||||
|
index 0632230cf..798442534 100755
|
||||||
|
--- a/build/toolchain/apple/linker_driver.py
|
||||||
|
+++ b/build/toolchain/apple/linker_driver.py
|
||||||
|
@@ -7,6 +7,7 @@
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
+import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
@@ -113,6 +114,53 @@ class LinkerDriver(object):
|
||||||
|
# The temporary directory for intermediate LTO object files. If it
|
||||||
|
# exists, it will clean itself up on script exit.
|
||||||
|
self._object_path_lto = None
|
||||||
|
+ self._temp_rsp_files = []
|
||||||
|
+
|
||||||
|
+ def _sanitize_rsp_arg(self, arg):
|
||||||
|
+ if not arg.startswith('@'):
|
||||||
|
+ return arg
|
||||||
|
+ rsp_path = arg[1:]
|
||||||
|
+ if not os.path.isfile(rsp_path):
|
||||||
|
+ return arg
|
||||||
|
+
|
||||||
|
+ try:
|
||||||
|
+ with open(rsp_path, 'r', encoding='utf-8') as f:
|
||||||
|
+ rsp_content = f.read()
|
||||||
|
+ except OSError:
|
||||||
|
+ return arg
|
||||||
|
+
|
||||||
|
+ tokens = shlex.split(rsp_content, posix=True)
|
||||||
|
+ sanitized = []
|
||||||
|
+ changed = False
|
||||||
|
+ i = 0
|
||||||
|
+ while i < len(tokens):
|
||||||
|
+ tok = tokens[i]
|
||||||
|
+ if tok == '-L' and i + 1 < len(tokens):
|
||||||
|
+ lib_dir = tokens[i + 1]
|
||||||
|
+ if not os.path.isdir(lib_dir):
|
||||||
|
+ changed = True
|
||||||
|
+ i += 2
|
||||||
|
+ continue
|
||||||
|
+ elif tok.startswith('-L') and len(tok) > 2:
|
||||||
|
+ lib_dir = tok[2:]
|
||||||
|
+ if not os.path.isdir(lib_dir):
|
||||||
|
+ changed = True
|
||||||
|
+ i += 1
|
||||||
|
+ continue
|
||||||
|
+ sanitized.append(tok)
|
||||||
|
+ i += 1
|
||||||
|
+
|
||||||
|
+ if not changed:
|
||||||
|
+ return arg
|
||||||
|
+
|
||||||
|
+ fd, temp_path = tempfile.mkstemp(prefix='linker_driver_', suffix='.rsp')
|
||||||
|
+ os.close(fd)
|
||||||
|
+ with open(temp_path, 'w', encoding='utf-8') as f:
|
||||||
|
+ for tok in sanitized:
|
||||||
|
+ f.write(tok)
|
||||||
|
+ f.write('\n')
|
||||||
|
+ self._temp_rsp_files.append(temp_path)
|
||||||
|
+ return '@' + temp_path
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Runs the linker driver, separating out the main compiler driver's
|
||||||
|
@@ -135,11 +183,25 @@ class LinkerDriver(object):
|
||||||
|
assert driver_action[0] not in linker_driver_actions
|
||||||
|
linker_driver_actions[driver_action[0]] = driver_action[1]
|
||||||
|
else:
|
||||||
|
+ if arg.startswith('@'):
|
||||||
|
+ arg = self._sanitize_rsp_arg(arg)
|
||||||
|
# TODO(crbug.com/1446796): On Apple, the linker command line
|
||||||
|
# produced by rustc for LTO includes these arguments, but the
|
||||||
|
# Apple linker doesn't accept them.
|
||||||
|
# Upstream bug: https://github.com/rust-lang/rust/issues/60059
|
||||||
|
BAD_RUSTC_ARGS = '-Wl,-plugin-opt=O[0-9],-plugin-opt=mcpu=.*'
|
||||||
|
+ if arg == '-Wl,-fatal_warnings':
|
||||||
|
+ # Some host link steps on Apple Silicon produce benign
|
||||||
|
+ # warnings from injected search paths (e.g. /usr/local/lib
|
||||||
|
+ # missing). Don't fail the whole build on those warnings.
|
||||||
|
+ continue
|
||||||
|
+ if arg.startswith('-L') and len(arg) > 2:
|
||||||
|
+ # Some environments inject non-existent library search
|
||||||
|
+ # paths (e.g. /usr/local/lib on Apple Silicon). lld treats
|
||||||
|
+ # them as hard errors, so skip missing -L entries.
|
||||||
|
+ lib_dir = arg[2:]
|
||||||
|
+ if not os.path.isdir(lib_dir):
|
||||||
|
+ continue
|
||||||
|
if not re.match(BAD_RUSTC_ARGS, arg):
|
||||||
|
compiler_driver_args.append(arg)
|
||||||
|
|
||||||
|
@@ -185,6 +247,9 @@ class LinkerDriver(object):
|
||||||
|
|
||||||
|
# Re-report the original failure.
|
||||||
|
raise
|
||||||
|
+ finally:
|
||||||
|
+ for path in self._temp_rsp_files:
|
||||||
|
+ _remove_path(path)
|
||||||
|
|
||||||
|
def _get_linker_output(self):
|
||||||
|
"""Returns the value of the output argument to the linker."""
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
diff --git a/build/android/gyp/util/server_utils.py b/build/android/gyp/util/server_utils.py
|
||||||
|
index 6d5ed79d3..c05b57529 100644
|
||||||
|
--- a/build/android/gyp/util/server_utils.py
|
||||||
|
+++ b/build/android/gyp/util/server_utils.py
|
||||||
|
@@ -36,7 +36,9 @@ def MaybeRunCommand(name, argv, stamp_file, force):
|
||||||
|
except socket.error as e:
|
||||||
|
# [Errno 111] Connection refused. Either the server has not been started
|
||||||
|
# or the server is not currently accepting new connections.
|
||||||
|
- if e.errno == 111:
|
||||||
|
+ # [Errno 2] Abstract Unix sockets are unsupported on macOS, so treat
|
||||||
|
+ # this the same way (build server unavailable).
|
||||||
|
+ if e.errno in (111, 2):
|
||||||
|
if force:
|
||||||
|
raise RuntimeError(
|
||||||
|
'\n\nBuild server is not running and '
|
||||||
Reference in New Issue
Block a user