Compare commits

..

10 Commits

28 changed files with 1472 additions and 3608 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.0" val rosettaVersionName = "1.3.2"
val rosettaVersionCode = 32 // Increment on each release val rosettaVersionCode = 34 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

@@ -16,6 +16,7 @@
#include <signal.h> #include <signal.h>
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
#include <time.h>
#include <stdarg.h> #include <stdarg.h>
#include <stdio.h> #include <stdio.h>
#include <android/log.h> #include <android/log.h>
@@ -34,6 +35,10 @@
static char g_diag_path[512] = {0}; static char g_diag_path[512] = {0};
static int g_diag_fd = -1; static int g_diag_fd = -1;
static std::atomic<int> g_diag_event_count{0};
static constexpr int kDiagEventLimit = 4000;
static constexpr int kDiagFrameLimit = 400;
static constexpr size_t kFrameHashSampleBytes = 320;
static void diag_write(const char *fmt, ...) { static void diag_write(const char *fmt, ...) {
if (g_diag_fd < 0) return; if (g_diag_fd < 0) return;
@@ -45,6 +50,50 @@ static void diag_write(const char *fmt, ...) {
if (n > 0) write(g_diag_fd, buf, n); if (n > 0) write(g_diag_fd, buf, n);
} }
static void diag_event(const char *fmt, ...) {
if (g_diag_fd < 0) return;
const int idx = g_diag_event_count.fetch_add(1, std::memory_order_relaxed);
if (idx >= kDiagEventLimit) return;
char buf[640];
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
if (n > 0) write(g_diag_fd, buf, n);
}
static inline uint32_t fnv1a32(const uint8_t* data, size_t len, size_t sample_limit = 0) {
if (!data || len == 0) return 0;
const size_t n = (sample_limit > 0 && len > sample_limit) ? sample_limit : len;
uint32_t h = 2166136261u;
for (size_t i = 0; i < n; ++i) {
h ^= data[i];
h *= 16777619u;
}
return h;
}
static inline uint32_t key_fingerprint32(const uint8_t key[32]) {
return fnv1a32(key, 32, 32);
}
static inline uint32_t nonce_ts32(const uint8_t nonce[24]) {
return ((uint32_t)nonce[4] << 24) |
((uint32_t)nonce[5] << 16) |
((uint32_t)nonce[6] << 8) |
((uint32_t)nonce[7]);
}
static const char* media_type_name(cricket::MediaType media_type) {
switch (media_type) {
case cricket::MEDIA_TYPE_AUDIO: return "audio";
case cricket::MEDIA_TYPE_VIDEO: return "video";
case cricket::MEDIA_TYPE_DATA: return "data";
case cricket::MEDIA_TYPE_UNSUPPORTED: return "unsupported";
default: return "unknown";
}
}
/* ── RTP helpers (for cases when additional_data is empty) ───── */ /* ── RTP helpers (for cases when additional_data is empty) ───── */
struct ParsedRtpPacket { struct ParsedRtpPacket {
@@ -72,6 +121,17 @@ struct GeneratedTsState {
uint32_t next_step = 960; // 20 ms @ 48 kHz (default Opus packetization) uint32_t next_step = 960; // 20 ms @ 48 kHz (default Opus packetization)
}; };
struct AdditionalTsState {
bool initialized = false;
uint64_t base_timestamp = 0;
};
struct SenderTsOffsetState {
bool initialized = false;
bool enabled = false;
uint64_t offset = 0;
};
static inline uint16_t load16_be(const uint8_t* p) { static inline uint16_t load16_be(const uint8_t* p) {
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
} }
@@ -83,6 +143,17 @@ static inline uint32_t load32_be(const uint8_t* p) {
((uint32_t)p[3]); ((uint32_t)p[3]);
} }
static inline uint64_t load64_be(const uint8_t* p) {
return ((uint64_t)p[0] << 56) |
((uint64_t)p[1] << 48) |
((uint64_t)p[2] << 40) |
((uint64_t)p[3] << 32) |
((uint64_t)p[4] << 24) |
((uint64_t)p[5] << 16) |
((uint64_t)p[6] << 8) |
((uint64_t)p[7]);
}
static bool parse_rtp_packet(const uint8_t* data, size_t len, ParsedRtpPacket* out) { static bool parse_rtp_packet(const uint8_t* data, size_t len, ParsedRtpPacket* out) {
if (!data || !out || len < 12) return false; if (!data || !out || len < 12) return false;
@@ -133,6 +204,8 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
state->probe_ssrc = packet.ssrc; state->probe_ssrc = packet.ssrc;
state->probe_sequence = packet.sequence; state->probe_sequence = packet.sequence;
state->probe_timestamp = packet.timestamp; state->probe_timestamp = packet.timestamp;
diag_event("RTP probe-start ssrc=%u seq=%u ts=%u hdr=%zu\n",
packet.ssrc, packet.sequence, packet.timestamp, packet.header_size);
return false; return false;
} }
@@ -151,8 +224,12 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
state->ssrc = packet.ssrc; state->ssrc = packet.ssrc;
state->last_sequence = packet.sequence; state->last_sequence = packet.sequence;
state->last_timestamp = packet.timestamp; state->last_timestamp = packet.timestamp;
diag_event("RTP probe-lock ssrc=%u seq=%u ts=%u hdr=%zu\n",
packet.ssrc, packet.sequence, packet.timestamp, packet.header_size);
} else { } else {
if (packet.ssrc != state->ssrc) { if (packet.ssrc != state->ssrc) {
diag_event("RTP probe-unlock reason=ssrc-change old=%u new=%u seq=%u ts=%u\n",
state->ssrc, packet.ssrc, packet.sequence, packet.timestamp);
state->locked = false; state->locked = false;
state->has_probe = true; state->has_probe = true;
state->probe_ssrc = packet.ssrc; state->probe_ssrc = packet.ssrc;
@@ -168,6 +245,8 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
state->last_timestamp = packet.timestamp; state->last_timestamp = packet.timestamp;
} else if (seq_delta != 0) { } else if (seq_delta != 0) {
// Not plausible for a continuous stream: re-probe. // Not plausible for a continuous stream: re-probe.
diag_event("RTP probe-unlock reason=seq-jump delta=%u ssrc=%u last_seq=%u seq=%u ts=%u\n",
seq_delta, packet.ssrc, state->last_sequence, packet.sequence, packet.timestamp);
state->locked = false; state->locked = false;
state->has_probe = true; state->has_probe = true;
state->probe_ssrc = packet.ssrc; state->probe_ssrc = packet.ssrc;
@@ -188,14 +267,39 @@ static bool fill_nonce_from_rtp_frame(const uint8_t* data,
static bool fill_nonce_from_additional_data(const uint8_t* data, static bool fill_nonce_from_additional_data(const uint8_t* data,
size_t len, size_t len,
uint8_t nonce[24], uint8_t nonce[24],
bool* used_rtp_header) { bool* used_rtp_header,
AdditionalTsState* ts_state,
bool* used_relative_ts) {
if (used_rtp_header) *used_rtp_header = false; if (used_rtp_header) *used_rtp_header = false;
if (used_relative_ts) *used_relative_ts = false;
if (!data || len < 8) return false; if (!data || len < 8) return false;
// Desktop-compatible path: additional_data contains encoded frame timestamp // Desktop-compatible path: additional_data contains encoded frame timestamp
// as 8-byte BE value. Use it directly as nonce[0..7]. // as 8-byte BE value. On Android sender/receiver can have different absolute
// base in some pipelines; ts_state enables optional relative fallback.
// Primary desktop-compatible mode uses absolute timestamp (ts_state == nullptr).
if (len == 8) { if (len == 8) {
memcpy(nonce, data, 8); uint64_t ts64 = load64_be(data);
uint64_t ts_rel = ts64;
if (ts_state != nullptr) {
if (!ts_state->initialized) {
ts_state->initialized = true;
ts_state->base_timestamp = ts64;
} else if (ts64 < ts_state->base_timestamp) {
// New stream or reset; avoid huge wrapped nonce drift.
ts_state->base_timestamp = ts64;
}
ts_rel = ts64 - ts_state->base_timestamp;
if (used_relative_ts) *used_relative_ts = true;
}
nonce[0] = (uint8_t)(ts_rel >> 56);
nonce[1] = (uint8_t)(ts_rel >> 48);
nonce[2] = (uint8_t)(ts_rel >> 40);
nonce[3] = (uint8_t)(ts_rel >> 32);
nonce[4] = (uint8_t)(ts_rel >> 24);
nonce[5] = (uint8_t)(ts_rel >> 16);
nonce[6] = (uint8_t)(ts_rel >> 8);
nonce[7] = (uint8_t)(ts_rel);
return true; return true;
} }
@@ -218,6 +322,18 @@ static bool fill_nonce_from_additional_data(const uint8_t* data,
return true; return true;
} }
static bool is_plausible_opus_packet(const uint8_t* packet, size_t len);
static bool is_plausible_decrypted_audio_frame(const uint8_t* data, size_t len) {
if (!data || len == 0) return false;
ParsedRtpPacket packet;
if (parse_rtp_packet(data, len, &packet) && packet.header_size < len) {
return is_plausible_opus_packet(data + packet.header_size, len - packet.header_size);
}
return is_plausible_opus_packet(data, len);
}
static inline void fill_nonce_from_ts32(uint32_t ts, uint8_t nonce[24]) { static inline void fill_nonce_from_ts32(uint32_t ts, uint8_t nonce[24]) {
nonce[4] = (uint8_t)(ts >> 24); nonce[4] = (uint8_t)(ts >> 24);
nonce[5] = (uint8_t)(ts >> 16); nonce[5] = (uint8_t)(ts >> 16);
@@ -225,6 +341,25 @@ static inline void fill_nonce_from_ts32(uint32_t ts, uint8_t nonce[24]) {
nonce[7] = (uint8_t)(ts); nonce[7] = (uint8_t)(ts);
} }
static inline void fill_nonce_from_ts64(uint64_t ts, uint8_t nonce[24]) {
nonce[0] = (uint8_t)(ts >> 56);
nonce[1] = (uint8_t)(ts >> 48);
nonce[2] = (uint8_t)(ts >> 40);
nonce[3] = (uint8_t)(ts >> 32);
nonce[4] = (uint8_t)(ts >> 24);
nonce[5] = (uint8_t)(ts >> 16);
nonce[6] = (uint8_t)(ts >> 8);
nonce[7] = (uint8_t)(ts);
}
static inline uint64_t monotonic_48k_ticks() {
struct timespec ts {};
if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0;
const uint64_t sec = (uint64_t)ts.tv_sec;
const uint64_t nsec = (uint64_t)ts.tv_nsec;
return sec * 48000ULL + (nsec * 48000ULL) / 1000000000ULL;
}
static inline uint32_t opus_base_frame_samples(uint8_t config) { static inline uint32_t opus_base_frame_samples(uint8_t config) {
// RFC 6716 TOC config mapping at 48 kHz. // RFC 6716 TOC config mapping at 48 kHz.
if (config <= 11) { if (config <= 11) {
@@ -263,18 +398,94 @@ static uint32_t infer_opus_packet_duration_samples(const uint8_t* packet, size_t
return total; return total;
} }
static bool decode_opus_frame_len(const uint8_t* packet,
size_t len,
size_t* offset,
size_t* frame_len) {
if (!packet || !offset || !frame_len) return false;
if (*offset >= len) return false;
const uint8_t b0 = packet[*offset];
(*offset)++;
if (b0 < 252) {
*frame_len = b0;
return true;
}
if (*offset >= len) return false;
const uint8_t b1 = packet[*offset];
(*offset)++;
*frame_len = (size_t)b1 * 4u + (size_t)b0;
return true;
}
static bool is_plausible_opus_packet(const uint8_t* packet, size_t len) { static bool is_plausible_opus_packet(const uint8_t* packet, size_t len) {
if (!packet || len == 0 || len > 2000) return false; if (!packet || len == 0 || len > 2000) return false;
const uint8_t toc = packet[0]; const uint8_t toc = packet[0];
const uint8_t config = (uint8_t)(toc >> 3); const uint8_t config = (uint8_t)(toc >> 3);
if (config > 31) return false; if (config > 31) return false;
const uint8_t frame_code = (uint8_t)(toc & 0x03); const uint8_t frame_code = (uint8_t)(toc & 0x03);
if (frame_code != 3) return true; const size_t payload_len = len - 1;
if (frame_code == 0) {
// 1 frame, full payload
return payload_len >= 1;
}
if (frame_code == 1) {
// 2 CBR frames, equal sizes.
if (payload_len < 2) return false;
return (payload_len % 2) == 0;
}
if (frame_code == 2) {
// 2 VBR frames
size_t off = 1;
size_t len1 = 0;
if (!decode_opus_frame_len(packet, len, &off, &len1)) return false;
if (len1 == 0) return false;
if (off + len1 >= len) return false; // need non-empty second frame
const size_t len2 = len - off - len1;
if (len2 == 0) return false;
return true;
}
// frame_code == 3: arbitrary number of frames
if (len < 2) return false; if (len < 2) return false;
const uint8_t frame_count = (uint8_t)(packet[1] & 0x3F); const uint8_t ch = packet[1];
const bool vbr = (ch & 0x80) != 0;
const bool has_padding = (ch & 0x40) != 0;
const uint8_t frame_count = (uint8_t)(ch & 0x3F);
if (frame_count == 0 || frame_count > 48) return false; if (frame_count == 0 || frame_count > 48) return false;
const uint32_t total = opus_base_frame_samples(config) * (uint32_t)frame_count; const uint32_t total = opus_base_frame_samples(config) * (uint32_t)frame_count;
return total <= 5760; if (total > 5760) return false;
// Padding bit is rarely used in live voice. Rejecting it improves
// discrimination between valid Opus and random decrypted noise.
if (has_padding) return false;
if (!vbr) {
const size_t data_len = len - 2;
if (data_len < (size_t)frame_count) return false;
return (data_len % frame_count) == 0;
}
// VBR: parse lengths for first N-1 frames; last frame consumes rest.
size_t off = 2;
size_t consumed = 0;
for (size_t i = 0; i + 1 < frame_count; ++i) {
size_t flen = 0;
if (!decode_opus_frame_len(packet, len, &off, &flen)) return false;
if (flen == 0) return false;
consumed += flen;
if (off + consumed >= len) return false;
}
const size_t remaining = len - off;
if (remaining <= consumed) return false;
const size_t last = remaining - consumed;
return last > 0;
} }
/* ── Native crash handler — writes to file before dying ──────── */ /* ── Native crash handler — writes to file before dying ──────── */
@@ -314,8 +525,13 @@ class XChaCha20Encryptor final : public webrtc::FrameEncryptorInterface {
public: public:
explicit XChaCha20Encryptor(const uint8_t key[32]) { explicit XChaCha20Encryptor(const uint8_t key[32]) {
memcpy(key_, key, 32); memcpy(key_, key, 32);
key_fingerprint_ = key_fingerprint32(key_);
LOGI("ENC init ptr=%p key_fp=%08x", this, key_fingerprint_);
diag_event("ENC init ptr=%p key_fp=%08x\n", this, key_fingerprint_);
} }
uint32_t KeyFingerprint() const { return key_fingerprint_; }
/* ── RefCountInterface ─────────────────────────────────────── */ /* ── RefCountInterface ─────────────────────────────────────── */
void AddRef() const override { void AddRef() const override {
ref_.fetch_add(1, std::memory_order_relaxed); ref_.fetch_add(1, std::memory_order_relaxed);
@@ -344,8 +560,8 @@ public:
* If RTP header is found inside frame, we leave header bytes unencrypted * If RTP header is found inside frame, we leave header bytes unencrypted
* and encrypt only payload (desktop-compatible). * and encrypt only payload (desktop-compatible).
*/ */
int Encrypt(cricket::MediaType /*media_type*/, int Encrypt(cricket::MediaType media_type,
uint32_t /*ssrc*/, uint32_t ssrc,
rtc::ArrayView<const uint8_t> additional_data, rtc::ArrayView<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> frame, rtc::ArrayView<const uint8_t> frame,
rtc::ArrayView<uint8_t> encrypted_frame, rtc::ArrayView<uint8_t> encrypted_frame,
@@ -360,15 +576,19 @@ public:
bool nonce_from_generated_ts = false; bool nonce_from_generated_ts = false;
bool nonce_from_additional_data = false; bool nonce_from_additional_data = false;
bool additional_was_rtp_header = false; bool additional_was_rtp_header = false;
bool additional_used_mono_offset = false;
uint32_t generated_ts_used = 0; uint32_t generated_ts_used = 0;
// Build nonce from RTP timestamp in additional_data (preferred). // Build nonce from RTP timestamp in additional_data (preferred).
uint8_t nonce[24] = {0}; uint8_t nonce[24] = {0};
bool additional_used_relative_ts = false;
nonce_from_additional_data = fill_nonce_from_additional_data( nonce_from_additional_data = fill_nonce_from_additional_data(
additional_data.data(), additional_data.data(),
additional_data.size(), additional_data.size(),
nonce, nonce,
&additional_was_rtp_header); &additional_was_rtp_header,
nullptr,
&additional_used_relative_ts);
if (!nonce_from_additional_data) { if (!nonce_from_additional_data) {
nonce_from_rtp_header = nonce_from_rtp_header =
fill_nonce_from_rtp_frame(frame.data(), frame.size(), &rtp_probe_, nonce, &header_size); fill_nonce_from_rtp_frame(frame.data(), frame.size(), &rtp_probe_, nonce, &header_size);
@@ -381,26 +601,41 @@ public:
nonce_from_generated_ts = true; nonce_from_generated_ts = true;
generated_ts_used = generated_ts_.next_timestamp; generated_ts_used = generated_ts_.next_timestamp;
fill_nonce_from_ts32(generated_ts_used, nonce); fill_nonce_from_ts32(generated_ts_used, nonce);
diag_event("ENC fallback=generated-ts mt=%s ssrc=%u frame_sz=%zu ad_sz=%zu gen_ts=%u\n",
media_type_name(media_type), ssrc, frame.size(), additional_data.size(), generated_ts_used);
} }
} }
if (nonce_from_rtp_header && header_size <= frame.size()) { // Some Android sender pipelines expose stream-relative ad8 timestamps
// Keep RTP header clear, encrypt payload only. // (0, 960, 1920, ...), while desktop receiver expects an absolute base.
if (header_size > 0) { // For interop, add a monotonic 48k offset once when first ad8 is tiny.
memcpy(encrypted_frame.data(), frame.data(), header_size); if (nonce_from_additional_data &&
additional_data.size() == 8 &&
!additional_was_rtp_header &&
additional_data.data() != nullptr) {
const uint64_t ad_ts64 = load64_be(additional_data.data());
if (!sender_ts_offset_.initialized) {
sender_ts_offset_.initialized = true;
// Keep pure raw-abs mode by default; desktop is the source of truth.
sender_ts_offset_.enabled = false;
sender_ts_offset_.offset = 0ULL;
diag_event("ENC ad8-base init ssrc=%u ad_ts=%llu use_mono=%d mono_off=%llu\n",
ssrc,
(unsigned long long)ad_ts64,
sender_ts_offset_.enabled ? 1 : 0,
(unsigned long long)sender_ts_offset_.offset);
}
if (sender_ts_offset_.enabled) {
const uint64_t ts_adj = ad_ts64 + sender_ts_offset_.offset;
fill_nonce_from_ts64(ts_adj, nonce);
additional_used_mono_offset = true;
} }
const size_t payload_size = frame.size() - header_size;
rosetta_xchacha20_xor(
encrypted_frame.data() + header_size,
frame.data() + header_size,
payload_size,
nonce,
key_);
} else {
// Legacy path: frame is payload-only.
rosetta_xchacha20_xor(encrypted_frame.data(),
frame.data(), frame.size(), nonce, key_);
} }
// Desktop createEncodedStreams encrypts full encoded chunk.
// To stay wire-compatible, do not preserve any leading RTP-like bytes.
rosetta_xchacha20_xor(encrypted_frame.data(),
frame.data(), frame.size(), nonce, key_);
*bytes_written = frame.size(); *bytes_written = frame.size();
if (nonce_from_generated_ts) { if (nonce_from_generated_ts) {
@@ -409,23 +644,48 @@ public:
generated_ts_.next_timestamp = generated_ts_used + step; generated_ts_.next_timestamp = generated_ts_used + step;
} }
// Diag: log first 3 frames // Diag: log first frames with enough context for crash analysis.
int n = diag_count_.fetch_add(1, std::memory_order_relaxed); int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
if (n < 3) { if (n < kDiagFrameLimit) {
uint8_t ad_prefix[8] = {0};
const size_t ad_copy = additional_data.size() < sizeof(ad_prefix)
? additional_data.size()
: sizeof(ad_prefix);
if (ad_copy > 0) memcpy(ad_prefix, additional_data.data(), ad_copy);
ParsedRtpPacket rtp{};
const bool has_rtp = parse_rtp_packet(frame.data(), frame.size(), &rtp);
const bool opus_plausible =
has_rtp && rtp.header_size < frame.size()
? is_plausible_opus_packet(frame.data() + rtp.header_size, frame.size() - rtp.header_size)
: is_plausible_opus_packet(frame.data(), frame.size());
const uint32_t in_hash = fnv1a32(frame.data(), frame.size(), kFrameHashSampleBytes);
const uint32_t out_hash = fnv1a32(encrypted_frame.data(), frame.size(), kFrameHashSampleBytes);
const char* mode = const char* mode =
nonce_from_rtp_header nonce_from_rtp_header
? "rtp" ? "rtp"
: (nonce_from_generated_ts : (nonce_from_generated_ts
? "gen" ? "gen"
: (nonce_from_additional_data : (nonce_from_additional_data
? (additional_was_rtp_header ? "ad-rtp" : "raw-abs") ? (additional_was_rtp_header
? "ad-rtp"
: (additional_used_mono_offset
? "raw-abs+mono"
: (additional_used_relative_ts ? "raw-rel" : "raw-abs")))
: "raw-abs")); : "raw-abs"));
LOGI("ENC frame#%d: sz=%zu ad=%zu hdr=%zu mode=%s nonce=%02x%02x%02x%02x", LOGI("ENC frame#%d mt=%s ssrc=%u sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u rtp_ok=%d rtp_seq=%u rtp_ts=%u rtp_ssrc=%u opus_ok=%d key_fp=%08x in_h=%08x out_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x",
n, frame.size(), additional_data.size(), header_size, mode, n, media_type_name(media_type), ssrc, frame.size(), additional_data.size(), header_size, mode,
nonce[4], nonce[5], nonce[6], nonce[7]); nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
diag_write("ENC frame#%d: sz=%zu ad=%zu hdr=%zu mode=%s nonce[4..7]=%02x%02x%02x%02x\n", has_rtp ? 1 : 0, has_rtp ? rtp.sequence : 0, has_rtp ? rtp.timestamp : 0, has_rtp ? rtp.ssrc : 0,
n, frame.size(), additional_data.size(), header_size, mode, opus_plausible ? 1 : 0, key_fingerprint_, in_hash, out_hash,
nonce[4], nonce[5], nonce[6], nonce[7]); ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
diag_write("ENC frame#%d mt=%s ssrc=%u sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u rtp_ok=%d rtp_seq=%u rtp_ts=%u rtp_ssrc=%u opus_ok=%d key_fp=%08x in_h=%08x out_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x\n",
n, media_type_name(media_type), ssrc, frame.size(), additional_data.size(), header_size, mode,
nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
has_rtp ? 1 : 0, has_rtp ? rtp.sequence : 0, has_rtp ? rtp.timestamp : 0, has_rtp ? rtp.ssrc : 0,
opus_plausible ? 1 : 0, key_fingerprint_, in_hash, out_hash,
ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
} }
return 0; return 0;
} }
@@ -435,13 +695,18 @@ public:
} }
protected: protected:
~XChaCha20Encryptor() override { memset(key_, 0, 32); } ~XChaCha20Encryptor() override {
diag_event("ENC destroy ptr=%p key_fp=%08x\n", this, key_fingerprint_);
memset(key_, 0, 32);
}
private: private:
mutable std::atomic<int> ref_{0}; mutable std::atomic<int> ref_{0};
mutable std::atomic<int> diag_count_{0}; mutable std::atomic<int> diag_count_{0};
mutable RtpProbeState rtp_probe_; mutable RtpProbeState rtp_probe_;
mutable GeneratedTsState generated_ts_; mutable GeneratedTsState generated_ts_;
mutable SenderTsOffsetState sender_ts_offset_;
uint32_t key_fingerprint_ = 0;
uint8_t key_[32]; uint8_t key_[32];
}; };
@@ -453,8 +718,13 @@ class XChaCha20Decryptor final : public webrtc::FrameDecryptorInterface {
public: public:
explicit XChaCha20Decryptor(const uint8_t key[32]) { explicit XChaCha20Decryptor(const uint8_t key[32]) {
memcpy(key_, key, 32); memcpy(key_, key, 32);
key_fingerprint_ = key_fingerprint32(key_);
LOGI("DEC init ptr=%p key_fp=%08x", this, key_fingerprint_);
diag_event("DEC init ptr=%p key_fp=%08x\n", this, key_fingerprint_);
} }
uint32_t KeyFingerprint() const { return key_fingerprint_; }
/* ── RefCountInterface ─────────────────────────────────────── */ /* ── RefCountInterface ─────────────────────────────────────── */
void AddRef() const override { void AddRef() const override {
ref_.fetch_add(1, std::memory_order_relaxed); ref_.fetch_add(1, std::memory_order_relaxed);
@@ -474,8 +744,8 @@ public:
* - if RTP header is present inside encrypted_frame (fallback path), * - if RTP header is present inside encrypted_frame (fallback path),
* keep header bytes untouched and decrypt payload only. * keep header bytes untouched and decrypt payload only.
*/ */
Result Decrypt(cricket::MediaType /*media_type*/, Result Decrypt(cricket::MediaType media_type,
const std::vector<uint32_t>& /*csrcs*/, const std::vector<uint32_t>& csrcs,
rtc::ArrayView<const uint8_t> additional_data, rtc::ArrayView<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> encrypted_frame, rtc::ArrayView<const uint8_t> encrypted_frame,
rtc::ArrayView<uint8_t> frame) override { rtc::ArrayView<uint8_t> frame) override {
@@ -485,12 +755,17 @@ public:
bool nonce_from_generated_ts = false; bool nonce_from_generated_ts = false;
bool nonce_from_additional_data = false; bool nonce_from_additional_data = false;
bool additional_was_rtp_header = false; bool additional_was_rtp_header = false;
bool additional_used_relative_ts = false;
bool used_additional_relative_fallback = false;
bool used_plain_passthrough = false;
uint32_t generated_ts_used = 0; uint32_t generated_ts_used = 0;
nonce_from_additional_data = fill_nonce_from_additional_data( nonce_from_additional_data = fill_nonce_from_additional_data(
additional_data.data(), additional_data.data(),
additional_data.size(), additional_data.size(),
nonce, nonce,
&additional_was_rtp_header); &additional_was_rtp_header,
nullptr,
&additional_used_relative_ts);
if (!nonce_from_additional_data) { if (!nonce_from_additional_data) {
nonce_from_rtp_header = nonce_from_rtp_header =
fill_nonce_from_rtp_frame(encrypted_frame.data(), encrypted_frame.size(), &rtp_probe_, nonce, &header_size); fill_nonce_from_rtp_frame(encrypted_frame.data(), encrypted_frame.size(), &rtp_probe_, nonce, &header_size);
@@ -503,6 +778,8 @@ public:
nonce_from_generated_ts = true; nonce_from_generated_ts = true;
generated_ts_used = generated_ts_.next_timestamp; generated_ts_used = generated_ts_.next_timestamp;
fill_nonce_from_ts32(generated_ts_used, nonce); fill_nonce_from_ts32(generated_ts_used, nonce);
diag_event("DEC fallback=generated-ts mt=%s csrcs=%zu enc_sz=%zu ad_sz=%zu gen_ts=%u\n",
media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size(), generated_ts_used);
} }
} }
@@ -512,19 +789,54 @@ public:
bool used_generated_resync = false; bool used_generated_resync = false;
if (nonce_from_rtp_header && header_size <= encrypted_frame.size()) { // Desktop createEncodedStreams decrypts full encoded chunk.
if (header_size > 0) { rosetta_xchacha20_xor(frame.data(), encrypted_frame.data(), encrypted_frame.size(), nonce, key_);
memcpy(frame.data(), encrypted_frame.data(), header_size);
if (nonce_from_additional_data) {
bool plausible = is_plausible_decrypted_audio_frame(frame.data(), encrypted_frame.size());
// Fallback for Android pipelines where additional_data timestamps are
// stream-relative while remote side uses a different absolute base.
if (!plausible && additional_data.size() == 8 && !additional_was_rtp_header) {
uint8_t nonce_rel[24] = {0};
bool rel_used = false;
if (fill_nonce_from_additional_data(
additional_data.data(),
additional_data.size(),
nonce_rel,
nullptr,
&additional_rel_ts_state_,
&rel_used) &&
rel_used) {
std::vector<uint8_t> candidate(encrypted_frame.size());
rosetta_xchacha20_xor(
candidate.data(),
encrypted_frame.data(),
encrypted_frame.size(),
nonce_rel,
key_);
if (is_plausible_decrypted_audio_frame(candidate.data(), candidate.size())) {
memcpy(frame.data(), candidate.data(), candidate.size());
memcpy(nonce, nonce_rel, sizeof(nonce));
plausible = true;
used_additional_relative_fallback = true;
additional_used_relative_ts = true;
diag_event("DEC fallback=relative-ad-ts mt=%s csrcs=%zu enc_sz=%zu ad_sz=%zu nonce_ts=%u\n",
media_type_name(media_type), csrcs.size(), encrypted_frame.size(),
additional_data.size(), nonce_ts32(nonce));
}
}
}
// If payload already looks like valid Opus, keep plaintext.
// This protects interop when peer stream is unexpectedly unencrypted.
if (!plausible &&
is_plausible_decrypted_audio_frame(encrypted_frame.data(), encrypted_frame.size())) {
memcpy(frame.data(), encrypted_frame.data(), encrypted_frame.size());
used_plain_passthrough = true;
diag_event("DEC fallback=plain-passthrough mt=%s csrcs=%zu enc_sz=%zu ad_sz=%zu\n",
media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size());
} }
const size_t payload_size = encrypted_frame.size() - header_size;
rosetta_xchacha20_xor(
frame.data() + header_size,
encrypted_frame.data() + header_size,
payload_size,
nonce,
key_);
} else {
rosetta_xchacha20_xor(frame.data(), encrypted_frame.data(), encrypted_frame.size(), nonce, key_);
} }
if (nonce_from_generated_ts) { if (nonce_from_generated_ts) {
@@ -548,6 +860,9 @@ public:
generated_ts_used = ts_try; generated_ts_used = ts_try;
used_generated_resync = true; used_generated_resync = true;
plausible = true; plausible = true;
diag_event("DEC fallback=generated-resync mt=%s csrcs=%zu enc_sz=%zu ts_try=%u step=%u probe=%u\n",
media_type_name(media_type), csrcs.size(), encrypted_frame.size(),
ts_try, generated_ts_.next_step, i);
break; break;
} }
} }
@@ -558,25 +873,68 @@ public:
generated_ts_.next_timestamp = generated_ts_used + step; generated_ts_.next_timestamp = generated_ts_used + step;
} }
// Diag: log first 3 frames // Diag: log first frames with enough context for crash analysis.
int n = diag_count_.fetch_add(1, std::memory_order_relaxed); int n = diag_count_.fetch_add(1, std::memory_order_relaxed);
if (n < 3) { if (n < kDiagFrameLimit) {
uint8_t ad_prefix[8] = {0};
const size_t ad_copy = additional_data.size() < sizeof(ad_prefix)
? additional_data.size()
: sizeof(ad_prefix);
if (ad_copy > 0) memcpy(ad_prefix, additional_data.data(), ad_copy);
ParsedRtpPacket enc_rtp{};
ParsedRtpPacket dec_rtp{};
const bool has_enc_rtp = parse_rtp_packet(encrypted_frame.data(), encrypted_frame.size(), &enc_rtp);
const bool has_dec_rtp = parse_rtp_packet(frame.data(), encrypted_frame.size(), &dec_rtp);
const bool dec_plausible = is_plausible_decrypted_audio_frame(frame.data(), encrypted_frame.size());
const uint32_t enc_hash = fnv1a32(encrypted_frame.data(), encrypted_frame.size(), kFrameHashSampleBytes);
const uint32_t dec_hash = fnv1a32(frame.data(), encrypted_frame.size(), kFrameHashSampleBytes);
const char* mode = nullptr; const char* mode = nullptr;
if (nonce_from_rtp_header) { if (nonce_from_rtp_header) {
mode = "rtp"; mode = "rtp";
} else if (nonce_from_generated_ts) { } else if (nonce_from_generated_ts) {
mode = used_generated_resync ? "gen-resync" : "gen"; mode = used_generated_resync ? "gen-resync" : "gen";
} else if (nonce_from_additional_data) { } else if (nonce_from_additional_data) {
mode = additional_was_rtp_header ? "ad-rtp" : "raw-abs"; if (used_plain_passthrough) mode = "raw-plain";
else if (additional_was_rtp_header) mode = "ad-rtp";
else if (used_additional_relative_fallback) mode = "raw-rel-fb";
else mode = additional_used_relative_ts ? "raw-rel" : "raw-abs";
} else { } else {
mode = "raw-abs"; mode = "raw-abs";
} }
LOGI("DEC frame#%d: enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce=%02x%02x%02x%02x", uint32_t bad_streak = 0;
n, encrypted_frame.size(), additional_data.size(), header_size, mode, if (!dec_plausible) {
nonce[4], nonce[5], nonce[6], nonce[7]); bad_streak = bad_audio_streak_.fetch_add(1, std::memory_order_relaxed) + 1;
diag_write("DEC frame#%d: enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce[4..7]=%02x%02x%02x%02x\n", if (bad_streak == 1 || bad_streak == 3 || bad_streak == 10 || (bad_streak % 50) == 0) {
n, encrypted_frame.size(), additional_data.size(), header_size, mode, diag_event("DEC degraded mt=%s csrcs=%zu mode=%s bad_streak=%u nonce_ts=%u key_fp=%08x\n",
nonce[4], nonce[5], nonce[6], nonce[7]); media_type_name(media_type), csrcs.size(), mode, bad_streak,
nonce_ts32(nonce), key_fingerprint_);
}
} else {
const uint32_t prev_bad = bad_audio_streak_.exchange(0, std::memory_order_relaxed);
if (prev_bad >= 3) {
diag_event("DEC recovered mt=%s csrcs=%zu mode=%s prev_bad_streak=%u nonce_ts=%u key_fp=%08x\n",
media_type_name(media_type), csrcs.size(), mode, prev_bad,
nonce_ts32(nonce), key_fingerprint_);
}
}
LOGI("DEC frame#%d mt=%s csrcs=%zu enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u dec_ok=%d bad_streak=%u enc_rtp=%d enc_seq=%u enc_ts=%u enc_ssrc=%u dec_rtp=%d dec_seq=%u dec_ts=%u dec_ssrc=%u key_fp=%08x enc_h=%08x dec_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x",
n, media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size(), header_size, mode,
nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
dec_plausible ? 1 : 0, bad_streak,
has_enc_rtp ? 1 : 0, has_enc_rtp ? enc_rtp.sequence : 0, has_enc_rtp ? enc_rtp.timestamp : 0, has_enc_rtp ? enc_rtp.ssrc : 0,
has_dec_rtp ? 1 : 0, has_dec_rtp ? dec_rtp.sequence : 0, has_dec_rtp ? dec_rtp.timestamp : 0, has_dec_rtp ? dec_rtp.ssrc : 0,
key_fingerprint_, enc_hash, dec_hash,
ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
diag_write("DEC frame#%d mt=%s csrcs=%zu enc_sz=%zu ad=%zu hdr=%zu mode=%s nonce_ts=%u gen_ts=%u next_step=%u dec_ok=%d bad_streak=%u enc_rtp=%d enc_seq=%u enc_ts=%u enc_ssrc=%u dec_rtp=%d dec_seq=%u dec_ts=%u dec_ssrc=%u key_fp=%08x enc_h=%08x dec_h=%08x ad8=%02x%02x%02x%02x%02x%02x%02x%02x\n",
n, media_type_name(media_type), csrcs.size(), encrypted_frame.size(), additional_data.size(), header_size, mode,
nonce_ts32(nonce), generated_ts_used, generated_ts_.next_step,
dec_plausible ? 1 : 0, bad_streak,
has_enc_rtp ? 1 : 0, has_enc_rtp ? enc_rtp.sequence : 0, has_enc_rtp ? enc_rtp.timestamp : 0, has_enc_rtp ? enc_rtp.ssrc : 0,
has_dec_rtp ? 1 : 0, has_dec_rtp ? dec_rtp.sequence : 0, has_dec_rtp ? dec_rtp.timestamp : 0, has_dec_rtp ? dec_rtp.ssrc : 0,
key_fingerprint_, enc_hash, dec_hash,
ad_prefix[0], ad_prefix[1], ad_prefix[2], ad_prefix[3],
ad_prefix[4], ad_prefix[5], ad_prefix[6], ad_prefix[7]);
} }
return {Result::Status::kOk, encrypted_frame.size()}; return {Result::Status::kOk, encrypted_frame.size()};
@@ -587,13 +945,20 @@ public:
} }
protected: protected:
~XChaCha20Decryptor() override { memset(key_, 0, 32); } ~XChaCha20Decryptor() override {
diag_event("DEC destroy ptr=%p key_fp=%08x bad_streak=%u\n",
this, key_fingerprint_, bad_audio_streak_.load(std::memory_order_relaxed));
memset(key_, 0, 32);
}
private: private:
mutable std::atomic<int> ref_{0}; mutable std::atomic<int> ref_{0};
mutable std::atomic<int> diag_count_{0}; mutable std::atomic<int> diag_count_{0};
mutable std::atomic<uint32_t> bad_audio_streak_{0};
mutable RtpProbeState rtp_probe_; mutable RtpProbeState rtp_probe_;
mutable GeneratedTsState generated_ts_; mutable GeneratedTsState generated_ts_;
mutable AdditionalTsState additional_rel_ts_state_;
uint32_t key_fingerprint_ = 0;
uint8_t key_[32]; uint8_t key_[32];
}; };
@@ -633,9 +998,14 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateEncryptor(
JNIEnv *env, jclass, jbyteArray jKey) JNIEnv *env, jclass, jbyteArray jKey)
{ {
jsize len = env->GetArrayLength(jKey); jsize len = env->GetArrayLength(jKey);
if (len < 32) return 0; if (len < 32) {
LOGE("Create encryptor failed: key length=%d (<32)", (int)len);
diag_event("ENC create-failed key_len=%d\n", (int)len);
return 0;
}
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr); auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
const uint32_t key_fp = key_fingerprint32(key);
auto *enc = new XChaCha20Encryptor(key); auto *enc = new XChaCha20Encryptor(key);
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT); env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
@@ -643,7 +1013,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateEncryptor(
// WebRTC's scoped_refptr will AddRef again when it takes ownership. // WebRTC's scoped_refptr will AddRef again when it takes ownership.
enc->AddRef(); enc->AddRef();
LOGI("Created XChaCha20 encryptor %p", enc); LOGI("Created XChaCha20 encryptor %p key_fp=%08x", enc, key_fp);
diag_event("ENC created ptr=%p key_fp=%08x key_len=%d\n", enc, key_fp, (int)len);
return reinterpret_cast<jlong>(enc); return reinterpret_cast<jlong>(enc);
} }
@@ -653,6 +1024,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseEncryptor(
{ {
if (ptr == 0) return; if (ptr == 0) return;
auto *enc = reinterpret_cast<XChaCha20Encryptor *>(ptr); auto *enc = reinterpret_cast<XChaCha20Encryptor *>(ptr);
LOGI("Release XChaCha20 encryptor %p key_fp=%08x", enc, enc->KeyFingerprint());
diag_event("ENC release ptr=%p key_fp=%08x\n", enc, enc->KeyFingerprint());
enc->Release(); enc->Release();
} }
@@ -663,15 +1036,21 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateDecryptor(
JNIEnv *env, jclass, jbyteArray jKey) JNIEnv *env, jclass, jbyteArray jKey)
{ {
jsize len = env->GetArrayLength(jKey); jsize len = env->GetArrayLength(jKey);
if (len < 32) return 0; if (len < 32) {
LOGE("Create decryptor failed: key length=%d (<32)", (int)len);
diag_event("DEC create-failed key_len=%d\n", (int)len);
return 0;
}
auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr); auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr);
const uint32_t key_fp = key_fingerprint32(key);
auto *dec = new XChaCha20Decryptor(key); auto *dec = new XChaCha20Decryptor(key);
env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT); env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT);
dec->AddRef(); dec->AddRef();
LOGI("Created XChaCha20 decryptor %p", dec); LOGI("Created XChaCha20 decryptor %p key_fp=%08x", dec, key_fp);
diag_event("DEC created ptr=%p key_fp=%08x key_len=%d\n", dec, key_fp, (int)len);
return reinterpret_cast<jlong>(dec); return reinterpret_cast<jlong>(dec);
} }
@@ -681,6 +1060,8 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseDecryptor(
{ {
if (ptr == 0) return; if (ptr == 0) return;
auto *dec = reinterpret_cast<XChaCha20Decryptor *>(ptr); auto *dec = reinterpret_cast<XChaCha20Decryptor *>(ptr);
LOGI("Release XChaCha20 decryptor %p key_fp=%08x", dec, dec->KeyFingerprint());
diag_event("DEC release ptr=%p key_fp=%08x\n", dec, dec->KeyFingerprint());
dec->Release(); dec->Release();
} }
@@ -712,6 +1093,7 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeOpenDiagFile(
JNIEnv *env, jclass, jstring jPath) JNIEnv *env, jclass, jstring jPath)
{ {
if (g_diag_fd >= 0) { close(g_diag_fd); g_diag_fd = -1; } if (g_diag_fd >= 0) { close(g_diag_fd); g_diag_fd = -1; }
g_diag_event_count.store(0, std::memory_order_relaxed);
const char *path = env->GetStringUTFChars(jPath, nullptr); const char *path = env->GetStringUTFChars(jPath, nullptr);
strncpy(g_diag_path, path, sizeof(g_diag_path) - 1); strncpy(g_diag_path, path, sizeof(g_diag_path) - 1);
@@ -719,8 +1101,9 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeOpenDiagFile(
env->ReleaseStringUTFChars(jPath, path); env->ReleaseStringUTFChars(jPath, path);
if (g_diag_fd >= 0) { if (g_diag_fd >= 0) {
diag_write("=== E2EE DIAGNOSTICS ===\n"); diag_write("=== E2EE DIAGNOSTICS pid=%d ===\n", (int)getpid());
LOGI("Diag file opened: %s", g_diag_path); LOGI("Diag file opened: %s", g_diag_path);
diag_event("DIAG open path=%s\n", g_diag_path);
} else { } else {
LOGE("Failed to open diag file: %s", g_diag_path); LOGE("Failed to open diag file: %s", g_diag_path);
} }
@@ -731,6 +1114,7 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCloseDiagFile(
JNIEnv *, jclass) JNIEnv *, jclass)
{ {
if (g_diag_fd >= 0) { if (g_diag_fd >= 0) {
diag_event("DIAG close path=%s\n", g_diag_path);
diag_write("=== END ===\n"); diag_write("=== END ===\n");
close(g_diag_fd); close(g_diag_fd);
g_diag_fd = -1; g_diag_fd = -1;

View File

@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/** /**
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out * 🚀 Telegram-style: Fixed Height Box + Fade In/Out
@@ -109,20 +109,4 @@ fun AnimatedKeyboardTransition(
content() content()
} }
} }
}
/**
* Алиас для обратной совместимости
*/
@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 android.os.Looper
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
var currentState by mutableStateOf(TransitionState.IDLE) var currentState by mutableStateOf(TransitionState.IDLE)
private set private set
var transitionProgress by mutableFloatStateOf(0f)
private set
// ============ Высоты ============ // ============ Высоты ============
var keyboardHeight by mutableStateOf(0.dp) var keyboardHeight by mutableStateOf(0.dp)
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
// Используется для отключения imePadding пока Box виден // Используется для отключения imePadding пока Box виден
var isEmojiBoxVisible by mutableStateOf(false) var isEmojiBoxVisible by mutableStateOf(false)
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
private var pendingShowEmojiCallback: (() -> Unit)? = null
// 📊 Для умного логирования (не каждый фрейм) // 📊 Для умного логирования (не каждый фрейм)
private var lastLogTime = 0L private var lastLogTime = 0L
private var lastLoggedHeight = -1f private var lastLoggedHeight = -1f
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
currentState = TransitionState.IDLE currentState = TransitionState.IDLE
isTransitioning = false isTransitioning = false
// Очищаем pending callback - больше не нужен
pendingShowEmojiCallback = null
} }
// ============ Главный метод: Emoji → Keyboard ============ // ============ Главный метод: Emoji → Keyboard ============
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
* плавно скрыть emoji. * плавно скрыть emoji.
*/ */
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) { fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
if (pendingShowEmojiCallback != null) {
pendingShowEmojiCallback = null
}
currentState = TransitionState.EMOJI_TO_KEYBOARD currentState = TransitionState.EMOJI_TO_KEYBOARD
isTransitioning = true isTransitioning = true
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji() // 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
} }
/** Обновить высоту emoji панели. */
fun updateEmojiHeight(height: Dp) {
if (height > 0.dp && height != emojiHeight) {
emojiHeight = height
}
}
/** /**
* Синхронизировать высоты (emoji = keyboard). * Синхронизировать высоты (emoji = keyboard).
* *
@@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator {
} }
} }
/**
* Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
* максимум из двух.
*/
fun getReservedHeight(): Dp {
return when {
isKeyboardVisible -> keyboardHeight
isEmojiVisible -> emojiHeight
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
else -> 0.dp
}
}
/** Проверка, можно ли начать новый переход. */
fun canStartTransition(): Boolean {
return !isTransitioning
}
/** Сброс состояния (для отладки). */
fun reset() {
currentState = TransitionState.IDLE
isTransitioning = false
isKeyboardVisible = false
isEmojiVisible = false
transitionProgress = 0f
}
/** Логирование текущего состояния. */
fun logState() {}
} }
/** Composable для создания и запоминания coordinator'а. */ /** Composable для создания и запоминания coordinator'а. */

