Compare commits

..

7 Commits

21 changed files with 1348 additions and 3542 deletions

View File

@@ -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 {

View File

@@ -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);
}
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.
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;
}
}
// 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);
}
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 {
// 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());
}
}
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;

View File

@@ -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
@@ -110,19 +110,3 @@ fun AnimatedKeyboardTransition(
}
}
}
/**
* Алиас для обратной совместимости
*/
@Composable
fun SimpleAnimatedKeyboardTransition(
coordinator: KeyboardTransitionCoordinator,
showEmojiPicker: Boolean,
content: @Composable () -> Unit
) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker,
content = content
)
}

View File

@@ -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'а. */

View File

@@ -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 =

View File

@@ -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) {
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
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()

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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 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 writeInt8(value: Int) {
val negationBit = if (value < 0) 1 else 0
val int8Value = Math.abs(value) and 0xFF
fun getBuffer(): ByteArray = getStream()
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
_writePointer++
fun isEmpty(): Boolean = writePointer == 0
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 length(): Int = (writePointer + 7) shr 3
fun readInt8(): Int {
var value = 0
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
fun getReadPointerBits(): Int = 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++
}
fun getTotalBits(): Int = writePointer
return if (negationBit == 1) -value else value
}
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 sb = StringBuilder()
for (i in 0 until length) {
sb.append(readInt16().toChar())
}
return sb.toString()
val len = readUInt32()
if (len > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("String length too large: $len")
}
fun writeBytes(value: ByteArray) {
writeInt32(value.size)
for (byte in value) {
writeInt8(byte.toInt())
val requiredBits = len * 16L
if (requiredBits > remainingBits()) {
throw IllegalStateException("Not enough bits to read string")
}
val chars = CharArray(len.toInt())
for (i in chars.indices) {
chars[i] = readUInt16().toChar()
}
return String(chars)
}
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 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) {
while (_stream.size <= index) {
_stream.add(0)
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
}
}

View File

@@ -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)
}

View File

@@ -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)
)
}

View File

@@ -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 -> {

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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(

View File

@@ -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"

View 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") {

View File

@@ -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."""

View File

@@ -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 '