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
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.3.1"
|
||||
val rosettaVersionCode = 33 // Increment on each release
|
||||
val rosettaVersionName = "1.3.2"
|
||||
val rosettaVersionCode = 34 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <time.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <android/log.h>
|
||||
@@ -34,6 +35,10 @@
|
||||
|
||||
static char g_diag_path[512] = {0};
|
||||
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, ...) {
|
||||
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);
|
||||
}
|
||||
|
||||
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) ───── */
|
||||
|
||||
struct ParsedRtpPacket {
|
||||
@@ -72,6 +121,17 @@ struct GeneratedTsState {
|
||||
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) {
|
||||
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]);
|
||||
}
|
||||
|
||||
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) {
|
||||
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_sequence = packet.sequence;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -151,8 +224,12 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
|
||||
state->ssrc = packet.ssrc;
|
||||
state->last_sequence = packet.sequence;
|
||||
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 {
|
||||
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->has_probe = true;
|
||||
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;
|
||||
} else if (seq_delta != 0) {
|
||||
// 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->has_probe = true;
|
||||
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,
|
||||
size_t len,
|
||||
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_relative_ts) *used_relative_ts = false;
|
||||
if (!data || len < 8) return false;
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -218,6 +322,18 @@ static bool fill_nonce_from_additional_data(const uint8_t* data,
|
||||
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]) {
|
||||
nonce[4] = (uint8_t)(ts >> 24);
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
// RFC 6716 TOC config mapping at 48 kHz.
|
||||
if (config <= 11) {
|
||||
@@ -263,18 +398,94 @@ static uint32_t infer_opus_packet_duration_samples(const uint8_t* packet, size_t
|
||||
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) {
|
||||
if (!packet || len == 0 || len > 2000) return false;
|
||||
|
||||
const uint8_t toc = packet[0];
|
||||
const uint8_t config = (uint8_t)(toc >> 3);
|
||||
if (config > 31) return false;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
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 ──────── */
|
||||
@@ -314,8 +525,13 @@ class XChaCha20Encryptor final : public webrtc::FrameEncryptorInterface {
|
||||
public:
|
||||
explicit XChaCha20Encryptor(const uint8_t 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 ─────────────────────────────────────── */
|
||||
void AddRef() const override {
|
||||
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
|
||||
* and encrypt only payload (desktop-compatible).
|
||||
*/
|
||||
int Encrypt(cricket::MediaType /*media_type*/,
|
||||
uint32_t /*ssrc*/,
|
||||
int Encrypt(cricket::MediaType media_type,
|
||||
uint32_t ssrc,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> frame,
|
||||
rtc::ArrayView<uint8_t> encrypted_frame,
|
||||
@@ -360,15 +576,19 @@ public:
|
||||
bool nonce_from_generated_ts = false;
|
||||
bool nonce_from_additional_data = false;
|
||||
bool additional_was_rtp_header = false;
|
||||
bool additional_used_mono_offset = false;
|
||||
uint32_t generated_ts_used = 0;
|
||||
|
||||
// Build nonce from RTP timestamp in additional_data (preferred).
|
||||
uint8_t nonce[24] = {0};
|
||||
bool additional_used_relative_ts = false;
|
||||
nonce_from_additional_data = fill_nonce_from_additional_data(
|
||||
additional_data.data(),
|
||||
additional_data.size(),
|
||||
nonce,
|
||||
&additional_was_rtp_header);
|
||||
&additional_was_rtp_header,
|
||||
nullptr,
|
||||
&additional_used_relative_ts);
|
||||
if (!nonce_from_additional_data) {
|
||||
nonce_from_rtp_header =
|
||||
fill_nonce_from_rtp_frame(frame.data(), frame.size(), &rtp_probe_, nonce, &header_size);
|
||||
@@ -381,26 +601,41 @@ public:
|
||||
nonce_from_generated_ts = true;
|
||||
generated_ts_used = generated_ts_.next_timestamp;
|
||||
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()) {
|
||||
// Keep RTP header clear, encrypt payload only.
|
||||
if (header_size > 0) {
|
||||
memcpy(encrypted_frame.data(), frame.data(), header_size);
|
||||
// Some Android sender pipelines expose stream-relative ad8 timestamps
|
||||
// (0, 960, 1920, ...), while desktop receiver expects an absolute base.
|
||||
// For interop, add a monotonic 48k offset once when first ad8 is tiny.
|
||||
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();
|
||||
|
||||
if (nonce_from_generated_ts) {
|
||||
@@ -409,23 +644,48 @@ public:
|
||||
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);
|
||||
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 =
|
||||
nonce_from_rtp_header
|
||||
? "rtp"
|
||||
: (nonce_from_generated_ts
|
||||
? "gen"
|
||||
: (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"));
|
||||
LOGI("ENC frame#%d: sz=%zu ad=%zu hdr=%zu mode=%s nonce=%02x%02x%02x%02x",
|
||||
n, frame.size(), additional_data.size(), header_size, mode,
|
||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
||||
diag_write("ENC frame#%d: sz=%zu ad=%zu hdr=%zu mode=%s nonce[4..7]=%02x%02x%02x%02x\n",
|
||||
n, frame.size(), additional_data.size(), header_size, mode,
|
||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
||||
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, 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]);
|
||||
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;
|
||||
}
|
||||
@@ -435,13 +695,18 @@ public:
|
||||
}
|
||||
|
||||
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:
|
||||
mutable std::atomic<int> ref_{0};
|
||||
mutable std::atomic<int> diag_count_{0};
|
||||
mutable RtpProbeState rtp_probe_;
|
||||
mutable GeneratedTsState generated_ts_;
|
||||
mutable SenderTsOffsetState sender_ts_offset_;
|
||||
uint32_t key_fingerprint_ = 0;
|
||||
uint8_t key_[32];
|
||||
};
|
||||
|
||||
@@ -453,8 +718,13 @@ class XChaCha20Decryptor final : public webrtc::FrameDecryptorInterface {
|
||||
public:
|
||||
explicit XChaCha20Decryptor(const uint8_t 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 ─────────────────────────────────────── */
|
||||
void AddRef() const override {
|
||||
ref_.fetch_add(1, std::memory_order_relaxed);
|
||||
@@ -474,8 +744,8 @@ public:
|
||||
* - if RTP header is present inside encrypted_frame (fallback path),
|
||||
* keep header bytes untouched and decrypt payload only.
|
||||
*/
|
||||
Result Decrypt(cricket::MediaType /*media_type*/,
|
||||
const std::vector<uint32_t>& /*csrcs*/,
|
||||
Result Decrypt(cricket::MediaType media_type,
|
||||
const std::vector<uint32_t>& csrcs,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||
rtc::ArrayView<uint8_t> frame) override {
|
||||
@@ -485,12 +755,17 @@ public:
|
||||
bool nonce_from_generated_ts = false;
|
||||
bool nonce_from_additional_data = 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;
|
||||
nonce_from_additional_data = fill_nonce_from_additional_data(
|
||||
additional_data.data(),
|
||||
additional_data.size(),
|
||||
nonce,
|
||||
&additional_was_rtp_header);
|
||||
&additional_was_rtp_header,
|
||||
nullptr,
|
||||
&additional_used_relative_ts);
|
||||
if (!nonce_from_additional_data) {
|
||||
nonce_from_rtp_header =
|
||||
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;
|
||||
generated_ts_used = generated_ts_.next_timestamp;
|
||||
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;
|
||||
|
||||
if (nonce_from_rtp_header && header_size <= encrypted_frame.size()) {
|
||||
if (header_size > 0) {
|
||||
memcpy(frame.data(), encrypted_frame.data(), header_size);
|
||||
// Desktop createEncodedStreams decrypts full encoded chunk.
|
||||
rosetta_xchacha20_xor(frame.data(), encrypted_frame.data(), encrypted_frame.size(), nonce, key_);
|
||||
|
||||
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) {
|
||||
@@ -548,6 +860,9 @@ public:
|
||||
generated_ts_used = ts_try;
|
||||
used_generated_resync = 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;
|
||||
}
|
||||
}
|
||||
@@ -558,25 +873,68 @@ public:
|
||||
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);
|
||||
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;
|
||||
if (nonce_from_rtp_header) {
|
||||
mode = "rtp";
|
||||
} else if (nonce_from_generated_ts) {
|
||||
mode = used_generated_resync ? "gen-resync" : "gen";
|
||||
} 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 {
|
||||
mode = "raw-abs";
|
||||
}
|
||||
LOGI("DEC frame#%d: enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce=%02x%02x%02x%02x",
|
||||
n, encrypted_frame.size(), additional_data.size(), header_size, mode,
|
||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
||||
diag_write("DEC frame#%d: enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce[4..7]=%02x%02x%02x%02x\n",
|
||||
n, encrypted_frame.size(), additional_data.size(), header_size, mode,
|
||||
nonce[4], nonce[5], nonce[6], nonce[7]);
|
||||
uint32_t bad_streak = 0;
|
||||
if (!dec_plausible) {
|
||||
bad_streak = bad_audio_streak_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
if (bad_streak == 1 || bad_streak == 3 || bad_streak == 10 || (bad_streak % 50) == 0) {
|
||||
diag_event("DEC degraded mt=%s csrcs=%zu mode=%s bad_streak=%u nonce_ts=%u key_fp=%08x\n",
|
||||
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()};
|
||||
@@ -587,13 +945,20 @@ public:
|
||||
}
|
||||
|
||||
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:
|
||||
mutable std::atomic<int> ref_{0};
|
||||
mutable std::atomic<int> diag_count_{0};
|
||||
mutable std::atomic<uint32_t> bad_audio_streak_{0};
|
||||
mutable RtpProbeState rtp_probe_;
|
||||
mutable GeneratedTsState generated_ts_;
|
||||
mutable AdditionalTsState additional_rel_ts_state_;
|
||||
uint32_t key_fingerprint_ = 0;
|
||||
uint8_t key_[32];
|
||||
};
|
||||
|
||||
@@ -633,9 +998,14 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateEncryptor(
|
||||
JNIEnv *env, jclass, jbyteArray 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);
|
||||
const uint32_t key_fp = key_fingerprint32(key);
|
||||
auto *enc = new XChaCha20Encryptor(key);
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -653,6 +1024,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseEncryptor(
|
||||
{
|
||||
if (ptr == 0) return;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -663,15 +1036,21 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateDecryptor(
|
||||
JNIEnv *env, jclass, jbyteArray 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);
|
||||
const uint32_t key_fp = key_fingerprint32(key);
|
||||
auto *dec = new XChaCha20Decryptor(key);
|
||||
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -681,6 +1060,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseDecryptor(
|
||||
{
|
||||
if (ptr == 0) return;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -712,6 +1093,7 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeOpenDiagFile(
|
||||
JNIEnv *env, jclass, jstring jPath)
|
||||
{
|
||||
if (g_diag_fd >= 0) { close(g_diag_fd); g_diag_fd = -1; }
|
||||
g_diag_event_count.store(0, std::memory_order_relaxed);
|
||||
|
||||
const char *path = env->GetStringUTFChars(jPath, nullptr);
|
||||
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);
|
||||
|
||||
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);
|
||||
diag_event("DIAG open path=%s\n", g_diag_path);
|
||||
} else {
|
||||
LOGE("Failed to open diag file: %s", g_diag_path);
|
||||
}
|
||||
@@ -731,6 +1114,7 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCloseDiagFile(
|
||||
JNIEnv *, jclass)
|
||||
{
|
||||
if (g_diag_fd >= 0) {
|
||||
diag_event("DIAG close path=%s\n", g_diag_path);
|
||||
diag_write("=== END ===\n");
|
||||
close(g_diag_fd);
|
||||
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.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
|
||||
/**
|
||||
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
||||
@@ -109,20 +109,4 @@ fun AnimatedKeyboardTransition(
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Алиас для обратной совместимости
|
||||
*/
|
||||
@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 androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
|
||||
var currentState by mutableStateOf(TransitionState.IDLE)
|
||||
private set
|
||||
|
||||
var transitionProgress by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
// ============ Высоты ============
|
||||
|
||||
var keyboardHeight by mutableStateOf(0.dp)
|
||||
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
|
||||
// Используется для отключения imePadding пока Box виден
|
||||
var isEmojiBoxVisible by mutableStateOf(false)
|
||||
|
||||
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
||||
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
||||
|
||||
// 📊 Для умного логирования (не каждый фрейм)
|
||||
private var lastLogTime = 0L
|
||||
private var lastLoggedHeight = -1f
|
||||
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
|
||||
currentState = TransitionState.IDLE
|
||||
isTransitioning = false
|
||||
|
||||
// Очищаем pending callback - больше не нужен
|
||||
pendingShowEmojiCallback = null
|
||||
}
|
||||
|
||||
// ============ Главный метод: Emoji → Keyboard ============
|
||||
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
|
||||
* плавно скрыть emoji.
|
||||
*/
|
||||
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
||||
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
|
||||
if (pendingShowEmojiCallback != null) {
|
||||
pendingShowEmojiCallback = null
|
||||
}
|
||||
|
||||
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
||||
isTransitioning = true
|
||||
|
||||
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
|
||||
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
||||
}
|
||||
|
||||
/** Обновить высоту emoji панели. */
|
||||
fun updateEmojiHeight(height: Dp) {
|
||||
if (height > 0.dp && height != emojiHeight) {
|
||||
emojiHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизировать высоты (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'а. */
|
||||
|
||||
@@ -17,9 +17,10 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Оптимизация sync и protocol logging
|
||||
- Устранены лаги при CONNECTING/SYNCING: heartbeat-логи ограничены и больше не забивают UI
|
||||
- Добавлен fail-safe для handshake state: поврежденное/неизвестное значение больше не трактуется как успешный handshake
|
||||
Защищенные звонки и диагностика E2EE
|
||||
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
|
||||
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
|
||||
- В Crash Reports добавлена кнопка копирования полного лога одним действием
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -87,6 +88,12 @@ object CallManager {
|
||||
private const val TAG = "CallManager"
|
||||
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
|
||||
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 secureRandom = SecureRandom()
|
||||
@@ -103,6 +110,11 @@ object CallManager {
|
||||
private var roomId: String = ""
|
||||
private var offerSent = 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 localPublicKey: ByteArray? = null
|
||||
@@ -124,8 +136,12 @@ object CallManager {
|
||||
|
||||
// E2EE (XChaCha20 — compatible with Desktop)
|
||||
private var sharedKeyBytes: ByteArray? = null
|
||||
private var senderEncryptor: XChaCha20E2EE.Encryptor? = null
|
||||
private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null
|
||||
private val senderEncryptors = LinkedHashMap<String, XChaCha20E2EE.Encryptor>()
|
||||
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()
|
||||
|
||||
@@ -176,7 +192,9 @@ object CallManager {
|
||||
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
||||
|
||||
resetSession(reason = null, notifyPeer = false)
|
||||
beginCallSession("outgoing:${targetKey.take(8)}")
|
||||
role = CallRole.CALLER
|
||||
generateSessionKeys()
|
||||
setPeer(targetKey, user.title, user.username)
|
||||
updateState {
|
||||
it.copy(
|
||||
@@ -190,6 +208,7 @@ object CallManager {
|
||||
src = ownPublicKey,
|
||||
dst = targetKey
|
||||
)
|
||||
breadcrumbState("startOutgoingCall")
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
||||
return CallActionResult.STARTED
|
||||
}
|
||||
@@ -210,6 +229,7 @@ object CallManager {
|
||||
dst = snapshot.peerPublicKey,
|
||||
sharedPublic = localPublic.toHex()
|
||||
)
|
||||
keyExchangeSent = true
|
||||
|
||||
updateState {
|
||||
it.copy(
|
||||
@@ -217,6 +237,7 @@ object CallManager {
|
||||
statusText = "Exchanging keys..."
|
||||
)
|
||||
}
|
||||
breadcrumbState("acceptIncomingCall")
|
||||
return CallActionResult.STARTED
|
||||
}
|
||||
|
||||
@@ -308,6 +329,7 @@ object CallManager {
|
||||
}
|
||||
val incomingPeer = packet.src.trim()
|
||||
if (incomingPeer.isBlank()) return
|
||||
beginCallSession("incoming:${incomingPeer.take(8)}")
|
||||
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
|
||||
role = CallRole.CALLEE
|
||||
resetRtcObjects()
|
||||
@@ -359,30 +381,45 @@ object CallManager {
|
||||
breadcrumb("KE: ABORT — sharedPublic blank")
|
||||
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)}…")
|
||||
lastPeerSharedPublicHex = peerPublicHex
|
||||
|
||||
if (role == CallRole.CALLER) {
|
||||
generateSessionKeys()
|
||||
if (localPrivateKey == null || localPublicKey == null) {
|
||||
breadcrumb("KE: CALLER — generating session keys (were null)")
|
||||
generateSessionKeys()
|
||||
}
|
||||
val sharedKey = computeSharedSecretHex(peerPublicHex)
|
||||
if (sharedKey == null) {
|
||||
breadcrumb("KE: CALLER — computeSharedSecret FAILED")
|
||||
return
|
||||
}
|
||||
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...") }
|
||||
val localPublic = localPublicKey ?: return
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.KEY_EXCHANGE,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey,
|
||||
sharedPublic = localPublic.toHex()
|
||||
)
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.CREATE_ROOM,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey
|
||||
)
|
||||
if (!keyExchangeSent) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.KEY_EXCHANGE,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey,
|
||||
sharedPublic = localPublic.toHex()
|
||||
)
|
||||
keyExchangeSent = true
|
||||
}
|
||||
if (!createRoomSent) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.CREATE_ROOM,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey
|
||||
)
|
||||
createRoomSent = true
|
||||
}
|
||||
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
||||
return
|
||||
}
|
||||
@@ -406,6 +443,7 @@ object CallManager {
|
||||
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
|
||||
webRtcSignalMutex.withLock {
|
||||
val phase = _state.value.phase
|
||||
breadcrumb("RTC: packet=${packet.signalType} payloadLen=${packet.sdpOrCandidate.length} phase=$phase")
|
||||
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
|
||||
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
|
||||
return@withLock
|
||||
@@ -435,6 +473,7 @@ object CallManager {
|
||||
pc.setRemoteDescriptionAwait(answer)
|
||||
remoteDescriptionSet = true
|
||||
flushBufferedRemoteCandidates()
|
||||
attachReceiverE2EEFromPeerConnection()
|
||||
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
|
||||
} catch (e: Exception) {
|
||||
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
|
||||
@@ -457,12 +496,23 @@ object CallManager {
|
||||
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
|
||||
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()})")
|
||||
try {
|
||||
pc.setRemoteDescriptionAwait(remoteOffer)
|
||||
remoteDescriptionSet = true
|
||||
flushBufferedRemoteCandidates()
|
||||
attachReceiverE2EEFromPeerConnection()
|
||||
|
||||
val stateAfterRemote = pc.signalingState()
|
||||
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
|
||||
@@ -478,6 +528,8 @@ object CallManager {
|
||||
signalType = WebRTCSignalType.ANSWER,
|
||||
sdpOrCandidate = serializeSessionDescription(answer)
|
||||
)
|
||||
attachReceiverE2EEFromPeerConnection()
|
||||
lastRemoteOfferFingerprint = offerFingerprint
|
||||
breadcrumb("RTC: OFFER handled → ANSWER sent")
|
||||
} catch (e: Exception) {
|
||||
breadcrumb("RTC: OFFER FAILED — ${e.message}")
|
||||
@@ -529,6 +581,7 @@ object CallManager {
|
||||
if (audioSource == null) {
|
||||
audioSource = factory.createAudioSource(MediaConstraints())
|
||||
}
|
||||
var senderToAttach: RtpSender? = null
|
||||
if (localAudioTrack == null) {
|
||||
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
|
||||
localAudioTrack?.setEnabled(!_state.value.isMuted)
|
||||
@@ -538,13 +591,27 @@ object CallManager {
|
||||
listOf(LOCAL_MEDIA_STREAM_ID)
|
||||
)
|
||||
val transceiver = pc.addTransceiver(localAudioTrack, txInit)
|
||||
breadcrumb("PC: audio transceiver added, attaching E2EE…")
|
||||
attachSenderE2EE(transceiver?.sender)
|
||||
senderToAttach = 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 {
|
||||
val offer = pc.createOfferAwait()
|
||||
pc.setLocalDescriptionAwait(offer)
|
||||
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
|
||||
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
|
||||
ProtocolManager.sendWebRtcSignal(
|
||||
signalType = WebRTCSignalType.OFFER,
|
||||
sdpOrCandidate = serializeSessionDescription(offer)
|
||||
@@ -599,10 +666,12 @@ object CallManager {
|
||||
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
|
||||
override fun onTrack(transceiver: RtpTransceiver?) {
|
||||
breadcrumb("PC: onTrack → attachReceiverE2EE")
|
||||
attachReceiverE2EE(transceiver)
|
||||
attachReceiverE2EE(transceiver?.receiver)
|
||||
attachReceiverE2EEFromPeerConnection()
|
||||
}
|
||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||
breadcrumb("PC: connState=$newState")
|
||||
breadcrumbState("onConnectionChange:$newState")
|
||||
when (newState) {
|
||||
PeerConnection.PeerConnectionState.CONNECTED -> {
|
||||
disconnectResetJob?.cancel()
|
||||
@@ -721,6 +790,7 @@ object CallManager {
|
||||
|
||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||
breadcrumbState("resetSession")
|
||||
val snapshot = _state.value
|
||||
val wasActive = snapshot.phase != CallPhase.IDLE
|
||||
val peerToNotify = snapshot.peerPublicKey
|
||||
@@ -747,8 +817,17 @@ object CallManager {
|
||||
roomId = ""
|
||||
offerSent = false
|
||||
remoteDescriptionSet = false
|
||||
keyExchangeSent = false
|
||||
createRoomSent = false
|
||||
lastPeerSharedPublicHex = ""
|
||||
lastRemoteOfferFingerprint = ""
|
||||
lastLocalOfferFingerprint = ""
|
||||
e2eeRebindJob?.cancel()
|
||||
e2eeRebindJob = null
|
||||
localPrivateKey = null
|
||||
localPublicKey = null
|
||||
callSessionId = ""
|
||||
callStartedAtMs = 0L
|
||||
durationJob?.cancel()
|
||||
durationJob = null
|
||||
disconnectResetJob?.cancel()
|
||||
@@ -792,6 +871,7 @@ object CallManager {
|
||||
return
|
||||
}
|
||||
sharedKeyBytes = keyBytes.copyOf(32)
|
||||
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
|
||||
// Open native diagnostics file for frame-level logging
|
||||
try {
|
||||
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
|
||||
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||
} 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)")
|
||||
}
|
||||
|
||||
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 */
|
||||
private fun breadcrumb(step: String) {
|
||||
try {
|
||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||
val dir = ensureCrashReportsDir() ?: return
|
||||
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
|
||||
if (step.startsWith("KE:") && step.contains("agreement")) {
|
||||
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) {}
|
||||
}
|
||||
|
||||
/** Save a full crash report to crash_reports/ */
|
||||
private fun saveCrashReport(title: String, error: Throwable) {
|
||||
try {
|
||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||
val dir = ensureCrashReportsDir() ?: return
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date())
|
||||
val f = java.io.File(dir, "crash_e2ee_$ts.txt")
|
||||
val sw = java.io.StringWriter()
|
||||
error.printStackTrace(java.io.PrintWriter(sw))
|
||||
f.writeText("=== E2EE CRASH REPORT ===\n$title\n\nType: ${error.javaClass.name}\nMessage: ${error.message}\n\n$sw")
|
||||
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) {}
|
||||
}
|
||||
|
||||
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?) {
|
||||
if (!e2eeAvailable) return
|
||||
val key = sharedKeyBytes ?: return
|
||||
if (sender == null) return
|
||||
val mapKey = senderMapKey(sender)
|
||||
val existing = senderEncryptors[mapKey]
|
||||
if (existing != null) {
|
||||
runCatching { sender.setFrameEncryptor(existing) }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
||||
@@ -847,7 +1096,8 @@ object CallManager {
|
||||
breadcrumb("4. calling sender.setFrameEncryptor…")
|
||||
sender.setFrameEncryptor(enc)
|
||||
breadcrumb("5. setFrameEncryptor OK!")
|
||||
senderEncryptor = enc
|
||||
senderEncryptors[mapKey] = enc
|
||||
pendingAudioSenderForE2ee = null
|
||||
} catch (e: Throwable) {
|
||||
saveCrashReport("attachSenderE2EE 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
|
||||
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 {
|
||||
breadcrumb("6. decryptor: creating…")
|
||||
@@ -873,7 +1134,7 @@ object CallManager {
|
||||
breadcrumb("9. calling receiver.setFrameDecryptor…")
|
||||
receiver.setFrameDecryptor(dec)
|
||||
breadcrumb("10. setFrameDecryptor OK!")
|
||||
receiverDecryptor = dec
|
||||
receiverDecryptors[mapKey] = dec
|
||||
} catch (e: Throwable) {
|
||||
saveCrashReport("attachReceiverE2EE 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.
|
||||
// After our Release: WebRTC ref remains. On peerConnection.close()
|
||||
// WebRTC releases its ref → ref=0 → native object deleted.
|
||||
runCatching { senderEncryptor?.dispose() }
|
||||
runCatching { receiverDecryptor?.dispose() }
|
||||
senderEncryptor = null
|
||||
receiverDecryptor = null
|
||||
senderEncryptors.values.forEach { enc ->
|
||||
runCatching { enc.dispose() }
|
||||
}
|
||||
receiverDecryptors.values.forEach { dec ->
|
||||
runCatching { dec.dispose() }
|
||||
}
|
||||
senderEncryptors.clear()
|
||||
receiverDecryptors.clear()
|
||||
pendingAudioSenderForE2ee = null
|
||||
sharedKeyBytes?.let { it.fill(0) }
|
||||
sharedKeyBytes = null
|
||||
runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
|
||||
@@ -896,11 +1162,12 @@ object CallManager {
|
||||
|
||||
private fun generateSessionKeys() {
|
||||
val privateKey = ByteArray(32)
|
||||
secureRandom.nextBytes(privateKey)
|
||||
X25519.generatePrivateKey(secureRandom, privateKey)
|
||||
val publicKey = ByteArray(32)
|
||||
X25519.generatePublicKey(privateKey, 0, publicKey, 0)
|
||||
localPrivateKey = privateKey
|
||||
localPublicKey = publicKey
|
||||
breadcrumb("KE: local keypair pub=${publicKey.shortHex()} privFp=${privateKey.fingerprintHex(6)}")
|
||||
}
|
||||
|
||||
private fun computeSharedSecretHex(peerPublicHex: String): String? {
|
||||
@@ -908,17 +1175,17 @@ object CallManager {
|
||||
val peerPublic = peerPublicHex.hexToBytes() ?: return null
|
||||
if (peerPublic.size != 32) return null
|
||||
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)
|
||||
if (!ok) {
|
||||
breadcrumb("KE: X25519 FAILED")
|
||||
return null
|
||||
}
|
||||
breadcrumb("KE: X25519 OK, calling HSalsa20…")
|
||||
breadcrumb("KE: X25519 OK rawDhFp=${rawDh.fingerprintHex(8)}, calling HSalsa20…")
|
||||
return try {
|
||||
val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
|
||||
rawDh.fill(0)
|
||||
breadcrumb("KE: HSalsa20 OK, key ready")
|
||||
breadcrumb("KE: HSalsa20 OK keyFp=${naclShared.fingerprintHex(8)}")
|
||||
naclShared.toHex()
|
||||
} catch (e: Throwable) {
|
||||
saveCrashReport("HSalsa20 failed", e)
|
||||
@@ -943,6 +1210,12 @@ object CallManager {
|
||||
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
|
||||
val sdp = json.getString("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()
|
||||
}
|
||||
|
||||
@@ -961,6 +1234,12 @@ object CallManager {
|
||||
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
|
||||
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -976,6 +1255,16 @@ object CallManager {
|
||||
}
|
||||
|
||||
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? {
|
||||
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 RECONNECT_INTERVAL = 5000L // 5 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 MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||
|
||||
@@ -1,163 +1,332 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Binary stream for protocol packets
|
||||
* Matches the React Native implementation exactly
|
||||
* Binary stream for protocol packets.
|
||||
* Ported from desktop/dev stream.ts implementation.
|
||||
*/
|
||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||
private var _stream = mutableListOf<Int>()
|
||||
private var _readPointer = 0
|
||||
private var _writePointer = 0
|
||||
|
||||
private var stream: ByteArray
|
||||
private var readPointer = 0 // bits
|
||||
private var writePointer = 0 // bits
|
||||
|
||||
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 {
|
||||
return _stream.map { it.toByte() }.toByteArray()
|
||||
return stream.copyOf(length())
|
||||
}
|
||||
|
||||
fun getReadPointerBits(): Int = _readPointer
|
||||
|
||||
fun getTotalBits(): Int = _stream.size * 8
|
||||
|
||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
||||
|
||||
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++
|
||||
fun setStream(stream: ByteArray = ByteArray(0)) {
|
||||
if (stream.isEmpty()) {
|
||||
this.stream = ByteArray(0)
|
||||
this.readPointer = 0
|
||||
this.writePointer = 0
|
||||
return
|
||||
}
|
||||
this.stream = stream.copyOf()
|
||||
this.readPointer = 0
|
||||
this.writePointer = this.stream.size shl 3
|
||||
}
|
||||
|
||||
fun readInt8(): Int {
|
||||
var value = 0
|
||||
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
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 getBuffer(): ByteArray = getStream()
|
||||
|
||||
fun isEmpty(): Boolean = writePointer == 0
|
||||
|
||||
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) {
|
||||
val bit = value and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
writeBits((value and 1).toULong(), 1)
|
||||
}
|
||||
|
||||
fun readBit(): Int {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
return bit
|
||||
}
|
||||
|
||||
|
||||
fun readBit(): Int = readBits(1).toInt()
|
||||
|
||||
fun writeBoolean(value: Boolean) {
|
||||
writeBit(if (value) 1 else 0)
|
||||
}
|
||||
|
||||
fun readBoolean(): Boolean {
|
||||
return readBit() == 1
|
||||
|
||||
fun readBoolean(): Boolean = 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) {
|
||||
writeInt8(value shr 8)
|
||||
writeInt8(value and 0xFF)
|
||||
writeUInt16(value)
|
||||
}
|
||||
|
||||
|
||||
fun readInt16(): Int {
|
||||
val high = readInt8() shl 8
|
||||
return high or readInt8()
|
||||
val value = readUInt16()
|
||||
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) {
|
||||
writeInt16(value shr 16)
|
||||
writeInt16(value and 0xFFFF)
|
||||
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
||||
}
|
||||
|
||||
fun readInt32(): Int {
|
||||
val high = readInt16() shl 16
|
||||
return high or readInt16()
|
||||
|
||||
fun readInt32(): Int = readUInt32().toInt()
|
||||
|
||||
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) {
|
||||
val high = (value shr 32).toInt()
|
||||
val low = (value and 0xFFFFFFFF).toInt()
|
||||
writeInt32(high)
|
||||
writeInt32(low)
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
val high = readInt32().toLong()
|
||||
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
||||
|
||||
fun readUInt64(): ULong {
|
||||
val high = readUInt32().toULong()
|
||||
val low = readUInt32().toULong()
|
||||
return (high shl 32) or low
|
||||
}
|
||||
|
||||
fun writeString(value: String) {
|
||||
writeInt32(value.length)
|
||||
for (char in value) {
|
||||
writeInt16(char.code)
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
writeUInt64(value.toULong())
|
||||
}
|
||||
|
||||
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 {
|
||||
val length = readInt32()
|
||||
// Desktop parity + safety: don't trust malformed string length.
|
||||
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
||||
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
||||
android.util.Log.w(
|
||||
"RosettaStream",
|
||||
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
||||
)
|
||||
return ""
|
||||
val len = readUInt32()
|
||||
if (len > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("String length too large: $len")
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
for (i in 0 until length) {
|
||||
sb.append(readInt16().toChar())
|
||||
|
||||
val requiredBits = len * 16L
|
||||
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) {
|
||||
writeInt32(value.size)
|
||||
for (byte in value) {
|
||||
writeInt8(byte.toInt())
|
||||
|
||||
fun writeBytes(value: ByteArray?) {
|
||||
val bytes = value ?: ByteArray(0)
|
||||
writeUInt32(bytes.size.toLong())
|
||||
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 {
|
||||
val length = readInt32()
|
||||
val bytes = ByteArray(length)
|
||||
for (i in 0 until length) {
|
||||
bytes[i] = readInt8().toByte()
|
||||
val len = readUInt32()
|
||||
if (len == 0L) return ByteArray(0)
|
||||
if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
|
||||
|
||||
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 ensureCapacity(index: Int) {
|
||||
while (_stream.size <= index) {
|
||||
_stream.add(0)
|
||||
|
||||
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) {
|
||||
val requiredSize = index + 1
|
||||
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.Path
|
||||
import android.graphics.RectF
|
||||
import android.util.Log
|
||||
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
@@ -11,13 +11,10 @@ import android.view.Gravity
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.width
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
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.LocalDensity
|
||||
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.sp
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
|
||||
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) {
|
||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
}
|
||||
|
||||
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
||||
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
|
||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||
}
|
||||
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
}
|
||||
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:
|
||||
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
||||
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
|
||||
val context = LocalContext.current
|
||||
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
|
||||
val useGpu = when (MetaballDebug.forceMode) {
|
||||
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
||||
"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
|
||||
}
|
||||
val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val useCpu = !useGpu
|
||||
|
||||
when {
|
||||
useGpu -> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.rosetta.messenger.ui.crashlogs
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.filled.ArrowBack
|
||||
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.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.Modifier
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -263,6 +267,8 @@ private fun CrashDetailScreen(
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -274,6 +280,14 @@ private fun CrashDetailScreen(
|
||||
}
|
||||
},
|
||||
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 */ }) {
|
||||
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.
|
||||
|
||||
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 `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
|
||||
- `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
|
||||
|
||||
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:
|
||||
|
||||
@@ -47,6 +52,7 @@ Optional env vars:
|
||||
- `SYNC_JOBS` — `gclient sync` jobs (default: `1`, safer for googlesource limits)
|
||||
- `SYNC_RETRIES` — sync retry attempts (default: `8`)
|
||||
- `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)
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && 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.
|
||||
WEBRTC_BRANCH="${WEBRTC_BRANCH:-branch-heads/6422}"
|
||||
@@ -132,21 +138,63 @@ sync_with_retry
|
||||
|
||||
echo "[webrtc-custom] applying Rosetta patch..."
|
||||
git reset --hard
|
||||
git apply --check "${PATCH_FILE}"
|
||||
git apply "${PATCH_FILE}"
|
||||
for patch in "${PATCH_FILES[@]}"; do
|
||||
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}")"
|
||||
|
||||
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 \
|
||||
--build-dir out_rosetta_aar \
|
||||
--output "${OUT_AAR}" \
|
||||
--arch "${ARCHS[@]}" \
|
||||
--extra-gn-args \
|
||||
is_debug=false \
|
||||
is_component_build=false \
|
||||
rtc_include_tests=false \
|
||||
rtc_build_examples=false
|
||||
--extra-gn-args "${GN_ARGS[@]}"
|
||||
|
||||
echo "[webrtc-custom] done"
|
||||
echo "[webrtc-custom] AAR: ${OUT_AAR}"
|
||||
|
||||
@@ -25,22 +25,24 @@ index 17cf859ed8..b9d9ab14c8 100644
|
||||
decrypted_audio_payload);
|
||||
|
||||
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
|
||||
+++ 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.
|
||||
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] = {
|
||||
+ 0,
|
||||
+ 0,
|
||||
+ 0,
|
||||
+ 0,
|
||||
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 24) & 0xff),
|
||||
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 16) & 0xff),
|
||||
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 8) & 0xff),
|
||||
+ static_cast<uint8_t>(rtp_timestamp_without_offset & 0xff),
|
||||
+ static_cast<uint8_t>((additional_data_timestamp >> 24) & 0xff),
|
||||
+ static_cast<uint8_t>((additional_data_timestamp >> 16) & 0xff),
|
||||
+ static_cast<uint8_t>((additional_data_timestamp >> 8) & 0xff),
|
||||
+ static_cast<uint8_t>(additional_data_timestamp & 0xff),
|
||||
+ };
|
||||
+
|
||||
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