View File

@@ -1379,6 +1379,11 @@ fun MainScreen(
}, },
onNavigateToCrashLogs = { onNavigateToCrashLogs = {
navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs
},
onNavigateToConnectionLogs = {
navStack =
navStack.filterNot { it is Screen.Search } +
Screen.ConnectionLogs
} }
) )
} }

View File

@@ -885,11 +885,10 @@ class MessageRepository private constructor(private val context: Context) {
unreadCount = dialog?.unreadCount ?: 0 unreadCount = dialog?.unreadCount ?: 0
) )
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа // 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа.
// Desktop parity: always re-fetch on incoming message so renamed contacts // Важно: не форсим повторный запрос на каждый входящий пакет — это создает
// get their new name/username updated in the chat list. // шторм PacketSearch во время sync и заметно тормозит обработку.
if (!isGroupDialogKey(dialogOpponentKey)) { if (!isGroupDialogKey(dialogOpponentKey)) {
requestedUserInfoKeys.remove(dialogOpponentKey)
requestUserInfo(dialogOpponentKey) requestUserInfo(dialogOpponentKey)
} else { } else {
applyGroupDisplayNameToDialog(account, dialogOpponentKey) applyGroupDisplayNameToDialog(account, dialogOpponentKey)

View File

@@ -17,12 +17,10 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Синхронизация 1 в 1 с desktop/server Защищенные звонки и диагностика E2EE
- Выровнен сетевой контракт пакетов как в desktop: добавлена поддержка 0x10 (push), 0x1A (signal), 0x1B (webrtc), 0x1C (ice) - Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
- Исправлена нормализация дубликатов своих сообщений из sync: локальные WAITING/ERROR теперь автоматически переходят в DELIVERED - Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
- Добавлен watchdog для sync-запроса: если ответ на PacketSync завис, запрос перезапускается автоматически - В Crash Reports добавлена кнопка копирования полного лога одним действием
- Повышена стабильность цикла BATCH_START/BATCH_END/NOT_NEEDED при reconnect
- Исправлена обработка PacketRead: read-статусы теперь ставятся как в desktop/wss, включая сценарии когда read приходит раньше delivery
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.util.Log import android.util.Log
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -87,6 +88,12 @@ object CallManager {
private const val TAG = "CallManager" private const val TAG = "CallManager"
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track" private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream" private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream"
private const val BREADCRUMB_FILE_NAME = "e2ee_breadcrumb.txt"
private const val DIAG_FILE_NAME = "e2ee_diag.txt"
private const val NATIVE_CRASH_FILE_NAME = "native_crash.txt"
private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 180
private const val MAX_LOG_PREFIX = 180
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
@@ -103,6 +110,11 @@ object CallManager {
private var roomId: String = "" private var roomId: String = ""
private var offerSent = false private var offerSent = false
private var remoteDescriptionSet = false private var remoteDescriptionSet = false
private var callSessionId: String = ""
private var callStartedAtMs: Long = 0L
private var keyExchangeSent = false
private var createRoomSent = false
private var lastPeerSharedPublicHex: String = ""
private var localPrivateKey: ByteArray? = null private var localPrivateKey: ByteArray? = null
private var localPublicKey: ByteArray? = null private var localPublicKey: ByteArray? = null
@@ -124,8 +136,12 @@ object CallManager {
// E2EE (XChaCha20 — compatible with Desktop) // E2EE (XChaCha20 — compatible with Desktop)
private var sharedKeyBytes: ByteArray? = null private var sharedKeyBytes: ByteArray? = null
private var senderEncryptor: XChaCha20E2EE.Encryptor? = null private val senderEncryptors = LinkedHashMap<String, XChaCha20E2EE.Encryptor>()
private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null private val receiverDecryptors = LinkedHashMap<String, XChaCha20E2EE.Decryptor>()
private var pendingAudioSenderForE2ee: RtpSender? = null
private var lastRemoteOfferFingerprint: String = ""
private var lastLocalOfferFingerprint: String = ""
private var e2eeRebindJob: Job? = null
private var iceServers: List<PeerConnection.IceServer> = emptyList() private var iceServers: List<PeerConnection.IceServer> = emptyList()
@@ -176,7 +192,9 @@ object CallManager {
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
resetSession(reason = null, notifyPeer = false) resetSession(reason = null, notifyPeer = false)
beginCallSession("outgoing:${targetKey.take(8)}")
role = CallRole.CALLER role = CallRole.CALLER
generateSessionKeys()
setPeer(targetKey, user.title, user.username) setPeer(targetKey, user.title, user.username)
updateState { updateState {
it.copy( it.copy(
@@ -190,6 +208,7 @@ object CallManager {
src = ownPublicKey, src = ownPublicKey,
dst = targetKey dst = targetKey
) )
breadcrumbState("startOutgoingCall")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -210,6 +229,7 @@ object CallManager {
dst = snapshot.peerPublicKey, dst = snapshot.peerPublicKey,
sharedPublic = localPublic.toHex() sharedPublic = localPublic.toHex()
) )
keyExchangeSent = true
updateState { updateState {
it.copy( it.copy(
@@ -217,6 +237,7 @@ object CallManager {
statusText = "Exchanging keys..." statusText = "Exchanging keys..."
) )
} }
breadcrumbState("acceptIncomingCall")
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -308,6 +329,7 @@ object CallManager {
} }
val incomingPeer = packet.src.trim() val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return if (incomingPeer.isBlank()) return
beginCallSession("incoming:${incomingPeer.take(8)}")
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
role = CallRole.CALLEE role = CallRole.CALLEE
resetRtcObjects() resetRtcObjects()
@@ -359,30 +381,45 @@ object CallManager {
breadcrumb("KE: ABORT — sharedPublic blank") breadcrumb("KE: ABORT — sharedPublic blank")
return return
} }
val duplicatePeerKey = lastPeerSharedPublicHex.equals(peerPublicHex, ignoreCase = true)
if (duplicatePeerKey && sharedKeyBytes != null) {
breadcrumb("KE: duplicate peer key ignored")
return
}
breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}") breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}")
lastPeerSharedPublicHex = peerPublicHex
if (role == CallRole.CALLER) { if (role == CallRole.CALLER) {
generateSessionKeys() if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("KE: CALLER — generating session keys (were null)")
generateSessionKeys()
}
val sharedKey = computeSharedSecretHex(peerPublicHex) val sharedKey = computeSharedSecretHex(peerPublicHex)
if (sharedKey == null) { if (sharedKey == null) {
breadcrumb("KE: CALLER — computeSharedSecret FAILED") breadcrumb("KE: CALLER — computeSharedSecret FAILED")
return return
} }
setupE2EE(sharedKey) setupE2EE(sharedKey)
breadcrumb("KE: CALLER — E2EE ready, sending KEY_EXCHANGE + CREATE_ROOM") breadcrumb("KE: CALLER — E2EE ready, sending missing signaling packets")
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") } updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") }
val localPublic = localPublicKey ?: return val localPublic = localPublicKey ?: return
ProtocolManager.sendCallSignal( if (!keyExchangeSent) {
signalType = SignalType.KEY_EXCHANGE, ProtocolManager.sendCallSignal(
src = ownPublicKey, signalType = SignalType.KEY_EXCHANGE,
dst = peerKey, src = ownPublicKey,
sharedPublic = localPublic.toHex() dst = peerKey,
) sharedPublic = localPublic.toHex()
ProtocolManager.sendCallSignal( )
signalType = SignalType.CREATE_ROOM, keyExchangeSent = true
src = ownPublicKey, }
dst = peerKey if (!createRoomSent) {
) ProtocolManager.sendCallSignal(
signalType = SignalType.CREATE_ROOM,
src = ownPublicKey,
dst = peerKey
)
createRoomSent = true
}
updateState { it.copy(phase = CallPhase.CONNECTING) } updateState { it.copy(phase = CallPhase.CONNECTING) }
return return
} }
@@ -406,6 +443,7 @@ object CallManager {
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) { private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
webRtcSignalMutex.withLock { webRtcSignalMutex.withLock {
val phase = _state.value.phase val phase = _state.value.phase
breadcrumb("RTC: packet=${packet.signalType} payloadLen=${packet.sdpOrCandidate.length} phase=$phase")
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) { if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase") breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
return@withLock return@withLock
@@ -435,6 +473,7 @@ object CallManager {
pc.setRemoteDescriptionAwait(answer) pc.setRemoteDescriptionAwait(answer)
remoteDescriptionSet = true remoteDescriptionSet = true
flushBufferedRemoteCandidates() flushBufferedRemoteCandidates()
attachReceiverE2EEFromPeerConnection()
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}") breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
} catch (e: Exception) { } catch (e: Exception) {
breadcrumb("RTC: ANSWER FAILED — ${e.message}") breadcrumb("RTC: ANSWER FAILED — ${e.message}")
@@ -457,12 +496,23 @@ object CallManager {
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored") breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
return@withLock return@withLock
} }
val offerFingerprint = remoteOffer.description.shortFingerprintHex(10)
val phaseNow = _state.value.phase
if (offerFingerprint == lastLocalOfferFingerprint) {
breadcrumb("RTC: OFFER loopback ignored fp=$offerFingerprint")
return@withLock
}
if (phaseNow == CallPhase.ACTIVE && offerFingerprint == lastRemoteOfferFingerprint) {
breadcrumb("RTC: OFFER duplicate in ACTIVE ignored fp=$offerFingerprint")
return@withLock
}
breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})") breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
try { try {
pc.setRemoteDescriptionAwait(remoteOffer) pc.setRemoteDescriptionAwait(remoteOffer)
remoteDescriptionSet = true remoteDescriptionSet = true
flushBufferedRemoteCandidates() flushBufferedRemoteCandidates()
attachReceiverE2EEFromPeerConnection()
val stateAfterRemote = pc.signalingState() val stateAfterRemote = pc.signalingState()
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER && if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
@@ -478,6 +528,8 @@ object CallManager {
signalType = WebRTCSignalType.ANSWER, signalType = WebRTCSignalType.ANSWER,
sdpOrCandidate = serializeSessionDescription(answer) sdpOrCandidate = serializeSessionDescription(answer)
) )
attachReceiverE2EEFromPeerConnection()
lastRemoteOfferFingerprint = offerFingerprint
breadcrumb("RTC: OFFER handled → ANSWER sent") breadcrumb("RTC: OFFER handled → ANSWER sent")
} catch (e: Exception) { } catch (e: Exception) {
breadcrumb("RTC: OFFER FAILED — ${e.message}") breadcrumb("RTC: OFFER FAILED — ${e.message}")
@@ -529,6 +581,7 @@ object CallManager {
if (audioSource == null) { if (audioSource == null) {
audioSource = factory.createAudioSource(MediaConstraints()) audioSource = factory.createAudioSource(MediaConstraints())
} }
var senderToAttach: RtpSender? = null
if (localAudioTrack == null) { if (localAudioTrack == null) {
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource) localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
localAudioTrack?.setEnabled(!_state.value.isMuted) localAudioTrack?.setEnabled(!_state.value.isMuted)
@@ -538,13 +591,27 @@ object CallManager {
listOf(LOCAL_MEDIA_STREAM_ID) listOf(LOCAL_MEDIA_STREAM_ID)
) )
val transceiver = pc.addTransceiver(localAudioTrack, txInit) val transceiver = pc.addTransceiver(localAudioTrack, txInit)
breadcrumb("PC: audio transceiver added, attaching E2EE…") senderToAttach = transceiver?.sender
attachSenderE2EE(transceiver?.sender) pendingAudioSenderForE2ee = senderToAttach
breadcrumb("PC: audio transceiver added (E2EE attach deferred)")
} else {
senderToAttach =
runCatching {
pc.senders.firstOrNull { sender ->
sender.track()?.kind() == "audio"
}
}.getOrNull()
if (senderToAttach != null) {
pendingAudioSenderForE2ee = senderToAttach
}
} }
attachSenderE2EE(pendingAudioSenderForE2ee ?: senderToAttach)
try { try {
val offer = pc.createOfferAwait() val offer = pc.createOfferAwait()
pc.setLocalDescriptionAwait(offer) pc.setLocalDescriptionAwait(offer)
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
ProtocolManager.sendWebRtcSignal( ProtocolManager.sendWebRtcSignal(
signalType = WebRTCSignalType.OFFER, signalType = WebRTCSignalType.OFFER,
sdpOrCandidate = serializeSessionDescription(offer) sdpOrCandidate = serializeSessionDescription(offer)
@@ -599,10 +666,12 @@ object CallManager {
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
override fun onTrack(transceiver: RtpTransceiver?) { override fun onTrack(transceiver: RtpTransceiver?) {
breadcrumb("PC: onTrack → attachReceiverE2EE") breadcrumb("PC: onTrack → attachReceiverE2EE")
attachReceiverE2EE(transceiver) attachReceiverE2EE(transceiver?.receiver)
attachReceiverE2EEFromPeerConnection()
} }
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
breadcrumb("PC: connState=$newState") breadcrumb("PC: connState=$newState")
breadcrumbState("onConnectionChange:$newState")
when (newState) { when (newState) {
PeerConnection.PeerConnectionState.CONNECTED -> { PeerConnection.PeerConnectionState.CONNECTED -> {
disconnectResetJob?.cancel() disconnectResetJob?.cancel()
@@ -721,6 +790,7 @@ object CallManager {
private fun resetSession(reason: String?, notifyPeer: Boolean) { private fun resetSession(reason: String?, notifyPeer: Boolean) {
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}") breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
breadcrumbState("resetSession")
val snapshot = _state.value val snapshot = _state.value
val wasActive = snapshot.phase != CallPhase.IDLE val wasActive = snapshot.phase != CallPhase.IDLE
val peerToNotify = snapshot.peerPublicKey val peerToNotify = snapshot.peerPublicKey
@@ -747,8 +817,17 @@ object CallManager {
roomId = "" roomId = ""
offerSent = false offerSent = false
remoteDescriptionSet = false remoteDescriptionSet = false
keyExchangeSent = false
createRoomSent = false
lastPeerSharedPublicHex = ""
lastRemoteOfferFingerprint = ""
lastLocalOfferFingerprint = ""
e2eeRebindJob?.cancel()
e2eeRebindJob = null
localPrivateKey = null localPrivateKey = null
localPublicKey = null localPublicKey = null
callSessionId = ""
callStartedAtMs = 0L
durationJob?.cancel() durationJob?.cancel()
durationJob = null durationJob = null
disconnectResetJob?.cancel() disconnectResetJob?.cancel()
@@ -792,6 +871,7 @@ object CallManager {
return return
} }
sharedKeyBytes = keyBytes.copyOf(32) sharedKeyBytes = keyBytes.copyOf(32)
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
// Open native diagnostics file for frame-level logging // Open native diagnostics file for frame-level logging
try { try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") val dir = java.io.File(appContext!!.filesDir, "crash_reports")
@@ -799,40 +879,209 @@ object CallManager {
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
XChaCha20E2EE.nativeOpenDiagFile(diagPath) XChaCha20E2EE.nativeOpenDiagFile(diagPath)
} catch (_: Throwable) {} } catch (_: Throwable) {}
// If sender track already exists, bind encryptor now.
val existingSender =
pendingAudioSenderForE2ee
?: runCatching {
peerConnection?.senders?.firstOrNull { sender -> sender.track()?.kind() == "audio" }
}.getOrNull()
if (existingSender != null) {
attachSenderE2EE(existingSender)
}
attachReceiverE2EEFromPeerConnection()
startE2EERebindLoopIfNeeded()
Log.i(TAG, "E2EE key ready (XChaCha20)") Log.i(TAG, "E2EE key ready (XChaCha20)")
} }
private fun startE2EERebindLoopIfNeeded() {
if (e2eeRebindJob?.isActive == true) return
e2eeRebindJob =
scope.launch {
while (true) {
delay(1500L)
if (!e2eeAvailable || sharedKeyBytes == null) continue
val phaseNow = _state.value.phase
if (phaseNow != CallPhase.CONNECTING && phaseNow != CallPhase.ACTIVE) continue
val pc = peerConnection ?: continue
val sender =
runCatching {
pc.senders.firstOrNull { it.track()?.kind() == "audio" }
}.getOrNull()
if (sender != null) {
attachSenderE2EE(sender)
}
attachReceiverE2EEFromPeerConnection()
}
}
}
private fun attachReceiverE2EEFromPeerConnection() {
val pc = peerConnection ?: return
runCatching {
var fromReceivers = 0
var fromTransceivers = 0
pc.receivers.forEach { receiver ->
if (isAudioReceiver(receiver)) {
attachReceiverE2EE(receiver)
fromReceivers++
}
}
pc.transceivers.forEach { transceiver ->
val receiver = transceiver.receiver ?: return@forEach
if (isAudioReceiver(receiver)) {
attachReceiverE2EE(receiver)
fromTransceivers++
}
}
breadcrumb("E2EE: scan receivers attached recv=$fromReceivers tx=$fromTransceivers totalMap=${receiverDecryptors.size}")
}.onFailure {
breadcrumb("E2EE: attachReceiverE2EEFromPeerConnection failed: ${it.message}")
}
}
/** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */ /** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
private fun breadcrumb(step: String) { private fun breadcrumb(step: String) {
try { try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") val dir = ensureCrashReportsDir() ?: return
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
val f = java.io.File(dir, "e2ee_breadcrumb.txt") val f = java.io.File(dir, BREADCRUMB_FILE_NAME)
// Reset file at start of key exchange // Reset file at start of key exchange
if (step.startsWith("KE:") && step.contains("agreement")) { if (step.startsWith("KE:") && step.contains("agreement")) {
f.writeText("") f.writeText("")
} }
f.appendText("${System.currentTimeMillis()} $step\n") val sidPrefix = if (callSessionId.isNotBlank()) "[sid=$callSessionId] " else ""
f.appendText("${System.currentTimeMillis()} $sidPrefix$step\n")
} catch (_: Throwable) {} } catch (_: Throwable) {}
} }
/** Save a full crash report to crash_reports/ */ /** Save a full crash report to crash_reports/ */
private fun saveCrashReport(title: String, error: Throwable) { private fun saveCrashReport(title: String, error: Throwable) {
try { try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") val dir = ensureCrashReportsDir() ?: return
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date()) val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date())
val f = java.io.File(dir, "crash_e2ee_$ts.txt") val f = java.io.File(dir, "crash_e2ee_$ts.txt")
val sw = java.io.StringWriter() val sw = java.io.StringWriter()
error.printStackTrace(java.io.PrintWriter(sw)) error.printStackTrace(java.io.PrintWriter(sw))
f.writeText("=== E2EE CRASH REPORT ===\n$title\n\nType: ${error.javaClass.name}\nMessage: ${error.message}\n\n$sw") val breadcrumbTail = readFileTail(java.io.File(dir, BREADCRUMB_FILE_NAME), TAIL_LINES)
val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES)
val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES)
val protocolTail =
ProtocolManager.debugLogs.value
.takeLast(PROTOCOL_LOG_TAIL_LINES)
.joinToString("\n")
f.writeText(
buildString {
appendLine("=== E2EE CRASH REPORT ===")
appendLine(title)
appendLine()
appendLine("Time: $ts")
appendLine("Type: ${error.javaClass.name}")
appendLine("Message: ${error.message}")
appendLine()
appendLine("--- CALL SNAPSHOT ---")
appendLine(buildStateSnapshot())
appendLine()
appendLine("--- STACKTRACE ---")
appendLine(sw.toString())
appendLine()
appendLine("--- NATIVE CRASH (tail) ---")
appendLine(nativeCrash)
appendLine()
appendLine("--- E2EE DIAG (tail) ---")
appendLine(diagTail)
appendLine()
appendLine("--- E2EE BREADCRUMB (tail) ---")
appendLine(breadcrumbTail)
appendLine()
appendLine("--- PROTOCOL LOGS (tail) ---")
appendLine(if (protocolTail.isBlank()) "<empty>" else protocolTail)
}
)
} catch (_: Throwable) {} } catch (_: Throwable) {}
} }
private fun beginCallSession(seed: String) {
val bytes = ByteArray(4)
secureRandom.nextBytes(bytes)
val random = bytes.joinToString("") { "%02x".format(it) }
callSessionId = "${seed.take(8)}-$random"
callStartedAtMs = System.currentTimeMillis()
breadcrumb("SESSION: begin seed=$seed")
}
private fun ensureCrashReportsDir(): java.io.File? {
val context = appContext ?: return null
return java.io.File(context.filesDir, "crash_reports").apply { if (!exists()) mkdirs() }
}
private fun readFileTail(file: java.io.File, maxLines: Int): String {
if (!file.exists()) return "<missing: ${file.name}>"
return runCatching {
val lines = file.readLines()
val tail = if (lines.size <= maxLines) lines else lines.takeLast(maxLines)
if (tail.isEmpty()) "<empty: ${file.name}>" else tail.joinToString("\n")
}.getOrElse { "<read-failed: ${file.name}: ${it.message}>" }
}
private fun buildStateSnapshot(): String {
val st = _state.value
val now = System.currentTimeMillis()
val age = if (callStartedAtMs > 0L) now - callStartedAtMs else -1L
val pc = peerConnection
val pcSig = runCatching { pc?.signalingState() }.getOrNull()
val pcIce = runCatching { pc?.iceConnectionState() }.getOrNull()
val pcConn = runCatching { pc?.connectionState() }.getOrNull()
val pcLocal = runCatching { pc?.localDescription?.type?.canonicalForm() }.getOrDefault("-")
val pcRemote = runCatching { pc?.remoteDescription?.type?.canonicalForm() }.getOrDefault("-")
val senders = runCatching { pc?.senders?.size ?: 0 }.getOrDefault(-1)
val receivers = runCatching { pc?.receivers?.size ?: 0 }.getOrDefault(-1)
return buildString {
append("sid=").append(if (callSessionId.isBlank()) "<none>" else callSessionId)
append(" ageMs=").append(age)
append(" phase=").append(st.phase)
append(" role=").append(role)
append(" peer=").append(st.peerPublicKey.take(12))
append(" room=").append(roomId.take(16))
append(" offerSent=").append(offerSent)
append(" remoteDescSet=").append(remoteDescriptionSet)
append(" e2eeAvail=").append(e2eeAvailable)
append(" keyBytes=").append(sharedKeyBytes?.size ?: 0)
append(" pc(sig=").append(pcSig)
append(",ice=").append(pcIce)
append(",conn=").append(pcConn)
append(",local=").append(pcLocal)
append(",remote=").append(pcRemote)
append(",senders=").append(senders)
append(",receivers=").append(receivers)
append(")")
}
}
private fun breadcrumbState(marker: String) {
breadcrumb("STATE[$marker] ${buildStateSnapshot()}")
}
private fun senderMapKey(sender: RtpSender): String {
val id = runCatching { sender.id() }.getOrNull().orEmpty()
return if (id.isNotBlank()) "sid:$id" else "sender@${System.identityHashCode(sender)}"
}
private fun receiverMapKey(receiver: RtpReceiver): String {
val id = runCatching { receiver.id() }.getOrNull().orEmpty()
return if (id.isNotBlank()) "rid:$id" else "recv@${System.identityHashCode(receiver)}"
}
private fun attachSenderE2EE(sender: RtpSender?) { private fun attachSenderE2EE(sender: RtpSender?) {
if (!e2eeAvailable) return if (!e2eeAvailable) return
val key = sharedKeyBytes ?: return val key = sharedKeyBytes ?: return
if (sender == null) return if (sender == null) return
val mapKey = senderMapKey(sender)
val existing = senderEncryptors[mapKey]
if (existing != null) {
runCatching { sender.setFrameEncryptor(existing) }
return
}
try { try {
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}") breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
@@ -847,7 +1096,8 @@ object CallManager {
breadcrumb("4. calling sender.setFrameEncryptor…") breadcrumb("4. calling sender.setFrameEncryptor…")
sender.setFrameEncryptor(enc) sender.setFrameEncryptor(enc)
breadcrumb("5. setFrameEncryptor OK!") breadcrumb("5. setFrameEncryptor OK!")
senderEncryptor = enc senderEncryptors[mapKey] = enc
pendingAudioSenderForE2ee = null
} catch (e: Throwable) { } catch (e: Throwable) {
saveCrashReport("attachSenderE2EE failed", e) saveCrashReport("attachSenderE2EE failed", e)
Log.e(TAG, "E2EE: sender encryptor failed", e) Log.e(TAG, "E2EE: sender encryptor failed", e)
@@ -855,10 +1105,21 @@ object CallManager {
} }
} }
private fun attachReceiverE2EE(transceiver: RtpTransceiver?) { private fun isAudioReceiver(receiver: RtpReceiver?): Boolean {
if (receiver == null) return false
return runCatching { receiver.track()?.kind() == "audio" }.getOrDefault(false)
}
private fun attachReceiverE2EE(receiver: RtpReceiver?) {
if (!e2eeAvailable) return if (!e2eeAvailable) return
val key = sharedKeyBytes ?: return val key = sharedKeyBytes ?: return
val receiver = transceiver?.receiver ?: return if (receiver == null) return
val mapKey = receiverMapKey(receiver)
val existing = receiverDecryptors[mapKey]
if (existing != null) {
runCatching { receiver.setFrameDecryptor(existing) }
return
}
try { try {
breadcrumb("6. decryptor: creating…") breadcrumb("6. decryptor: creating…")
@@ -873,7 +1134,7 @@ object CallManager {
breadcrumb("9. calling receiver.setFrameDecryptor…") breadcrumb("9. calling receiver.setFrameDecryptor…")
receiver.setFrameDecryptor(dec) receiver.setFrameDecryptor(dec)
breadcrumb("10. setFrameDecryptor OK!") breadcrumb("10. setFrameDecryptor OK!")
receiverDecryptor = dec receiverDecryptors[mapKey] = dec
} catch (e: Throwable) { } catch (e: Throwable) {
saveCrashReport("attachReceiverE2EE failed", e) saveCrashReport("attachReceiverE2EE failed", e)
Log.e(TAG, "E2EE: receiver decryptor failed", e) Log.e(TAG, "E2EE: receiver decryptor failed", e)
@@ -885,10 +1146,15 @@ object CallManager {
// Release our ref. WebRTC holds its own ref via scoped_refptr. // Release our ref. WebRTC holds its own ref via scoped_refptr.
// After our Release: WebRTC ref remains. On peerConnection.close() // After our Release: WebRTC ref remains. On peerConnection.close()
// WebRTC releases its ref → ref=0 → native object deleted. // WebRTC releases its ref → ref=0 → native object deleted.
runCatching { senderEncryptor?.dispose() } senderEncryptors.values.forEach { enc ->
runCatching { receiverDecryptor?.dispose() } runCatching { enc.dispose() }
senderEncryptor = null }
receiverDecryptor = null receiverDecryptors.values.forEach { dec ->
runCatching { dec.dispose() }
}
senderEncryptors.clear()
receiverDecryptors.clear()
pendingAudioSenderForE2ee = null
sharedKeyBytes?.let { it.fill(0) } sharedKeyBytes?.let { it.fill(0) }
sharedKeyBytes = null sharedKeyBytes = null
runCatching { XChaCha20E2EE.nativeCloseDiagFile() } runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
@@ -896,11 +1162,12 @@ object CallManager {
private fun generateSessionKeys() { private fun generateSessionKeys() {
val privateKey = ByteArray(32) val privateKey = ByteArray(32)
secureRandom.nextBytes(privateKey) X25519.generatePrivateKey(secureRandom, privateKey)
val publicKey = ByteArray(32) val publicKey = ByteArray(32)
X25519.generatePublicKey(privateKey, 0, publicKey, 0) X25519.generatePublicKey(privateKey, 0, publicKey, 0)
localPrivateKey = privateKey localPrivateKey = privateKey
localPublicKey = publicKey localPublicKey = publicKey
breadcrumb("KE: local keypair pub=${publicKey.shortHex()} privFp=${privateKey.fingerprintHex(6)}")
} }
private fun computeSharedSecretHex(peerPublicHex: String): String? { private fun computeSharedSecretHex(peerPublicHex: String): String? {
@@ -908,17 +1175,17 @@ object CallManager {
val peerPublic = peerPublicHex.hexToBytes() ?: return null val peerPublic = peerPublicHex.hexToBytes() ?: return null
if (peerPublic.size != 32) return null if (peerPublic.size != 32) return null
val rawDh = ByteArray(32) val rawDh = ByteArray(32)
breadcrumb("KE: X25519 agreement…") breadcrumb("KE: X25519 agreement with peerPub=${peerPublic.shortHex()}")
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0) val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
if (!ok) { if (!ok) {
breadcrumb("KE: X25519 FAILED") breadcrumb("KE: X25519 FAILED")
return null return null
} }
breadcrumb("KE: X25519 OK, calling HSalsa20…") breadcrumb("KE: X25519 OK rawDhFp=${rawDh.fingerprintHex(8)}, calling HSalsa20…")
return try { return try {
val naclShared = XChaCha20E2EE.hsalsa20(rawDh) val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
rawDh.fill(0) rawDh.fill(0)
breadcrumb("KE: HSalsa20 OK, key ready") breadcrumb("KE: HSalsa20 OK keyFp=${naclShared.fingerprintHex(8)}")
naclShared.toHex() naclShared.toHex()
} catch (e: Throwable) { } catch (e: Throwable) {
saveCrashReport("HSalsa20 failed", e) saveCrashReport("HSalsa20 failed", e)
@@ -943,6 +1210,12 @@ object CallManager {
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type")) val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
val sdp = json.getString("sdp") val sdp = json.getString("sdp")
SessionDescription(type, sdp) SessionDescription(type, sdp)
}.onFailure { error ->
val preview = raw.replace('\n', ' ').replace('\r', ' ')
breadcrumb(
"RTC: parseSessionDescription FAILED len=${raw.length} " +
"preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
)
}.getOrNull() }.getOrNull()
} }
@@ -961,6 +1234,12 @@ object CallManager {
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0) val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
IceCandidate(sdpMid, sdpMLineIndex, candidate) IceCandidate(sdpMid, sdpMLineIndex, candidate)
}.onFailure { error ->
val preview = raw.replace('\n', ' ').replace('\r', ' ')
breadcrumb(
"RTC: parseIceCandidate FAILED len=${raw.length} " +
"preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
)
}.getOrNull() }.getOrNull()
} }
@@ -976,6 +1255,16 @@ object CallManager {
} }
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun ByteArray.shortHex(bytes: Int = 6): String =
take(bytes.coerceAtMost(size)).joinToString("") { "%02x".format(it) }
private fun ByteArray.fingerprintHex(bytes: Int = 8): String {
val digest = MessageDigest.getInstance("SHA-256").digest(this)
return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
}
private fun String.shortFingerprintHex(bytes: Int = 8): String {
val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray(Charsets.UTF_8))
return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
}
private fun String.hexToBytes(): ByteArray? { private fun String.hexToBytes(): ByteArray? {
val clean = trim().lowercase() val clean = trim().lowercase()

View File

@@ -6,7 +6,8 @@ enum class HandshakeState(val value: Int) {
companion object { companion object {
fun fromValue(value: Int): HandshakeState { fun fromValue(value: Int): HandshakeState {
return entries.firstOrNull { it.value == value } ?: COMPLETED // Fail-safe: unknown value must not auto-authenticate.
return entries.firstOrNull { it.value == value } ?: NEED_DEVICE_VERIFICATION
} }
} }
} }

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,10 @@ class Protocol(
private const val TAG = "RosettaProtocol" private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве) private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each) private const val MIN_PACKET_ID_BITS = 16 // Stream.readInt16() reads exactly 16 bits
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
} }
private fun log(message: String) { private fun log(message: String) {
@@ -112,6 +115,9 @@ class Protocol(
// Heartbeat // Heartbeat
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
@Volatile private var heartbeatPeriodMs: Long = 0L
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var heartbeatOkSuppressedCount: Int = 0
// Supported packets // Supported packets
private val supportedPackets = mapOf( private val supportedPackets = mapOf(
@@ -179,11 +185,24 @@ class Protocol(
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом * Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
*/ */
private fun startHeartbeat(intervalSeconds: Int) { private fun startHeartbeat(intervalSeconds: Int) {
val normalizedServerIntervalSec =
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
// Отправляем чаще - каждые 1/3 интервала, но с нижним лимитом чтобы исключить tight-loop.
val intervalMs =
((normalizedServerIntervalSec * 1000L) / 3).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
if (heartbeatJob?.isActive == true && heartbeatPeriodMs == intervalMs) {
return
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatPeriodMs = intervalMs
// Отправляем чаще - каждые 1/3 интервала (чтобы не терять соединение) lastHeartbeatOkLogAtMs = 0L
val intervalMs = (intervalSeconds * 1000L) / 3 heartbeatOkSuppressedCount = 0
log("💓 HEARTBEAT START: server=${intervalSeconds}s, sending=${intervalMs/1000}s, state=${_state.value}") log(
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
"sending=${intervalMs / 1000}s, state=${_state.value}"
)
heartbeatJob = scope.launch { heartbeatJob = scope.launch {
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве) // ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
@@ -210,7 +229,17 @@ class Protocol(
) { ) {
val sent = webSocket?.send("heartbeat") ?: false val sent = webSocket?.send("heartbeat") ?: false
if (sent) { if (sent) {
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState)") val now = System.currentTimeMillis()
if (now - lastHeartbeatOkLogAtMs >= HEARTBEAT_OK_LOG_THROTTLE_MS) {
val suppressed = heartbeatOkSuppressedCount
heartbeatOkSuppressedCount = 0
lastHeartbeatOkLogAtMs = now
val suffix =
if (suppressed > 0) ", +$suppressed suppressed" else ""
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState$suffix)")
} else {
heartbeatOkSuppressedCount++
}
} else { } else {
log("💔 HEARTBEAT FAILED: socket=$socketAlive, state=$currentState, manuallyClosed=$isManuallyClosed") log("💔 HEARTBEAT FAILED: socket=$socketAlive, state=$currentState, manuallyClosed=$isManuallyClosed")
// Триггерим reconnect если heartbeat не прошёл // Триггерим reconnect если heartbeat не прошёл
@@ -506,52 +535,40 @@ class Protocol(
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}") log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
val stream = Stream(data) val stream = Stream(data)
var parsedPackets = 0 if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
log("⚠️ Frame too short to contain packet ID (${stream.getRemainingBits()} bits)")
return
}
// Desktop/server parity: one WebSocket frame contains one packet.
val packetId = stream.readInt16()
log("📥 Packet ID: $packetId")
while (stream.getRemainingBits() >= MIN_PACKET_ID_BITS) { val packetFactory = supportedPackets[packetId]
val packetStartBits = stream.getReadPointerBits() if (packetFactory == null) {
val packetId = stream.readInt16() log("⚠️ Unknown packet ID: $packetId")
return
log("📥 Packet ID: $packetId")
val packetFactory = supportedPackets[packetId]
if (packetFactory == null) {
log("⚠️ Unknown packet ID: $packetId, stopping frame parse")
break
}
val packet = packetFactory()
try {
packet.receive(stream)
} catch (e: Exception) {
log("❌ Error parsing packet $packetId: ${e.message}")
e.printStackTrace()
break
}
// Notify waiters
val waitersCount = packetWaiters[packetId]?.size ?: 0
log("📥 Notifying $waitersCount waiter(s) for packet $packetId")
packetWaiters[packetId]?.forEach { callback ->
try {
callback(packet)
} catch (e: Exception) {
log("❌ Error in packet handler: ${e.message}")
e.printStackTrace()
}
}
parsedPackets++
val consumedBits = stream.getReadPointerBits() - packetStartBits
if (consumedBits <= 0) {
log("⚠️ Packet parser made no progress for packet $packetId, stopping frame parse")
break
}
} }
if (parsedPackets > 1) { val packet = packetFactory()
log("📦 Parsed $parsedPackets packets from single WebSocket frame") try {
packet.receive(stream)
} catch (e: Exception) {
log("❌ Error parsing packet $packetId: ${e.message}")
e.printStackTrace()
return
}
// Notify waiters
val waitersCount = packetWaiters[packetId]?.size ?: 0
log("📥 Notifying $waitersCount waiter(s) for packet $packetId")
packetWaiters[packetId]?.forEach { callback ->
try {
callback(packet)
} catch (e: Exception) {
log("❌ Error in packet handler: ${e.message}")
e.printStackTrace()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
log("❌ Error parsing packet: ${e.message}") log("❌ Error parsing packet: ${e.message}")
@@ -573,6 +590,7 @@ class Protocol(
handshakeComplete = false handshakeComplete = false
handshakeJob?.cancel() handshakeJob?.cancel()
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatPeriodMs = 0L
// Автоматический reconnect с защитой от бесконечных попыток // Автоматический reconnect с защитой от бесконечных попыток
if (!isManuallyClosed) { if (!isManuallyClosed) {
@@ -628,6 +646,7 @@ class Protocol(
reconnectJob = null reconnectJob = null
handshakeJob?.cancel() handshakeJob?.cancel()
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatPeriodMs = 0L
webSocket?.close(1000, "User disconnected") webSocket?.close(1000, "User disconnected")
webSocket = null webSocket = null
_state.value = ProtocolState.DISCONNECTED _state.value = ProtocolState.DISCONNECTED

View File

@@ -6,6 +6,7 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.utils.MessageLogger
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,6 +31,7 @@ object ProtocolManager {
private const val SYNC_REQUEST_TIMEOUT_MS = 12_000L private const val SYNC_REQUEST_TIMEOUT_MS = 12_000L
private const val MAX_DEBUG_LOGS = 600 private const val MAX_DEBUG_LOGS = 600
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
private const val PACKET_SIGNAL_PEER = 0x1A private const val PACKET_SIGNAL_PEER = 0x1A
private const val PACKET_WEB_RTC = 0x1B private const val PACKET_WEB_RTC = 0x1B
@@ -61,6 +63,8 @@ object ProtocolManager {
private val debugLogsLock = Any() private val debugLogsLock = Any()
@Volatile private var debugFlushJob: Job? = null @Volatile private var debugFlushJob: Job? = null
private val debugFlushPending = AtomicBoolean(false) private val debugFlushPending = AtomicBoolean(false)
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
// Typing status // Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet()) private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
@@ -92,8 +96,8 @@ object ProtocolManager {
private fun normalizeSearchQuery(value: String): String = private fun normalizeSearchQuery(value: String): String =
value.trim().removePrefix("@").lowercase(Locale.ROOT) value.trim().removePrefix("@").lowercase(Locale.ROOT)
// UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS. // Keep heavy protocol/message UI logs disabled by default.
private var uiLogsEnabled = true private var uiLogsEnabled = false
private var lastProtocolState: ProtocolState? = null private var lastProtocolState: ProtocolState? = null
@Volatile private var syncBatchInProgress = false @Volatile private var syncBatchInProgress = false
private val _syncInProgress = MutableStateFlow(false) private val _syncInProgress = MutableStateFlow(false)
@@ -131,9 +135,23 @@ object ProtocolManager {
fun addLog(message: String) { fun addLog(message: String) {
if (!uiLogsEnabled) return if (!uiLogsEnabled) return
var normalizedMessage = message
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
if (isHeartbeatOk) {
val now = System.currentTimeMillis()
if (now - lastHeartbeatOkLogAtMs < HEARTBEAT_OK_LOG_MIN_INTERVAL_MS) {
suppressedHeartbeatOkLogs++
return
}
if (suppressedHeartbeatOkLogs > 0) {
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
suppressedHeartbeatOkLogs = 0
}
lastHeartbeatOkLogAtMs = now
}
val timestamp = val timestamp =
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val line = "[$timestamp] $message" val line = "[$timestamp] $normalizedMessage"
synchronized(debugLogsLock) { synchronized(debugLogsLock) {
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) { if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
debugLogsBuffer.removeFirst() debugLogsBuffer.removeFirst()
@@ -145,6 +163,7 @@ object ProtocolManager {
fun enableUILogs(enabled: Boolean) { fun enableUILogs(enabled: Boolean) {
uiLogsEnabled = enabled uiLogsEnabled = enabled
MessageLogger.setEnabled(enabled)
if (enabled) { if (enabled) {
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() } val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
_debugLogs.value = snapshot _debugLogs.value = snapshot
@@ -157,6 +176,8 @@ object ProtocolManager {
synchronized(debugLogsLock) { synchronized(debugLogsLock) {
debugLogsBuffer.clear() debugLogsBuffer.clear()
} }
suppressedHeartbeatOkLogs = 0
lastHeartbeatOkLogAtMs = 0L
_debugLogs.value = emptyList() _debugLogs.value = emptyList()
} }

View File

@@ -1,163 +1,332 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
/** /**
* Binary stream for protocol packets * Binary stream for protocol packets.
* Matches the React Native implementation exactly * Ported from desktop/dev stream.ts implementation.
*/ */
class Stream(stream: ByteArray = ByteArray(0)) { class Stream(stream: ByteArray = ByteArray(0)) {
private var _stream = mutableListOf<Int>() private var stream: ByteArray
private var _readPointer = 0 private var readPointer = 0 // bits
private var _writePointer = 0 private var writePointer = 0 // bits
init { init {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList() if (stream.isEmpty()) {
this.stream = ByteArray(0)
} else {
this.stream = stream.copyOf()
this.writePointer = this.stream.size shl 3
}
} }
fun getStream(): ByteArray { fun getStream(): ByteArray {
return _stream.map { it.toByte() }.toByteArray() return stream.copyOf(length())
} }
fun getReadPointerBits(): Int = _readPointer fun setStream(stream: ByteArray = ByteArray(0)) {
if (stream.isEmpty()) {
fun getTotalBits(): Int = _stream.size * 8 this.stream = ByteArray(0)
this.readPointer = 0
fun getRemainingBits(): Int = getTotalBits() - _readPointer this.writePointer = 0
return
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
fun setStream(stream: ByteArray) {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
_readPointer = 0
}
fun writeInt8(value: Int) {
val negationBit = if (value < 0) 1 else 0
val int8Value = Math.abs(value) and 0xFF
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
_writePointer++
for (i in 0 until 8) {
val bit = (int8Value shr (7 - i)) and 1
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
} }
this.stream = stream.copyOf()
this.readPointer = 0
this.writePointer = this.stream.size shl 3
} }
fun readInt8(): Int { fun getBuffer(): ByteArray = getStream()
var value = 0
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1 fun isEmpty(): Boolean = writePointer == 0
_readPointer++
fun length(): Int = (writePointer + 7) shr 3
for (i in 0 until 8) {
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1 fun getReadPointerBits(): Int = readPointer
value = value or (bit shl (7 - i))
_readPointer++ fun getTotalBits(): Int = writePointer
}
fun getRemainingBits(): Int = writePointer - readPointer
return if (negationBit == 1) -value else value
} fun hasRemainingBits(): Boolean = readPointer < writePointer
fun writeBit(value: Int) { fun writeBit(value: Int) {
val bit = value and 1 writeBits((value and 1).toULong(), 1)
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
} }
fun readBit(): Int { fun readBit(): Int = readBits(1).toInt()
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
return bit
}
fun writeBoolean(value: Boolean) { fun writeBoolean(value: Boolean) {
writeBit(if (value) 1 else 0) writeBit(if (value) 1 else 0)
} }
fun readBoolean(): Boolean { fun readBoolean(): Boolean = readBit() == 1
return readBit() == 1
fun writeByte(value: Int) {
writeUInt8(value and 0xFF)
} }
fun readByte(): Int {
val value = readUInt8()
return if (value >= 0x80) value - 0x100 else value
}
fun writeUInt8(value: Int) {
val v = value and 0xFF
if ((writePointer and 7) == 0) {
reserveBits(8)
stream[writePointer shr 3] = v.toByte()
writePointer += 8
return
}
writeBits(v.toULong(), 8)
}
fun readUInt8(): Int {
if (remainingBits() < 8L) {
throw IllegalStateException("Not enough bits to read UInt8")
}
if ((readPointer and 7) == 0) {
val value = stream[readPointer shr 3].toInt() and 0xFF
readPointer += 8
return value
}
return readBits(8).toInt()
}
fun writeInt8(value: Int) {
writeUInt8(value)
}
fun readInt8(): Int {
val value = readUInt8()
return if (value >= 0x80) value - 0x100 else value
}
fun writeUInt16(value: Int) {
val v = value and 0xFFFF
writeUInt8((v ushr 8) and 0xFF)
writeUInt8(v and 0xFF)
}
fun readUInt16(): Int {
val hi = readUInt8()
val lo = readUInt8()
return (hi shl 8) or lo
}
fun writeInt16(value: Int) { fun writeInt16(value: Int) {
writeInt8(value shr 8) writeUInt16(value)
writeInt8(value and 0xFF)
} }
fun readInt16(): Int { fun readInt16(): Int {
val high = readInt8() shl 8 val value = readUInt16()
return high or readInt8() return if (value >= 0x8000) value - 0x10000 else value
} }
fun writeUInt32(value: Long) {
if (value < 0L || value > 0xFFFF_FFFFL) {
throw IllegalArgumentException("UInt32 out of range: $value")
}
writeUInt8(((value ushr 24) and 0xFF).toInt())
writeUInt8(((value ushr 16) and 0xFF).toInt())
writeUInt8(((value ushr 8) and 0xFF).toInt())
writeUInt8((value and 0xFF).toInt())
}
fun readUInt32(): Long {
val b1 = readUInt8().toLong()
val b2 = readUInt8().toLong()
val b3 = readUInt8().toLong()
val b4 = readUInt8().toLong()
return ((b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4) and 0xFFFF_FFFFL
}
fun writeInt32(value: Int) { fun writeInt32(value: Int) {
writeInt16(value shr 16) writeUInt32(value.toLong() and 0xFFFF_FFFFL)
writeInt16(value and 0xFFFF)
} }
fun readInt32(): Int { fun readInt32(): Int = readUInt32().toInt()
val high = readInt16() shl 16
return high or readInt16() fun writeUInt64(value: ULong) {
writeUInt8(((value shr 56) and 0xFFu).toInt())
writeUInt8(((value shr 48) and 0xFFu).toInt())
writeUInt8(((value shr 40) and 0xFFu).toInt())
writeUInt8(((value shr 32) and 0xFFu).toInt())
writeUInt8(((value shr 24) and 0xFFu).toInt())
writeUInt8(((value shr 16) and 0xFFu).toInt())
writeUInt8(((value shr 8) and 0xFFu).toInt())
writeUInt8((value and 0xFFu).toInt())
} }
fun writeInt64(value: Long) { fun readUInt64(): ULong {
val high = (value shr 32).toInt() val high = readUInt32().toULong()
val low = (value and 0xFFFFFFFF).toInt() val low = readUInt32().toULong()
writeInt32(high)
writeInt32(low)
}
fun readInt64(): Long {
val high = readInt32().toLong()
val low = (readInt32().toLong() and 0xFFFFFFFFL)
return (high shl 32) or low return (high shl 32) or low
} }
fun writeString(value: String) { fun writeInt64(value: Long) {
writeInt32(value.length) writeUInt64(value.toULong())
for (char in value) { }
writeInt16(char.code)
fun readInt64(): Long = readUInt64().toLong()
fun writeFloat32(value: Float) {
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL
writeUInt32(bits)
}
fun readFloat32(): Float {
val bits = readUInt32().toInt()
return Float.fromBits(bits)
}
fun writeString(value: String?) {
val str = value ?: ""
writeUInt32(str.length.toLong())
if (str.isEmpty()) return
reserveBits(str.length.toLong() * 16L)
for (i in str.indices) {
writeUInt16(str[i].code and 0xFFFF)
} }
} }
fun readString(): String { fun readString(): String {
val length = readInt32() val len = readUInt32()
// Desktop parity + safety: don't trust malformed string length. if (len > Int.MAX_VALUE.toLong()) {
val bytesAvailable = _stream.size - (_readPointer shr 3) throw IllegalStateException("String length too large: $len")
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
android.util.Log.w(
"RosettaStream",
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
)
return ""
} }
val sb = StringBuilder()
for (i in 0 until length) { val requiredBits = len * 16L
sb.append(readInt16().toChar()) if (requiredBits > remainingBits()) {
throw IllegalStateException("Not enough bits to read string")
} }
return sb.toString()
val chars = CharArray(len.toInt())
for (i in chars.indices) {
chars[i] = readUInt16().toChar()
}
return String(chars)
} }
fun writeBytes(value: ByteArray) { fun writeBytes(value: ByteArray?) {
writeInt32(value.size) val bytes = value ?: ByteArray(0)
for (byte in value) { writeUInt32(bytes.size.toLong())
writeInt8(byte.toInt()) if (bytes.isEmpty()) return
reserveBits(bytes.size.toLong() * 8L)
if ((writePointer and 7) == 0) {
val byteIndex = writePointer shr 3
ensureCapacity(byteIndex + bytes.size - 1)
System.arraycopy(bytes, 0, stream, byteIndex, bytes.size)
writePointer += bytes.size shl 3
return
}
for (b in bytes) {
writeUInt8(b.toInt() and 0xFF)
} }
} }
fun readBytes(): ByteArray { fun readBytes(): ByteArray {
val length = readInt32() val len = readUInt32()
val bytes = ByteArray(length) if (len == 0L) return ByteArray(0)
for (i in 0 until length) { if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
bytes[i] = readInt8().toByte()
val requiredBits = len * 8L
if (requiredBits > remainingBits()) {
return ByteArray(0)
} }
return bytes
val out = ByteArray(len.toInt())
if ((readPointer and 7) == 0) {
val byteIndex = readPointer shr 3
System.arraycopy(stream, byteIndex, out, 0, out.size)
readPointer += out.size shl 3
return out
}
for (i in out.indices) {
out[i] = readUInt8().toByte()
}
return out
} }
private fun ensureCapacity(index: Int) { private fun remainingBits(): Long = (writePointer - readPointer).toLong()
while (_stream.size <= index) {
_stream.add(0) private fun writeBits(value: ULong, bits: Int) {
if (bits <= 0) return
reserveBits(bits.toLong())
for (i in bits - 1 downTo 0) {
val bit = ((value shr i) and 1u).toInt()
val byteIndex = writePointer shr 3
val shift = 7 - (writePointer and 7)
if (bit == 1) {
stream[byteIndex] = (stream[byteIndex].toInt() or (1 shl shift)).toByte()
} else {
stream[byteIndex] = (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
}
writePointer++
} }
} }
private fun readBits(bits: Int): ULong {
if (bits <= 0) return 0u
if (remainingBits() < bits.toLong()) {
throw IllegalStateException("Not enough bits to read")
}
var value = 0uL
repeat(bits) {
val bit = (stream[readPointer shr 3].toInt() ushr (7 - (readPointer and 7))) and 1
value = (value shl 1) or bit.toULong()
readPointer++
}
return value
}
private fun reserveBits(bitsToWrite: Long) {
if (bitsToWrite <= 0L) return
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
if (lastBitIndex < 0L) {
throw IllegalStateException("Bit index overflow")
}
val byteIndex = lastBitIndex ushr 3
if (byteIndex > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("Stream too large")
}
ensureCapacity(byteIndex.toInt())
}
private fun ensureCapacity(index: Int) {
val requiredSize = index + 1
if (requiredSize <= stream.size) return
var newSize = if (stream.isEmpty()) 32 else stream.size
while (newSize < requiredSize) {
if (newSize > (Int.MAX_VALUE shr 1)) {
newSize = requiredSize
break
}
newSize = newSize shl 1
}
val next = ByteArray(newSize)
System.arraycopy(stream, 0, next, 0, stream.size)
stream = next
}
} }

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

@@ -40,9 +40,16 @@ fun ConnectionLogsScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
ProtocolManager.enableUILogs(true)
onDispose {
ProtocolManager.enableUILogs(false)
}
}
LaunchedEffect(logs.size) { LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) { if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1) listState.scrollToItem(logs.size - 1)
} }
} }
@@ -89,7 +96,7 @@ fun ConnectionLogsScreen(
IconButton(onClick = { IconButton(onClick = {
scope.launch { scope.launch {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1) if (logs.isNotEmpty()) listState.scrollToItem(logs.size - 1)
} }
}) { }) {
Icon( Icon(

View File

@@ -101,7 +101,8 @@ fun SearchScreen(
protocolState: ProtocolState, protocolState: ProtocolState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onUserSelect: (SearchUser) -> Unit, onUserSelect: (SearchUser) -> Unit,
onNavigateToCrashLogs: () -> Unit = {} onNavigateToCrashLogs: () -> Unit = {},
onNavigateToConnectionLogs: () -> Unit = {}
) { ) {
// Context и View для мгновенного закрытия клавиатуры // Context и View для мгновенного закрытия клавиатуры
val context = LocalContext.current val context = LocalContext.current
@@ -150,6 +151,11 @@ fun SearchScreen(
if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) { if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) {
searchViewModel.clearSearchQuery() searchViewModel.clearSearchQuery()
onNavigateToCrashLogs() onNavigateToCrashLogs()
return@LaunchedEffect
}
if (searchQuery.trim().equals("rosettadev2", ignoreCase = true)) {
searchViewModel.clearSearchQuery()
onNavigateToConnectionLogs()
} }
} }

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.ColorMatrixColorFilter
import android.graphics.Path import android.graphics.Path
import android.graphics.RectF import android.graphics.RectF
import android.util.Log
import android.graphics.RenderEffect import android.graphics.RenderEffect
import android.graphics.Shader import android.graphics.Shader
import android.os.Build import android.os.Build
@@ -11,13 +11,10 @@ import android.view.Gravity
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -25,13 +22,11 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -48,10 +43,10 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
} }
// Only log in explicit debug mode to keep production scroll clean.
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
if (debugLogsEnabled) {
Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
}
}
val hasCenteredNotch = remember(notchInfo, screenWidthPx) { val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) isCenteredTopCutout(notchInfo, screenWidthPx)
} }
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView) // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
} }
val hasRealNotch = remember(notchInfo, screenWidthPx) { val hasRealNotch = remember(notchInfo, screenWidthPx) {
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) isCenteredTopCutout(notchInfo, screenWidthPx)
} }
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
@@ -1162,153 +1148,6 @@ fun ProfileMetaballOverlayCpu(
} }
} }
/**
* DEBUG: Temporary toggle to force a specific rendering path.
* Set forceMode to test different paths on your device:
* - null: auto-detect (default production behavior)
* - "gpu": force GPU path (requires API 31+)
* - "cpu": force CPU bitmap path
* - "compat": force compat/noop path
*
* Set forceNoNotch = true to simulate no-notch device (black bar fallback).
*
* TODO: Remove before release!
*/
object MetaballDebug {
var forceMode: String? = null // "gpu", "cpu", "compat", or null
var forceNoNotch: Boolean = false // true = pretend no notch exists
}
/**
* DEBUG: Floating panel with buttons to switch metaball rendering path.
* Place inside a Box (e.g. profile header) — it aligns to bottom-center.
* TODO: Remove before release!
*/
@Composable
fun MetaballDebugPanel(modifier: Modifier = Modifier) {
var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) }
var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) }
val context = LocalContext.current
val perfClass = remember { DevicePerformanceClass.get(context) }
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.background(
ComposeColor.Black.copy(alpha = 0.75f),
RoundedCornerShape(12.dp)
)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Title
Text(
text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass",
color = ComposeColor.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
// Mode buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat")
modes.forEach { (mode, label) ->
val isSelected = currentMode == mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(
if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f)
)
.border(
width = 1.dp,
color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f),
shape = RoundedCornerShape(8.dp)
)
.clickable {
MetaballDebug.forceMode = mode
currentMode = mode
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
color = ComposeColor.White,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
}
// No-notch toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Force no-notch (black bar)",
color = ComposeColor.White,
fontSize = 12.sp
)
Switch(
checked = noNotch,
onCheckedChange = {
MetaballDebug.forceNoNotch = it
noNotch = it
},
colors = SwitchDefaults.colors(
checkedThumbColor = ComposeColor(0xFF4CAF50),
checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f)
)
)
}
// Current active path info
val activePath = when (currentMode) {
"gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!"
"cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)"
else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
else -> "CPU (auto)"
}
}
Text(
text = "Active: $activePath" + if (noNotch) " + no-notch" else "",
color = ComposeColor(0xFF4CAF50),
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
// Notch detection info
val view = LocalView.current
val notchRes = remember { NotchInfoUtils.getInfo(context) }
val notchCutout = remember(view) { NotchInfoUtils.getInfoFromCutout(view) }
val notchSource = when {
notchRes != null -> "resource"
notchCutout != null -> "DisplayCutout"
else -> "NONE"
}
val activeNotch = notchRes ?: notchCutout
Text(
text = "Notch: $notchSource" +
if (activeNotch != null) " | ${activeNotch.bounds.width().toInt()}x${activeNotch.bounds.height().toInt()}" +
" circle=${activeNotch.isLikelyCircle}" else " (black bar fallback!)",
color = if (activeNotch != null) ComposeColor(0xFF4CAF50) else ComposeColor(0xFFFF5722),
fontSize = 10.sp
)
}
}
/** /**
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView: * Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter * 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
val context = LocalContext.current val context = LocalContext.current
val performanceClass = remember { DevicePerformanceClass.get(context) } val performanceClass = remember { DevicePerformanceClass.get(context) }
// Debug: log which path is selected
val selectedPath = when (MetaballDebug.forceMode) {
"gpu" -> "GPU (forced)"
"cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)"
else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
else -> "CPU (auto)"
}
}
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) {
if (debugLogsEnabled) {
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
}
}
// Resolve actual mode // Resolve actual mode
val useGpu = when (MetaballDebug.forceMode) { val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31 val useCpu = !useGpu
"cpu" -> false
"compat" -> false
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
val useCpu = when (MetaballDebug.forceMode) {
"gpu" -> false
"cpu" -> true
"compat" -> false
else -> !useGpu
}
when { when {
useGpu -> { useGpu -> {

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.crashlogs package com.rosetta.messenger.ui.crashlogs
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -8,13 +9,16 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -263,6 +267,8 @@ private fun CrashDetailScreen(
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Scaffold( Scaffold(
topBar = { topBar = {
@@ -274,6 +280,14 @@ private fun CrashDetailScreen(
} }
}, },
actions = { actions = {
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(crashReport.content))
Toast.makeText(context, "Full log copied", Toast.LENGTH_SHORT).show()
}
) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy Full Log")
}
IconButton(onClick = { /* TODO: Share */ }) { IconButton(onClick = { /* TODO: Share */ }) {
Icon(Icons.Default.Share, contentDescription = "Share") Icon(Icons.Default.Share, contentDescription = "Share")
} }

View File

@@ -13,10 +13,13 @@ import com.rosetta.messenger.network.ProtocolManager
*/ */
object MessageLogger { object MessageLogger {
private const val TAG = "RosettaMsg" private const val TAG = "RosettaMsg"
// Всегда включён — вывод идёт только в ProtocolManager.addLog() (in-memory UI), @Volatile
// не в logcat, безопасно для release private var isEnabled: Boolean = false
private val isEnabled: Boolean = true
fun setEnabled(enabled: Boolean) {
isEnabled = enabled
}
/** /**
* Добавить лог в UI (Debug Logs в чате) * Добавить лог в UI (Debug Logs в чате)

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. `additional_data` (`ad=0`), so nonce derivation based on timestamp is unavailable.
Desktop uses frame timestamp for nonce. This patch aligns Android with that approach by passing Desktop uses frame timestamp for nonce. This patch aligns Android with that approach by passing
an 8-byte big-endian timestamp payload in `additional_data`: an 8-byte big-endian timestamp payload in `additional_data` (absolute RTP timestamp,
including sender start offset):
- bytes `0..3` = `0` - bytes `0..3` = `0`
- bytes `4..7` = RTP timestamp (big-endian) - bytes `4..7` = RTP timestamp (big-endian)
@@ -18,10 +19,14 @@ an 8-byte big-endian timestamp payload in `additional_data`:
- `build_custom_webrtc.sh` — reproducible build script - `build_custom_webrtc.sh` — reproducible build script
- `patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch` — WebRTC patch - `patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch` — WebRTC patch
- `patches/0002-android-build-on-mac-host.patch` — allows Android target build on macOS host
- `patches/0003-macos-host-java-ijar.patch` — enables host tools (`ijar`/`jdk`) on macOS
- `patches/0004-macos-linker-missing-L-dirs.patch` — skips invalid host `-L...` paths for lld
- `patches/0005-macos-server-utils-socket.patch` — handles macOS socket errno in Android Java compile helper
## Build ## Build
Recommended on Linux (macOS can work but is less predictable for long WebRTC builds). Recommended on Linux (macOS is supported via additional patches in this folder).
Bootstrap `depot_tools` first: Bootstrap `depot_tools` first:
@@ -47,6 +52,7 @@ Optional env vars:
- `SYNC_JOBS``gclient sync` jobs (default: `1`, safer for googlesource limits) - `SYNC_JOBS``gclient sync` jobs (default: `1`, safer for googlesource limits)
- `SYNC_RETRIES` — sync retry attempts (default: `8`) - `SYNC_RETRIES` — sync retry attempts (default: `8`)
- `SYNC_RETRY_BASE_SEC` — base retry delay in seconds (default: `20`) - `SYNC_RETRY_BASE_SEC` — base retry delay in seconds (default: `20`)
- `MAC_ANDROID_NDK_ROOT` — local Android NDK path on macOS (default: `~/Library/Android/sdk/ndk/27.1.12297006`)
## Troubleshooting (HTTP 429 / RESOURCE_EXHAUSTED) ## Troubleshooting (HTTP 429 / RESOURCE_EXHAUSTED)

View File

@@ -9,7 +9,13 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROSETTA_ANDROID_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" ROSETTA_ANDROID_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
PATCH_FILE="${SCRIPT_DIR}/patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch" PATCH_FILES=(
"${SCRIPT_DIR}/patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch"
"${SCRIPT_DIR}/patches/0002-android-build-on-mac-host.patch"
"${SCRIPT_DIR}/patches/0003-macos-host-java-ijar.patch"
"${SCRIPT_DIR}/patches/0004-macos-linker-missing-L-dirs.patch"
"${SCRIPT_DIR}/patches/0005-macos-server-utils-socket.patch"
)
# Default target: WebRTC M125 family used by app dependency 125.6422.07. # Default target: WebRTC M125 family used by app dependency 125.6422.07.
WEBRTC_BRANCH="${WEBRTC_BRANCH:-branch-heads/6422}" WEBRTC_BRANCH="${WEBRTC_BRANCH:-branch-heads/6422}"
@@ -132,21 +138,63 @@ sync_with_retry
echo "[webrtc-custom] applying Rosetta patch..." echo "[webrtc-custom] applying Rosetta patch..."
git reset --hard git reset --hard
git apply --check "${PATCH_FILE}" for patch in "${PATCH_FILES[@]}"; do
git apply "${PATCH_FILE}" echo "[webrtc-custom] apply $(basename "${patch}")"
git apply --check "${patch}"
git apply "${patch}"
done
# macOS host tweaks:
# - point third_party/jdk/current to local JDK
# - use locally installed Android NDK (darwin toolchain)
if [[ "$(uname -s)" == "Darwin" ]]; then
if [[ -z "${JAVA_HOME:-}" ]]; then
JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)"
fi
if [[ -z "${JAVA_HOME:-}" || ! -d "${JAVA_HOME}" ]]; then
echo "[webrtc-custom] ERROR: JAVA_HOME not found on macOS"
exit 1
fi
JAVA_HOME_CANDIDATE="${JAVA_HOME}"
if [[ ! -f "${JAVA_HOME_CANDIDATE}/conf/logging.properties" ]] && [[ -d "${JAVA_HOME_CANDIDATE}/libexec/openjdk.jdk/Contents/Home" ]]; then
JAVA_HOME_CANDIDATE="${JAVA_HOME_CANDIDATE}/libexec/openjdk.jdk/Contents/Home"
fi
if [[ ! -f "${JAVA_HOME_CANDIDATE}/conf/logging.properties" ]]; then
echo "[webrtc-custom] ERROR: invalid JAVA_HOME (conf/logging.properties not found): ${JAVA_HOME}"
exit 1
fi
JAVA_HOME="${JAVA_HOME_CANDIDATE}"
ln -sfn "${JAVA_HOME}" "${WEBRTC_SRC}/third_party/jdk/current"
echo "[webrtc-custom] macOS JDK linked: ${WEBRTC_SRC}/third_party/jdk/current -> ${JAVA_HOME}"
fi
mkdir -p "$(dirname "${OUT_AAR}")" mkdir -p "$(dirname "${OUT_AAR}")"
echo "[webrtc-custom] building AAR (this can take a while)..." echo "[webrtc-custom] building AAR (this can take a while)..."
GN_ARGS=(
is_debug=false
is_component_build=false
rtc_include_tests=false
rtc_build_examples=false
)
if [[ "$(uname -s)" == "Darwin" ]]; then
MAC_ANDROID_NDK_ROOT="${MAC_ANDROID_NDK_ROOT:-$HOME/Library/Android/sdk/ndk/27.1.12297006}"
if [[ ! -d "${MAC_ANDROID_NDK_ROOT}" ]]; then
echo "[webrtc-custom] ERROR: Android NDK not found at ${MAC_ANDROID_NDK_ROOT}"
echo "[webrtc-custom] Set MAC_ANDROID_NDK_ROOT to your local NDK path."
exit 1
fi
GN_ARGS+=("android_ndk_root=\"${MAC_ANDROID_NDK_ROOT}\"")
GN_ARGS+=("android_ndk_version=\"27.1.12297006\"")
echo "[webrtc-custom] macOS Android NDK: ${MAC_ANDROID_NDK_ROOT}"
fi
python3 tools_webrtc/android/build_aar.py \ python3 tools_webrtc/android/build_aar.py \
--build-dir out_rosetta_aar \ --build-dir out_rosetta_aar \
--output "${OUT_AAR}" \ --output "${OUT_AAR}" \
--arch "${ARCHS[@]}" \ --arch "${ARCHS[@]}" \
--extra-gn-args \ --extra-gn-args "${GN_ARGS[@]}"
is_debug=false \
is_component_build=false \
rtc_include_tests=false \
rtc_build_examples=false
echo "[webrtc-custom] done" echo "[webrtc-custom] done"
echo "[webrtc-custom] AAR: ${OUT_AAR}" echo "[webrtc-custom] AAR: ${OUT_AAR}"

View File

@@ -25,22 +25,24 @@ index 17cf859ed8..b9d9ab14c8 100644
decrypted_audio_payload); decrypted_audio_payload);
diff --git a/audio/channel_send.cc b/audio/channel_send.cc diff --git a/audio/channel_send.cc b/audio/channel_send.cc
index 4a2700177b..93283c2e78 100644 index 4a2700177b..7ebb501704 100644
--- a/audio/channel_send.cc --- a/audio/channel_send.cc
+++ b/audio/channel_send.cc +++ b/audio/channel_send.cc
@@ -320,10 +320,21 @@ int32_t ChannelSend::SendRtpAudio(AudioFrameType frameType, @@ -320,10 +320,23 @@ int32_t ChannelSend::SendRtpAudio(AudioFrameType frameType,
// Encrypt the audio payload into the buffer. // Encrypt the audio payload into the buffer.
size_t bytes_written = 0; size_t bytes_written = 0;
+ const uint32_t additional_data_timestamp =
+ rtp_timestamp_without_offset + rtp_rtcp_->StartTimestamp();
+ const uint8_t additional_data_bytes[8] = { + const uint8_t additional_data_bytes[8] = {
+ 0, + 0,
+ 0, + 0,
+ 0, + 0,
+ 0, + 0,
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 24) & 0xff), + static_cast<uint8_t>((additional_data_timestamp >> 24) & 0xff),
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 16) & 0xff), + static_cast<uint8_t>((additional_data_timestamp >> 16) & 0xff),
+ static_cast<uint8_t>((rtp_timestamp_without_offset >> 8) & 0xff), + static_cast<uint8_t>((additional_data_timestamp >> 8) & 0xff),
+ static_cast<uint8_t>(rtp_timestamp_without_offset & 0xff), + static_cast<uint8_t>(additional_data_timestamp & 0xff),
+ }; + };
+ +
int encrypt_status = frame_encryptor_->Encrypt( int encrypt_status = frame_encryptor_->Encrypt(

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 '