From d4a396f888f6d88366b1c1de8bd5e093eaf447fd Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jul 2026 17:09:31 +0300 Subject: [PATCH] =?UTF-8?q?media:=20Ed25519-=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B8=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2,=20local=5F?= =?UTF-8?q?attrs,=20MediaBlockReader,=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/chat/chat_core.c | 1 + src/chat/chat_core.h | 10 + src/chat/chat_core_priv.h | 1 + src/chat/chat_msg.c | 199 ++++++++++++++++++- src/chat/db_sync.c | 30 ++- src/chat/db_sync.h | 3 +- tests/test_chat_sync_stress.c | 2 +- tests/test_db_sync.c | 6 +- tools/chatgui/CMakeLists.txt | 42 +++- tools/chatgui/db/db_manager.cpp | 6 +- tools/chatgui/db/db_manager.h | 1 + tools/chatgui/src/inputbar.cpp | 89 ++++++--- tools/chatgui/src/inputbar.h | 11 +- tools/chatgui/src/media_blocks.cpp | 288 ++++++++++++++++++++++++++++ tools/chatgui/src/media_blocks.h | 72 +++++++ tools/chatgui/src/messagedelegate.h | 7 +- tools/chatgui/src/messagelist.cpp | 84 ++++++-- tools/chatgui/src/voiceplayback.cpp | 61 ++++++ tools/chatgui/src/voiceplayback.h | 7 +- 20 files changed, 851 insertions(+), 70 deletions(-) create mode 100644 tools/chatgui/src/media_blocks.cpp create mode 100644 tools/chatgui/src/media_blocks.h diff --git a/.gitignore b/.gitignore index af06926f..695f6dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /build/ build-win/ tools/chatgui/build/ +tools/chatgui/ffmpeg/ffmpeg_build/ # Build outputs *.o diff --git a/src/chat/chat_core.c b/src/chat/chat_core.c index 5f8f740c..472b415b 100644 --- a/src/chat/chat_core.c +++ b/src/chat/chat_core.c @@ -88,6 +88,7 @@ void si_register(struct DB_SYNC_INSTANCE* si, const char* ch_id) { int chat_core_init(struct UTUN_INSTANCE* inst, const char* db_path) { if (!inst || !db_path) return -1; memset(&g_cc, 0, sizeof(g_cc)); + snprintf(g_cc.db_path, sizeof(g_cc.db_path), "%s", db_path); if (inst->topo_sqlite_db) { g_cc.db = inst->topo_sqlite_db; g_cc.shared_db = 1; diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index a2c15aed..cc6c0fc1 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -28,6 +28,11 @@ int chat_core_is_initialized(void); struct chat_msg_submit { char channel_id[64]; char content_type[32]; + char media_dt[32]; + char media_basename[256]; + char media_suffix[8]; + char media_ext[16]; + uint32_t media_num_blocks; uint8_t* data; uint32_t data_len; uint64_t timestamp; @@ -80,6 +85,11 @@ void chat_core_connect_channel_trampoline(void* arg); /* Трамплин для gui_bridge_post_uasync (GUI → uasync) */ void chat_core_submit_trampoline(void* arg); +/* Обновление local_attrs для записи (вызывается из uasync-потока) */ +int chat_core_update_local_attrs(const char* ch_id, uint64_t ts, + const uint8_t* author_sig, + const char* local_attrs_json); + /* Сбор статуса (NTP + connections) и отправка в GUI */ void chat_core_collect_status_trampoline(void* arg); diff --git a/src/chat/chat_core_priv.h b/src/chat/chat_core_priv.h index 5894c094..fc0d9d38 100644 --- a/src/chat/chat_core_priv.h +++ b/src/chat/chat_core_priv.h @@ -24,6 +24,7 @@ struct chat_core_ctx { struct UTUN_INSTANCE* inst; sqlite3* db; + char db_path[512]; uint8_t shared_db; uint64_t my_node_id; struct DB_SYNC_INSTANCE** si; diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index 012cb518..b760c3a2 100644 --- a/src/chat/chat_msg.c +++ b/src/chat/chat_msg.c @@ -13,6 +13,15 @@ #include "../../lib/platform_compat.h" #include +#include +#include + +#define MEDIA_BLOCK_MIN (10 * 1024 * 1024) +#define MEDIA_BLOCK_MAX (25 * 1024 * 1024) +#define MEDIA_BLOCK_TARGET 30 + +/* forward decl */ +static void chat_core_submit_media_message(struct chat_msg_submit* req); /* ─── отправка сообщения (GUI → uasync) ─── */ @@ -54,14 +63,200 @@ void chat_core_submit_message(struct chat_msg_submit* req) { return; } - int ret = db_sync_insert_signed(si, json, strlen(json), sig, 64, ts); + int ret = db_sync_insert_signed(si, json, strlen(json), sig, 64, ts, NULL); if (ret != 0) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: db_sync_insert_signed failed ret=%d", CC_ID, ret); return; } } -void chat_core_submit_trampoline(void* arg) { chat_core_submit_message((struct chat_msg_submit*)arg); u_free(arg); } +void chat_core_submit_trampoline(void* arg) { + struct chat_msg_submit* req = (struct chat_msg_submit*)arg; + if (req->media_num_blocks > 0) + chat_core_submit_media_message(req); + else + chat_core_submit_message(req); + u_free(req); +} + +/* ─── Медиа-сообщения: подпись блоков + сборка в единый файл ─── */ + +static uint32_t media_calc_block_size(uint64_t file_size) { + if (file_size == 0) return 0; + uint64_t target = (file_size + MEDIA_BLOCK_TARGET - 1) / MEDIA_BLOCK_TARGET; + if (target < MEDIA_BLOCK_MIN) target = MEDIA_BLOCK_MIN; + if (target > MEDIA_BLOCK_MAX) target = MEDIA_BLOCK_MAX; + return (uint32_t)target; +} + +static void chat_core_submit_media_message(struct chat_msg_submit* req) { + if (!g_cc.initialized) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: media submit before init", CC_ID); return; } + if (!req || req->media_num_blocks == 0) return; + + DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: MEDIA SUBMIT ch=%s ct=%s dt=%s stem=%s ext=%s blocks=%u", + CC_ID, req->channel_id, req->content_type, + req->media_dt, req->media_basename, req->media_ext, req->media_num_blocks); + + struct DB_SYNC_INSTANCE* si = si_find(req->channel_id); + if (!si) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: no db_sync instance for ch=%s", CC_ID, req->channel_id); return; } + + char media_dir[1024]; + snprintf(media_dir, sizeof(media_dir), "%s/media/%s", g_cc.db_path, req->channel_id); + + uint32_t num_blocks = req->media_num_blocks; + uint64_t block_size = 0; + uint64_t file_size = 0; + + /* allocate sig array */ + uint8_t (*sigs)[64] = (uint8_t(*)[64])u_malloc(num_blocks * 64); + if (!sigs) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: sig alloc failed blocks=%u", CC_ID, num_blocks); return; } + + /* read blocks, sign, compute sizes */ + for (uint32_t n = 0; n < num_blocks; n++) { + char path[1280]; + snprintf(path, sizeof(path), "%s/%s_%u_%s_%s.%s", + media_dir, req->media_dt, n, req->media_basename, req->media_suffix, req->media_ext); + + FILE* f = fopen(path, "rb"); + if (!f) { + DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: cannot open block %u: %s", CC_ID, n, path); + u_free(sigs); return; + } + fseek(f, 0, SEEK_END); long fsz = ftell(f); fseek(f, 0, SEEK_SET); + if (fsz <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: empty block %u: %s", CC_ID, n, path); fclose(f); u_free(sigs); return; } + + uint8_t* buf = (uint8_t*)u_malloc((size_t)fsz); + if (!buf || fread(buf, 1, (size_t)fsz, f) != (size_t)fsz) { + DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: read fail block %u", CC_ID, n); + if (buf) u_free(buf); fclose(f); u_free(sigs); return; + } + fclose(f); + + if (sc_ed25519_sign(g_cc.inst->my_ed25519_privkey, buf, (size_t)fsz, sigs[n]) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: Ed25519 sign fail block %u", CC_ID, n); + u_free(buf); u_free(sigs); return; + } + u_free(buf); + + file_size += (uint64_t)fsz; + if (n == 0) block_size = (uint64_t)fsz; + } + + if (file_size == 0) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: zero file size", CC_ID); u_free(sigs); return; } + + /* build final data: ||||,... */ + size_t base_len = req->data_len; + size_t sigs_hex_len = num_blocks * 129; /* 128 hex + comma */ + size_t full_data_cap = base_len + 128 + sigs_hex_len; + char* full_data = (char*)u_malloc(full_data_cap + 1); + if (!full_data) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: data alloc fail", CC_ID); u_free(sigs); return; } + + size_t off = 0; + if (base_len > 0) { memcpy(full_data + off, req->data, base_len); off += base_len; } + off += snprintf(full_data + off, full_data_cap - off, "|%llu|%llu|%u|", + (unsigned long long)file_size, (unsigned long long)block_size, num_blocks); + for (uint32_t n = 0; n < num_blocks; n++) { + if (n > 0) full_data[off++] = ','; + for (int b = 0; b < 64; b++) + off += snprintf(full_data + off, full_data_cap - off, "%02x", sigs[n][b]); + } + size_t full_data_len = off; + + /* build JSON with dynamic buffer */ + size_t json_cap = full_data_len + 256; + char* json = (char*)u_malloc(json_cap); + if (!json) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: json alloc fail", CC_ID); u_free(full_data); u_free(sigs); return; } + snprintf(json, json_cap, + "{\"n\":%llu,\"ch\":\"%s\",\"ct\":\"%s\",\"d\":\"%.*s\"}", + (unsigned long long)g_cc.my_node_id, req->channel_id, + req->content_type, (int)full_data_len, full_data); + + uint64_t ts = db_sync_next_timestamp(si); + size_t sig_msg_len = 8 + strlen(json); + uint8_t* sig_msg = (uint8_t*)u_malloc(sig_msg_len); + if (!sig_msg) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: sig_msg alloc fail", CC_ID); u_free(json); u_free(full_data); u_free(sigs); return; } + memcpy(sig_msg, &ts, 8); + memcpy(sig_msg + 8, json, sig_msg_len - 8); + uint8_t json_sig[64]; + if (sc_ed25519_sign(g_cc.inst->my_ed25519_privkey, sig_msg, sig_msg_len, json_sig) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: Ed25519 sign JSON failed", CC_ID); + u_free(sig_msg); u_free(json); u_free(full_data); u_free(sigs); return; + } + + /* assemble single file from blocks, then delete blocks */ + { + char assembled_path[1280]; + snprintf(assembled_path, sizeof(assembled_path), "%s/%s_%s_%s.%s", + media_dir, req->media_dt, req->media_basename, req->media_suffix, req->media_ext); + FILE* af = fopen(assembled_path, "wb"); + if (af) { + for (uint32_t n = 0; n < num_blocks; n++) { + char block_path[1280]; + snprintf(block_path, sizeof(block_path), "%s/%s_%u_%s_%s.%s", + media_dir, req->media_dt, n, req->media_basename, req->media_suffix, req->media_ext); + FILE* bf = fopen(block_path, "rb"); + if (bf) { + uint8_t buf[65536]; size_t rd; + while ((rd = fread(buf, 1, sizeof(buf), bf)) > 0) fwrite(buf, 1, rd, af); + fclose(bf); + unlink(block_path); + } + } + fclose(af); + DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: assembled %s (%llu bytes)", CC_ID, assembled_path, (unsigned long long)file_size); + } else { + DEBUG_WARN(DEBUG_CATEGORY_DB_SYNC, "%s: cannot create assembled file %s", CC_ID, assembled_path); + } + } + + /* build local_attrs JSON */ + char local_attrs_json[512]; + { + char bl_list[256] = "0"; size_t bl_off = 1; + for (uint32_t n = 1; n < num_blocks; n++) + bl_off += snprintf(bl_list + bl_off, sizeof(bl_list) - bl_off, ",%u", n); + snprintf(local_attrs_json, sizeof(local_attrs_json), + "{\"st\":\"fl\",\"fp\":\"%s_%s_%s.%s\",\"bl\":[%s]}", + req->media_dt, req->media_basename, req->media_suffix, req->media_ext, bl_list); + } + + int ret = db_sync_insert_signed(si, json, strlen(json), json_sig, 64, ts, local_attrs_json); + if (ret != 0) + DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: media insert failed ret=%d", CC_ID, ret); + + u_free(sig_msg); u_free(json); u_free(full_data); u_free(sigs); +} + +/* ─── Обновление local_attrs (для будущей приёмной стороны) ─── */ + +int chat_core_update_local_attrs(const char* ch_id, uint64_t ts, + const uint8_t* author_sig, + const char* local_attrs_json) +{ + if (!g_cc.initialized || !ch_id || !author_sig || !local_attrs_json) return -1; + + char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); + char sql[256]; + snprintf(sql, sizeof(sql), + "UPDATE \"%s\" SET local_attrs=? WHERE timestamp=? AND author_signature=?", tbl); + + sqlite3_stmt* stmt = NULL; + if (sqlite3_prepare_v2(g_cc.db, sql, -1, &stmt, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: update local_attrs prep fail: %s", CC_ID, sqlite3_errmsg(g_cc.db)); + return -1; + } + sqlite3_bind_text(stmt, 1, local_attrs_json, -1, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 2, (sqlite3_int64)ts); + sqlite3_bind_blob(stmt, 3, author_sig, DB_SIG_SIZE, SQLITE_STATIC); + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: update local_attrs step fail rc=%d", CC_ID, rc); + return -1; + } + DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: local_attrs updated ch=%s ts=%llu", CC_ID, ch_id, (unsigned long long)ts); + return 0; +} /* ─── DB-операции для chat_sync ─── */ diff --git a/src/chat/db_sync.c b/src/chat/db_sync.c index 74a68994..fd84de41 100644 --- a/src/chat/db_sync.c +++ b/src/chat/db_sync.c @@ -15,6 +15,7 @@ #include "../../lib/platform_compat.h" #include #include +#include // ---- Forward declarations ---- struct DB_SYNC; @@ -422,7 +423,8 @@ static int db_verify_author_sig(struct DB_SYNC_INSTANCE* si, static int db_record_insert(struct DB_SYNC_INSTANCE* si, uint64_t id, uint64_t ts, uint64_t author_node_id, const char* json, size_t jlen, - const uint8_t* author_sig, int do_cascade) + const uint8_t* author_sig, int do_cascade, + const char* local_attrs) { DEBUG_TRACE(DEBUG_CATEGORY_DB_SYNC, "id=%llu ts=%llu author=%N len=%zu cascade=%d", (unsigned long long)id, (unsigned long long)ts, (unsigned long long)author_node_id, jlen, do_cascade); sqlite3* db = SI_DB(si); @@ -465,8 +467,8 @@ static int db_record_insert(struct DB_SYNC_INSTANCE* si, if (si_prep(si, &stmt, "INSERT INTO \"%s\"" " (timestamp,node_id,id,chain_hash,flags,data," - " author_signature,delivered_peers,delivery_chain)" - " VALUES (?,?,?,?,0,?,?,0,'')") != SQLITE_OK) + " author_signature,local_attrs,delivered_peers,delivery_chain)" + " VALUES (?,?,?,?,0,?,?,?,0,'')") != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "db_record_insert INSERT prep: %s", sqlite3_errmsg(db)); sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); return -1; } sqlite3_bind_int64(stmt, 1, (sqlite3_int64)ts); sqlite3_bind_int64(stmt, 2, (sqlite3_int64)author_node_id); @@ -475,6 +477,8 @@ static int db_record_insert(struct DB_SYNC_INSTANCE* si, if (jlen > 0) sqlite3_bind_blob(stmt, 5, json, (int)jlen, SQLITE_STATIC); else sqlite3_bind_null(stmt, 5); sqlite3_bind_blob(stmt, 6, author_sig, DB_SIG_SIZE, SQLITE_STATIC); + if (local_attrs && local_attrs[0]) sqlite3_bind_text(stmt, 7, local_attrs, -1, SQLITE_STATIC); + else sqlite3_bind_text(stmt, 7, "", -1, SQLITE_STATIC); rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "INSERT: %s", sqlite3_errmsg(db)); sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); return -1; } @@ -923,7 +927,7 @@ static void db_handle_send_data(struct DB_SYNC_INSTANCE* si, uint64_t src, const const uint8_t* save = ptr; uint64_t rid, rts, rauthor; uint32_t rdlen; const uint8_t* rdata, *rsig; int rsiglen; if (si_parse_record(&ptr, p + len, &rid, &rts, &rauthor, &rdlen, &rdata, &rsig, &rsiglen) != 0) { parse_fails++; break; } - int ret = db_record_insert(si, rid, rts, rauthor, (const char*)rdata, rdlen, rsig, 0); + int ret = db_record_insert(si, rid, rts, rauthor, (const char*)rdata, rdlen, rsig, 0, NULL); if (ret >= 0) { recv_ts[recv_cnt] = rts; if (rsig && rsiglen >= 8) memcpy(&recv_sig8[recv_cnt], rsig, 8); else recv_sig8[recv_cnt] = 0; recv_cnt++; } if (ret >= 0) { if (received == 0) first_id = rid; last_id = rid; received++; } if (ret == 1) duplicates++; @@ -1160,7 +1164,7 @@ static void db_handle_push(struct DB_SYNC_INSTANCE* si, uint64_t src, const uint if (si_parse_record(&ptr, p + len, &rid, &rts, &rauthor, &rdlen, &rdata, &rsig, &rsiglen) != 0) return; DEBUG_TRACE(DEBUG_CATEGORY_DB_SYNC, "from=%016llx id=%llu author=%016llx ts=%llu len=%u", (unsigned long long)src, (unsigned long long)rid, (unsigned long long)rauthor, (unsigned long long)rts, rdlen); - int ret = db_record_insert(si, rid, rts, rauthor, (const char*)rdata, rdlen, rsig, 0); + int ret = db_record_insert(si, rid, rts, rauthor, (const char*)rdata, rdlen, rsig, 0, NULL); if (ret == 0) { uint32_t ins_pos = si_find_pos(si, rts, rsig); uint32_t total = db_count(si); @@ -1672,6 +1676,7 @@ struct DB_SYNC_INSTANCE* db_sync_instance_add(struct UTUN_INSTANCE* inst, const " flags INTEGER NOT NULL DEFAULT 0," " data BLOB," " author_signature BLOB NOT NULL," + " local_attrs TEXT DEFAULT ''," " delivered_peers INTEGER NOT NULL DEFAULT 0," " delivery_chain TEXT NOT NULL DEFAULT ''," " PRIMARY KEY (timestamp, author_signature))", @@ -1746,7 +1751,8 @@ void db_sync_instance_remove(struct DB_SYNC_INSTANCE* si) } int db_sync_insert_signed(struct DB_SYNC_INSTANCE* si, const char* json_data, size_t len, - const uint8_t* sig, size_t sig_len, uint64_t ts) + const uint8_t* sig, size_t sig_len, uint64_t ts, + const char* local_attrs) { if (!si || !si->enabled || !json_data || len == 0) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "insert invalid args"); return -1; } if (!sig || sig_len != DB_SIG_SIZE) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "insert_signed: signature required (64 bytes Ed25519)"); return -1; } @@ -1755,22 +1761,25 @@ int db_sync_insert_signed(struct DB_SYNC_INSTANCE* si, const char* json_data, si uint64_t id = si->next_id; uint64_t author_node_id = si->db_sync->inst->node_id; - int ret = db_record_insert(si, id, ts, author_node_id, json_data, len, sig, 1); + int ret = db_record_insert(si, id, ts, author_node_id, json_data, len, sig, 1, local_attrs); if (ret != 0) return ret; if (si->on_insert) si->on_insert(si, ts, json_data, len, author_node_id, si->on_insert_arg); // Build PUSH: [DB_MSG_PUSH][id:8][ts:8][author:8][dlen:4][data][sig_len:1=64][sig:64] uint32_t sl = DB_SIG_SIZE; - uint8_t pbuf[2560]; + size_t push_overhead = 1 + 8 + 8 + 8 + 4 + 1 + 64; + size_t push_total = push_overhead + (len > 0 ? len : 0); + uint8_t* pbuf = (uint8_t*)u_malloc(push_total); + if (!pbuf) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "push alloc failed size=%zu", push_total); return -1; } uint32_t off = 0; pbuf[off++] = DB_MSG_PUSH; memcpy(pbuf + off, &id, 8); off += 8; memcpy(pbuf + off, &ts, 8); off += 8; memcpy(pbuf + off, &author_node_id, 8); off += 8; memcpy(pbuf + off, &len, 4); off += 4; - if (len > 0 && off + len <= sizeof(pbuf)) { memcpy(pbuf + off, json_data, len); off += len; } + if (len > 0) { memcpy(pbuf + off, json_data, len); off += (uint32_t)len; } pbuf[off++] = (uint8_t)sl; - if (off + sl <= sizeof(pbuf)) { memcpy(pbuf + off, sig, sl); off += sl; } + memcpy(pbuf + off, sig, sl); off += sl; // Push to all synced peers int push_count = 0; @@ -1783,6 +1792,7 @@ int db_sync_insert_signed(struct DB_SYNC_INSTANCE* si, const char* json_data, si if (push_count > 0) DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "sync [%s:ME--] → PUSH out: new msg id=%llu ts=%llu → forwarded to %d synced peers", SI_SHRT(si), (unsigned long long)id, (unsigned long long)ts, push_count); + u_free(pbuf); return 0; } diff --git a/src/chat/db_sync.h b/src/chat/db_sync.h index ae722d78..cb26dcef 100644 --- a/src/chat/db_sync.h +++ b/src/chat/db_sync.h @@ -96,7 +96,8 @@ void db_sync_instance_remove(struct DB_SYNC_INSTANCE* si); // sig = Ed25519(ts[8] || author[8] || json). // ts — call db_sync_next_timestamp(si) before signing to reserve monotonically increasing timestamp. int db_sync_insert_signed(struct DB_SYNC_INSTANCE* si, const char* json_data, size_t len, - const uint8_t* sig, size_t sig_len, uint64_t ts); + const uint8_t* sig, size_t sig_len, uint64_t ts, + const char* local_attrs); uint32_t db_sync_count(struct DB_SYNC_INSTANCE* si); uint64_t db_sync_get_last_timestamp(struct DB_SYNC_INSTANCE* si); uint64_t db_sync_next_timestamp(struct DB_SYNC_INSTANCE* si); diff --git a/tests/test_chat_sync_stress.c b/tests/test_chat_sync_stress.c index 288e30eb..7c99ca0c 100644 --- a/tests/test_chat_sync_stress.c +++ b/tests/test_chat_sync_stress.c @@ -126,7 +126,7 @@ static int insert_record(struct DB_SYNC_INSTANCE* si, struct UTUN_INSTANCE* inst memcpy(sig_msg + off, data, len); off += len; uint8_t sig[64]; if (sc_ed25519_sign(inst->my_ed25519_privkey, sig_msg, off, sig) != SC_OK) return -1; - return db_sync_insert_signed(si, data, len, sig, 64, ts); + return db_sync_insert_signed(si, data, len, sig, 64, ts, NULL); } int main(void) { diff --git a/tests/test_db_sync.c b/tests/test_db_sync.c index 9e40a36b..32cb8177 100644 --- a/tests/test_db_sync.c +++ b/tests/test_db_sync.c @@ -153,7 +153,7 @@ static int insert_many(struct DB_SYNC_INSTANCE* si, struct UTUN_INSTANCE* inst, size_t jl = strlen(buf); memcpy(sig_msg + off, buf, jl); off += jl; uint8_t sig[64]; if (sc_ed25519_sign(inst->my_ed25519_privkey, sig_msg, off, sig) != SC_OK) { fprintf(stderr,"sign fail\n"); return -1; } - if (db_sync_insert_signed(si, buf, jl, sig, 64, ts) < 0) { fprintf(stderr,"insert fail\n"); return -1; } + if (db_sync_insert_signed(si, buf, jl, sig, 64, ts, NULL) < 0) { fprintf(stderr,"insert fail\n"); return -1; } } return 0; } @@ -261,7 +261,7 @@ int main(void) { size_t jl = strlen(ebuf); memcpy(msg + moff, ebuf, jl); moff += jl; uint8_t sig[64]; if (sc_ed25519_sign(inst_a->my_ed25519_privkey, msg, moff, sig) != SC_OK - || db_sync_insert_signed(si_a, ebuf, jl, sig, 64, early_ts) != 0) + || db_sync_insert_signed(si_a, ebuf, jl, sig, 64, early_ts, NULL) != 0) { test_phase = 2; goto done; } } ca_target = 3; cb_target = 3; @@ -308,7 +308,7 @@ int main(void) { size_t jl = strlen(ebuf); memcpy(msg + moff, ebuf, jl); moff += jl; uint8_t sig[64]; if (sc_ed25519_sign(inst_a->my_ed25519_privkey, msg, moff, sig) != SC_OK - || db_sync_insert_signed(si_a, ebuf, jl, sig, 64, early_ts) != 0) + || db_sync_insert_signed(si_a, ebuf, jl, sig, 64, early_ts, NULL) != 0) { test_phase = 2; goto done; } } ca_target = 6; cb_target = 6; cc_target = 6; diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 7752e2af..1eac1646 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -42,6 +42,41 @@ set(BUILD_SHARED_LIBS OFF CACHE BOOL "") add_subdirectory(zxing-cpp) set_target_properties(ZXing PROPERTIES AUTOMOC OFF) +# FFmpeg - custom built shared libraries +set(FFMPEG_INSTALL_DIR "${CMAKE_SOURCE_DIR}/ffmpeg/ffmpeg_build/install") +set(ENV{PKG_CONFIG_PATH} "${FFMPEG_INSTALL_DIR}/lib/pkgconfig") +find_package(PkgConfig QUIET) +if(PkgConfig_FOUND) + pkg_check_modules(FFMPEG_PC libavcodec libavformat libavutil libswscale libswresample IMPORTED_TARGET) + if(FFMPEG_PC_FOUND) + set(FFMPEG_FOUND TRUE) + set(FFMPEG_TARGET PkgConfig::FFMPEG_PC) + set(FFMPEG_INCLUDE_DIRS "${FFMPEG_PC_INCLUDE_DIRS}") + endif() +endif() +if(NOT FFMPEG_FOUND) + message(STATUS "pkg-config search failed, trying direct library search") + find_path(FFMPEG_INCLUDE_DIR libavcodec/avcodec.h PATHS "${FFMPEG_INSTALL_DIR}/include" NO_DEFAULT_PATH) + find_library(AVCODEC_LIBRARY avcodec PATHS "${FFMPEG_INSTALL_DIR}/lib" NO_DEFAULT_PATH) + find_library(AVFORMAT_LIBRARY avformat PATHS "${FFMPEG_INSTALL_DIR}/lib" NO_DEFAULT_PATH) + find_library(AVUTIL_LIBRARY avutil PATHS "${FFMPEG_INSTALL_DIR}/lib" NO_DEFAULT_PATH) + find_library(SWSCALE_LIBRARY swscale PATHS "${FFMPEG_INSTALL_DIR}/lib" NO_DEFAULT_PATH) + find_library(SWRESAMPLE_LIBRARY swresample PATHS "${FFMPEG_INSTALL_DIR}/lib" NO_DEFAULT_PATH) + if(FFMPEG_INCLUDE_DIR AND AVCODEC_LIBRARY AND AVFORMAT_LIBRARY AND AVUTIL_LIBRARY) + set(FFMPEG_FOUND TRUE) + set(FFMPEG_INCLUDE_DIRS "${FFMPEG_INCLUDE_DIR}") + set(FFMPEG_LIBRARIES "${AVCODEC_LIBRARY}" "${AVFORMAT_LIBRARY}" "${AVUTIL_LIBRARY}" "${SWSCALE_LIBRARY}" "${SWRESAMPLE_LIBRARY}") + message(STATUS "FFmpeg found via direct search") + endif() +endif() +if(NOT FFMPEG_FOUND) + message(FATAL_ERROR "FFmpeg not found at ${FFMPEG_INSTALL_DIR}. Run: cd ffmpeg && ./build_ffmpeg.sh") +endif() +if(UNIX AND NOT APPLE) + set(CMAKE_BUILD_RPATH "${FFMPEG_INSTALL_DIR}/lib") + set(CMAKE_INSTALL_RPATH "${FFMPEG_INSTALL_DIR}/lib") +endif() + # uTun core library (C, built from ../src/ and ../lib/) add_subdirectory(libutun) @@ -80,6 +115,7 @@ add_executable(chatgui src/audiocompressor.cpp src/voicemessageencoder.cpp src/voiceplayback.cpp + src/media_blocks.cpp src/audiodevicesettingspage.cpp transport/utun_node.cpp transport/node_config.cpp @@ -91,13 +127,13 @@ add_executable(chatgui resources/chatgui.qrc ) -target_include_directories(chatgui PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include) +target_include_directories(chatgui PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include ${FFMPEG_INCLUDE_DIRS}) target_compile_definitions(chatgui PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE) set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C) if(WIN32) - target_link_libraries(chatgui PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread) + target_link_libraries(chatgui PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) else() - target_link_libraries(chatgui PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread dl) + target_link_libraries(chatgui PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) endif() # ============================================================================ diff --git a/tools/chatgui/db/db_manager.cpp b/tools/chatgui/db/db_manager.cpp index 0bcde3a2..1790b872 100644 --- a/tools/chatgui/db/db_manager.cpp +++ b/tools/chatgui/db/db_manager.cpp @@ -170,7 +170,7 @@ QString DbManager::getChannelLastMessage(const QString& channelId) const { QList DbManager::getMessages(const QString& chId, int limit) const { QList list; QString sql = QStringLiteral( - "SELECT id, node_id, data, timestamp, chain_hash, author_signature" + "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs" " FROM \"%1\" ORDER BY timestamp DESC, author_signature DESC LIMIT ?").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return list; @@ -190,6 +190,7 @@ QList DbManager::getMessages(const QString& chId, int limit) const { m.datahash = 0; m.chainHash = colBlob(stmt, 4); m.signature = colBlob(stmt, 5); + m.localAttrs = QByteArray((const char*)sqlite3_column_text(stmt, 6)); m.isOutgoing = (m.authorNodeId == myId); m.isRead = true; list.prepend(m); @@ -201,7 +202,7 @@ QList DbManager::getMessages(const QString& chId, int limit) const { QList DbManager::getMessagesSince(const QString& chId, qint64 sinceId) const { QList list; QString sql = QStringLiteral( - "SELECT id, node_id, data, timestamp, chain_hash, author_signature" + "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs" " FROM \"%1\" WHERE id > ? ORDER BY id ASC").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return list; @@ -219,6 +220,7 @@ QList DbManager::getMessagesSince(const QString& chId, qint64 sinceI m.datahash = 0; m.chainHash = colBlob(stmt, 4); m.signature = colBlob(stmt, 5); + m.localAttrs = QByteArray((const char*)sqlite3_column_text(stmt, 6)); m.isOutgoing = (m.authorNodeId == myId); m.isRead = true; list.append(m); diff --git a/tools/chatgui/db/db_manager.h b/tools/chatgui/db/db_manager.h index 2e89b1e1..fae100de 100644 --- a/tools/chatgui/db/db_manager.h +++ b/tools/chatgui/db/db_manager.h @@ -36,6 +36,7 @@ struct MessageRow { uint64_t datahash = 0; QByteArray chainHash; QByteArray signature; + QByteArray localAttrs; bool isOutgoing = false; bool isRead = false; }; diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 691323ed..61140786 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -2,6 +2,7 @@ #include "emojipanel.h" #include "audiorecorder.h" #include "voicemessageencoder.h" +#include "media_blocks.h" #include "debug_ui.h" #include #include @@ -53,15 +54,10 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par layout->setContentsMargins(8, 4, 8, 4); layout->setSpacing(4); - auto *attachBtn = makeIconBtn(QString::fromUtf8("\xF0\x9F\x93\x8E"), - QString::fromUtf8("Прикрепить"), this); - connect(attachBtn, &QPushButton::clicked, this, [this]() { - QString path = QFileDialog::getOpenFileName(this, QString::fromUtf8("Прикрепить файл")); - if (!path.isEmpty()) { - qDebug() << "Attached:" << path; - } - }); - layout->addWidget(wrapBottomBtn(attachBtn, 3), 0, Qt::AlignBottom); + m_attachBtn = makeIconBtn(QString::fromUtf8("\xF0\x9F\x93\x8E"), + QString::fromUtf8("Прикрепить"), this); + connect(m_attachBtn, &QPushButton::clicked, this, &InputBar::onAttachClicked); + layout->addWidget(wrapBottomBtn(m_attachBtn, 3), 0, Qt::AlignBottom); m_input->setAcceptRichText(false); m_input->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); @@ -233,30 +229,61 @@ void InputBar::onPttReleased() { qPrintable(mediaPath), QDir(mediaPath).exists(), qPrintable(m_channelIdForRecord)); QString fileName = VoiceEncoder::generateFileName(); - QString filePath = mediaPath + "/" + fileName; + QString tempPath = mediaPath + "/" + fileName; float durationSec = 0; - int frames = VoiceEncoder::encodeToFile(pcm, 48000, 1, filePath, durationSec); - if (frames > 0) { - m_voiceFilePath = filePath; - - const auto& wf = m_recorder->waveformLevels(); - QString wfStr; - int N = (int)wf.size(); - for (int i = 0; i < 100; i++) { - int s = i * N / 100, e = (i + 1) * N / 100; - if (s >= N) s = N - 1; - if (e <= s) e = s + 1; - if (e > N) e = N; - float sum = 0; int cnt = 0; - for (int j = s; j < e; j++) { sum += wf[j]; cnt++; } - float v = cnt > 0 ? sum / cnt : 0; - if (i > 0) wfStr += ','; - wfStr += QString::number(v, 'f', 3); - } - GUI_INFO("InputBar: voice message ready: %s (%.1fs) wf=%d levels", - qPrintable(filePath), durationSec, N); - emit sendVoiceMessage(filePath, durationMs, wfStr); + int frames = VoiceEncoder::encodeToFile(pcm, 48000, 1, tempPath, durationSec); + if (frames <= 0) { + QFile::remove(tempPath); + return; + } + + MediaBlockInfo blk = media_split_to_blocks(tempPath, mediaPath, true); + if (blk.numBlocks <= 0) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar: block split failed"); + return; + } + + m_voiceFilePath = QString("%1/%2_%3_%4.%5").arg(mediaPath, blk.dt, blk.basename, blk.suffix, blk.ext); + + const auto& wf = m_recorder->waveformLevels(); + QString wfStr; + int N = (int)wf.size(); + for (int i = 0; i < 100; i++) { + int s = i * N / 100, e = (i + 1) * N / 100; + if (s >= N) s = N - 1; + if (e <= s) e = s + 1; + if (e > N) e = N; + float sum = 0; int cnt = 0; + for (int j = s; j < e; j++) { sum += wf[j]; cnt++; } + float v = cnt > 0 ? sum / cnt : 0; + if (i > 0) wfStr += ','; + wfStr += QString::number(v, 'f', 3); } + GUI_INFO("InputBar: voice message ready: dt=%s stem=%s blocks=%d dur=%.1fs", + qPrintable(blk.dt), qPrintable(blk.basename), blk.numBlocks, durationSec); + emit sendVoiceMessage(blk.dt, blk.basename, blk.suffix, blk.ext, + blk.numBlocks, blk.blockSize, durationMs, wfStr); +} + +void InputBar::onAttachClicked() { + if (m_channelIdForRecord.isEmpty()) return; + QString srcPath = QFileDialog::getOpenFileName(this, QString::fromUtf8("Прикрепить файл")); + if (srcPath.isEmpty()) return; + + QFileInfo fi(srcPath); + if (fi.size() == 0) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar::onAttachClicked: empty file"); return; } + + QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord; + VoiceEncoder::ensureMediaDir(mediaPath); + QDir().mkpath(mediaPath); + + MediaBlockInfo blk = media_split_to_blocks(srcPath, mediaPath, false); + if (blk.numBlocks <= 0) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar::onAttachClicked: split failed"); return; } + + GUI_INFO("InputBar::onAttachClicked: file=%s dt=%s blocks=%d", + qPrintable(fi.fileName()), qPrintable(blk.dt), blk.numBlocks); + emit sendFileAttachment(blk.dt, blk.basename, blk.suffix, blk.ext, + blk.numBlocks, blk.blockSize, fi.fileName()); } void InputBar::resizeEvent(QResizeEvent* event) { diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 3793e4eb..2e8ff79b 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -19,7 +19,14 @@ public: signals: void sendMessage(const QString &text); - void sendVoiceMessage(const QString &filePath, int durationMs, const QString& waveformStr); + void sendVoiceMessage(const QString& mediaDt, const QString& mediaBasename, + const QString& mediaSuffix, const QString& mediaExt, + int numBlocks, qint64 blockSize, + int durationMs, const QString& waveformStr); + void sendFileAttachment(const QString& mediaDt, const QString& mediaBasename, + const QString& mediaSuffix, const QString& mediaExt, + int numBlocks, qint64 blockSize, + const QString& displayName); protected: bool eventFilter(QObject *obj, QEvent *event) override; @@ -30,6 +37,7 @@ private slots: void adjustInputHeight(); void onPttPressed(); void onPttReleased(); + void onAttachClicked(); void updateRecordingUI(int ms); void showRecordOverlay(); void hideRecordOverlay(); @@ -38,6 +46,7 @@ private: QTextEdit *m_input; QPushButton *m_emojiBtn; QPushButton *m_pttBtn; + QPushButton *m_attachBtn = nullptr; EmojiPanel *m_emojiPanel; AudioRecorder *m_recorder; QWidget *m_recordOverlay = nullptr; diff --git a/tools/chatgui/src/media_blocks.cpp b/tools/chatgui/src/media_blocks.cpp new file mode 100644 index 00000000..c0bf6418 --- /dev/null +++ b/tools/chatgui/src/media_blocks.cpp @@ -0,0 +1,288 @@ +#include "media_blocks.h" + +#include "../../lib/debug_config.h" + +#include +#include +#include +#include + +#include + +/* ─── MediaBlockReader ─── */ + +MediaBlockReader::MediaBlockReader() {} + +void MediaBlockReader::initFromAttrs(const QString& mediaDir, + const QString& dt, const QString& basename, + const QString& suffix, const QString& ext, + const QJsonObject& localAttrs, + qint64 blockSize, qint64 totalSize, int totalBlocks) +{ + m_mediaDir = mediaDir; m_dt = dt; m_basename = basename; + m_suffix = suffix; m_ext = ext; + m_blockSize = blockSize; m_totalSize = totalSize; m_totalBlocks = totalBlocks; + m_pos = 0; m_valid = true; + + QString st = localAttrs.value("st").toString(); + if (st == "fl") { + m_mode = SINGLE_FILE; + QString fp = localAttrs.value("fp").toString(); + m_filePath = mediaDir + "/" + fp; + m_fileSize = totalSize; + QFileInfo fi(m_filePath); + m_complete = fi.exists() && fi.size() == totalSize; + } else { + m_mode = FRAGMENTED; + QJsonArray bl = localAttrs.value("bl").toArray(); + for (const auto& v : bl) m_blocks.append(v.toInt()); + m_complete = (m_blocks.size() == totalBlocks); + } + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MediaBlockReader: mode=%s complete=%d path=%s blocks=%d/%d", + m_mode == SINGLE_FILE ? "single" : "frag", m_complete, + qPrintable(debugPath()), m_blocks.size(), totalBlocks); +} + +MediaBlockReader MediaBlockReader::fromLegacyFile(const QString& filePath) { + MediaBlockReader r; + r.m_mode = SINGLE_FILE; + r.m_filePath = filePath; + r.m_valid = true; + QFileInfo fi(filePath); + r.m_fileSize = fi.size(); + r.m_complete = fi.exists() && r.m_fileSize > 0; + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MediaBlockReader: legacy file=%s size=%lld valid=%d", + qPrintable(filePath), (long long)r.m_fileSize, r.m_valid); + return r; +} + +qint64 MediaBlockReader::blockSizeForN(int n) const { + if (n < m_totalBlocks - 1) return m_blockSize; + qint64 last = m_totalSize - (m_totalBlocks - 1) * m_blockSize; + if (last <= 0) last = m_totalSize; + return last; +} + +QString MediaBlockReader::blockFilePath(int n) const { + return QString("%1/%2_%3_%4_%5.%6") + .arg(m_mediaDir, m_dt).arg(n).arg(m_basename, m_suffix, m_ext); +} + +QByteArray MediaBlockReader::readAll() { + if (!m_valid || !m_complete || m_mode == NONE) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "MediaBlockReader::readAll: invalid/incomplete"); + return {}; + } + + if (m_mode == SINGLE_FILE) { + QFile f(m_filePath); + if (!f.open(QIODevice::ReadOnly)) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "MediaBlockReader::readAll: cannot open %s", qPrintable(m_filePath)); + return {}; + } + QByteArray data = f.readAll(); + m_pos = data.size(); + return data; + } + + QByteArray data; data.resize(m_totalSize); + qint64 offset = 0; + for (int i = 0; i < m_blocks.size(); i++) { + int n = m_blocks[i]; + QString path = blockFilePath(n); + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "MediaBlockReader::readAll: cannot open block %d: %s", n, qPrintable(path)); + return {}; + } + qint64 sz = blockSizeForN(n); + f.read(data.data() + offset, sz); + offset += sz; + } + m_pos = data.size(); + return data; +} + +QByteArray MediaBlockReader::readRange(qint64 offset, qint64 size) { + if (!m_valid || offset < 0 || size <= 0 || m_mode == NONE) return {}; + if (offset >= m_totalSize) return {}; + + if (offset + size > m_totalSize) size = m_totalSize - offset; + + if (m_mode == SINGLE_FILE) { + QFile f(m_filePath); + if (!f.open(QIODevice::ReadOnly)) return {}; + f.seek(offset); + QByteArray data = f.read(size); + m_pos = offset + data.size(); + return data; + } + + int startBlock = offset / m_blockSize; + QByteArray data; data.resize(size); + qint64 copied = 0; + qint64 remaining = size; + int currentN = startBlock; + + while (remaining > 0 && currentN < m_totalBlocks && m_blocks.contains(currentN)) { + qint64 blockStart = (qint64)currentN * m_blockSize; + qint64 blockSize = blockSizeForN(currentN); + qint64 inBlockOffset = offset + copied - blockStart; + if (inBlockOffset < 0) inBlockOffset = 0; + qint64 toRead = qMin(remaining, blockSize - inBlockOffset); + if (toRead <= 0) break; + + QString path = blockFilePath(currentN); + QFile f(path); + if (f.open(QIODevice::ReadOnly)) { + f.seek(inBlockOffset); + f.read(data.data() + copied, toRead); + } + copied += toRead; + remaining -= toRead; + currentN++; + } + m_pos = offset + copied; + if (copied < size) data.resize(copied); + return data; +} + +bool MediaBlockReader::seek(qint64 pos) { + if (!m_valid || pos < 0 || pos > m_totalSize) return false; + m_pos = pos; + return true; +} + +bool MediaBlockReader::atEnd() const { + return m_pos >= m_totalSize; +} + +bool MediaBlockReader::saveToFile(const QString& destPath) { + if (!m_valid || !m_complete) return false; + + QByteArray data = readAll(); + if (data.isEmpty()) return false; + + QFile f(destPath); + if (!f.open(QIODevice::WriteOnly)) return false; + f.write(data); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MediaBlockReader: saved to %s (%lld bytes)", qPrintable(destPath), (long long)data.size()); + return true; +} + +QString MediaBlockReader::assembleAndCleanup() { + if (!m_valid || m_mode != FRAGMENTED || !m_complete) return {}; + + QString assembledPath = QString("%1/%2_%3_%4.%5") + .arg(m_mediaDir, m_dt, m_basename, m_suffix, m_ext); + + QFile af(assembledPath); + if (!af.open(QIODevice::WriteOnly)) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "MediaBlockReader::assemble: cannot create %s", qPrintable(assembledPath)); + return {}; + } + + for (int i = 0; i < m_blocks.size(); i++) { + int n = m_blocks[i]; + QString path = blockFilePath(n); + QFile bf(path); + if (bf.open(QIODevice::ReadOnly)) { + af.write(bf.readAll()); + bf.close(); + QFile::remove(path); + } + } + af.close(); + + m_mode = SINGLE_FILE; + m_filePath = assembledPath; + m_fileSize = m_totalSize; + m_blocks.clear(); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MediaBlockReader: assembled %s", qPrintable(assembledPath)); + return assembledPath; +} + +QJsonObject MediaBlockReader::localAttrsAfterAssembly() const { + QJsonObject obj; + obj["st"] = "fl"; + obj["fp"] = QString("%1_%2_%3.%4").arg(m_dt, m_basename, m_suffix, m_ext); + QJsonArray bl; + for (int i = 0; i < m_totalBlocks; i++) bl.append(i); + obj["bl"] = bl; + return obj; +} + +QString MediaBlockReader::debugPath() const { + if (m_mode == SINGLE_FILE) return m_filePath; + if (m_mode == FRAGMENTED) return QString("%1/%2_*_%3_%4.%5") + .arg(m_mediaDir, m_dt, m_basename, m_suffix, m_ext); + return "none"; +} + +/* ─── media_split_to_blocks ─── */ + +qint64 media_calc_block_size(qint64 fileSize) { + if (fileSize <= 0) return 0; + qint64 target = (fileSize + 29) / 30; /* ceil(file_size / 30) */ + if (target < 10 * 1024 * 1024) target = 10 * 1024 * 1024; + if (target > 25 * 1024 * 1024) target = 25 * 1024 * 1024; + return target; +} + +MediaBlockInfo media_split_to_blocks(const QString& srcFile, + const QString& mediaDir, + bool removeSrc) +{ + MediaBlockInfo info; + QFileInfo fi(srcFile); + if (!fi.exists()) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "media_split_to_blocks: src not found: %s", qPrintable(srcFile)); + return info; + } + + info.fileSize = fi.size(); + if (info.fileSize == 0) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "media_split_to_blocks: empty file: %s", qPrintable(srcFile)); + return info; + } + + info.blockSize = media_calc_block_size(info.fileSize); + info.numBlocks = (int)((info.fileSize + info.blockSize - 1) / info.blockSize); + info.ext = fi.suffix(); + info.basename = fi.completeBaseName(); + + QDateTime now = QDateTime::currentDateTimeUtc(); + info.dt = now.toString("yyyyMMdd-HHmmss"); + uint16_t rnd = QRandomGenerator::global()->generate() & 0xFFFF; + info.suffix = QString::number(rnd, 16).rightJustified(4, '0'); + + QDir().mkpath(mediaDir); + + QFile src(srcFile); + if (!src.open(QIODevice::ReadOnly)) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "media_split_to_blocks: cannot open src: %s", qPrintable(srcFile)); + return info; + } + + qint64 remaining = info.fileSize; + for (int n = 0; n < info.numBlocks; n++) { + qint64 chunk = qMin(remaining, info.blockSize); + QString path = QString("%1/%2_%3_%4_%5.%6") + .arg(mediaDir, info.dt).arg(n).arg(info.basename, info.suffix, info.ext); + QFile dst(path); + if (!dst.open(QIODevice::WriteOnly)) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "media_split_to_blocks: cannot create block %d: %s", n, qPrintable(path)); + src.close(); return info; + } + dst.write(src.read(chunk)); + dst.close(); + remaining -= chunk; + } + src.close(); + + if (removeSrc) QFile::remove(srcFile); + + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "media_split_to_blocks: %s -> %d blocks (%lld bytes each) in %s", + qPrintable(fi.fileName()), info.numBlocks, (long long)info.blockSize, qPrintable(mediaDir)); + return info; +} diff --git a/tools/chatgui/src/media_blocks.h b/tools/chatgui/src/media_blocks.h new file mode 100644 index 00000000..168ec776 --- /dev/null +++ b/tools/chatgui/src/media_blocks.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +class MediaBlockReader { +public: + MediaBlockReader(); + + void initFromAttrs(const QString& mediaDir, + const QString& dt, const QString& basename, + const QString& suffix, const QString& ext, + const QJsonObject& localAttrs, + qint64 blockSize, qint64 totalSize, int totalBlocks); + + static MediaBlockReader fromLegacyFile(const QString& filePath); + + bool isValid() const { return m_valid; } + bool isComplete() const { return m_complete; } + bool isSingleFile() const { return m_mode == SINGLE_FILE; } + bool isFragmented() const { return m_mode == FRAGMENTED; } + qint64 totalSize() const { return m_totalSize; } + + QByteArray readAll(); + QByteArray readRange(qint64 offset, qint64 size); + bool seek(qint64 pos); + qint64 pos() const { return m_pos; } + bool atEnd() const; + + bool saveToFile(const QString& destPath); + QString assembleAndCleanup(); + QJsonObject localAttrsAfterAssembly() const; + + QString debugPath() const; + +private: + enum Mode { NONE, SINGLE_FILE, FRAGMENTED }; + Mode m_mode = NONE; + bool m_valid = false; + bool m_complete = false; + qint64 m_pos = 0; + + QString m_filePath; + qint64 m_fileSize = 0; + + QString m_mediaDir, m_dt, m_basename, m_suffix, m_ext; + QList m_blocks; + qint64 m_blockSize = 0; + qint64 m_totalSize = 0; + int m_totalBlocks = 0; + + qint64 blockSizeForN(int n) const; + QString blockFilePath(int n) const; +}; + +struct MediaBlockInfo { + QString dt; + QString basename; + QString suffix; + QString ext; + int numBlocks = 0; + qint64 fileSize = 0; + qint64 blockSize = 0; +}; + +qint64 media_calc_block_size(qint64 fileSize); +MediaBlockInfo media_split_to_blocks(const QString& srcFile, + const QString& mediaDir, + bool removeSrc = true); diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h index 853f7319..0b1319a2 100644 --- a/tools/chatgui/src/messagedelegate.h +++ b/tools/chatgui/src/messagedelegate.h @@ -20,7 +20,12 @@ enum MessageDataRole { MsgVoicePlayStateRole = Qt::UserRole + 15, MsgVoicePlayPosRole = Qt::UserRole + 16, MsgVoiceWaveformRole = Qt::UserRole + 17, - MsgAuthorNodeIdRole = Qt::UserRole + 20, + MsgVoiceFileSizeRole = Qt::UserRole + 18, + MsgVoiceBlockSizeRole = Qt::UserRole + 19, + MsgAuthorNodeIdRole = Qt::UserRole + 20, + MsgVoiceNumBlocksRole = Qt::UserRole + 21, + MsgVoiceSigsRole = Qt::UserRole + 22, + MsgFileDisplayNameRole = Qt::UserRole + 23, }; class MessageDelegate : public QStyledItemDelegate { diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index e91e221b..f52e219e 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -87,8 +87,7 @@ static QString contentTypeFromJson(const QByteArray& data) { static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data, const QString& contentType, const QString& mediaDirBase, - const QString& channelId) { - (void)channelId; + const QString& channelId, const QByteArray& localAttrs) { QString ct = contentType; if (ct.isEmpty()) ct = contentTypeFromJson(data); if (ct.isEmpty()) ct = "text/plain"; @@ -97,14 +96,33 @@ static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data, if (ct == "audio/opus") { QString raw = QString::fromUtf8(data); int pipe1 = raw.indexOf('|'); - if (pipe1 < 0 && raw.startsWith('{')) { - /* old double-nested JSON from before the fix — skip */ - return; - } + if (pipe1 < 0 && raw.startsWith('{')) return; int pipe2 = raw.indexOf('|', pipe1 + 1); QString fname = pipe1 >= 0 ? raw.left(pipe1) : raw; QString dur = pipe2 >= 0 ? raw.mid(pipe1 + 1, pipe2 - pipe1 - 1) : raw.mid(pipe1 + 1); - QString wfData = pipe2 >= 0 ? raw.mid(pipe2 + 1) : QString(); + QString rest = pipe2 >= 0 ? raw.mid(pipe2 + 1) : QString(); + + /* find end of wf (comma-separated floats), then file metadata */ + int afterWf = -1; + int pipeCount = 0; + for (int i = 0; i < rest.size(); i++) { + if (rest[i] == '|') { pipeCount++; if (pipeCount >= 4) { afterWf = i; break; } } + } + QString wfData; + if (afterWf >= 0) { + wfData = rest.left(afterWf); + QString meta = rest.mid(afterWf + 1); + QStringList parts = meta.split('|'); + if (parts.size() >= 4) { + item->setData(parts[0].toULongLong(), MsgVoiceFileSizeRole); + item->setData(parts[1].toULongLong(), MsgVoiceBlockSizeRole); + item->setData(parts[2].toUInt(), MsgVoiceNumBlocksRole); + item->setData(parts[3], MsgVoiceSigsRole); + } + } else { + wfData = rest; + } + if (!dur.isEmpty()) item->setData(dur + "s", MsgVoiceDurationRole); if (!fname.isEmpty()) { QString fullPath = mediaDirBase + "/" + channelId + "/" + fname; @@ -122,6 +140,10 @@ static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data, item->setData(wf, MsgVoiceWaveformRole); } } + if (ct == "application/octet-stream") { + QString fname = QString::fromUtf8(data); + item->setData(fname, MsgFileDisplayNameRole); + } } static void setMsg(QStandardItem *item, @@ -212,12 +234,14 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req); }); - connect(m_inputBar, &InputBar::sendVoiceMessage, this, [this](const QString &filePath, int durationMs, const QString& wfStr) { + connect(m_inputBar, &InputBar::sendVoiceMessage, this, [this](const QString& dt, const QString& basename, + const QString& suffix, const QString& ext, + int numBlocks, qint64 blockSize, + int durationMs, const QString& wfStr) { if (m_currentChannelId.isEmpty()) return; - QFileInfo fi(filePath); - QString fileName = fi.fileName(); - QByteArray textData = (fileName + "|" + + QString displayName = basename + "." + ext; + QByteArray textData = (displayName + "|" + QString::number(durationMs / 1000.0, 'f', 1) + "|" + wfStr).toUtf8(); auto ts = QDateTime::currentMSecsSinceEpoch(); @@ -227,6 +251,38 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent snprintf(req->channel_id, sizeof(req->channel_id), "%s", m_currentChannelId.toUtf8().constData()); snprintf(req->content_type, sizeof(req->content_type), "audio/opus"); + snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt.toUtf8().constData()); + snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename.toUtf8().constData()); + snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix.toUtf8().constData()); + snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext.toUtf8().constData()); + req->media_num_blocks = (uint32_t)numBlocks; + req->data = (uint8_t*)(req + 1); + req->data_len = (uint32_t)textData.size(); + memcpy(req->data, textData.constData(), textData.size()); + req->timestamp = (uint64_t)ts; + gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req); + }); + + connect(m_inputBar, &InputBar::sendFileAttachment, this, [this](const QString& dt, const QString& basename, + const QString& suffix, const QString& ext, + int numBlocks, qint64 blockSize, + const QString& displayName) { + if (m_currentChannelId.isEmpty()) return; + + QByteArray textData = displayName.toUtf8(); + auto ts = QDateTime::currentMSecsSinceEpoch(); + + struct chat_msg_submit* req = (struct chat_msg_submit*) + u_malloc(sizeof(struct chat_msg_submit) + textData.size()); + memset(req, 0, sizeof(*req)); + snprintf(req->channel_id, sizeof(req->channel_id), "%s", + m_currentChannelId.toUtf8().constData()); + snprintf(req->content_type, sizeof(req->content_type), "application/octet-stream"); + snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt.toUtf8().constData()); + snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename.toUtf8().constData()); + snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix.toUtf8().constData()); + snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext.toUtf8().constData()); + req->media_num_blocks = (uint32_t)numBlocks; req->data = (uint8_t*)(req + 1); req->data_len = (uint32_t)textData.size(); memcpy(req->data, textData.constData(), textData.size()); @@ -316,7 +372,7 @@ void MessageList::loadChannel(const QString& channelId) { item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole); item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); setVoiceMessageRoles(item, m.data, m.contentType, - m_db ? m_db->dbPath() + "/media" : QString(), channelId); + m_db ? m_db->dbPath() + "/media" : QString(), channelId, m.localAttrs); m_model->appendRow(item); } @@ -381,7 +437,7 @@ void MessageList::refresh() { item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole); item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); setVoiceMessageRoles(item, m.data, m.contentType, - m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId); + m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs); m_model->appendRow(item); while (m_model->rowCount() > 100) m_model->removeRow(0); @@ -416,7 +472,7 @@ void MessageList::addMessage(const QString& channelId, quint64 authorNodeId, item->setData(QVariant::fromValue(authorNodeId), MsgAuthorNodeIdRole); item->setData(isSelf ? 1 : 0, MsgIsOutgoingRole); setVoiceMessageRoles(item, content, QString(), - m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId); + m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, QByteArray()); m_model->appendRow(item); if (m_model->rowCount() > 50) diff --git a/tools/chatgui/src/voiceplayback.cpp b/tools/chatgui/src/voiceplayback.cpp index 2d1942f9..4105a34a 100644 --- a/tools/chatgui/src/voiceplayback.cpp +++ b/tools/chatgui/src/voiceplayback.cpp @@ -1,5 +1,6 @@ #include "voiceplayback.h" #include "sound_manager.h" +#include "media_blocks.h" extern "C" { #include "../../../lib/opus_codec.h" @@ -89,3 +90,63 @@ void VoicePlayer::playOpusFile(const QString& filePath) { pcm.size(), sampleRate, channels, duration); SoundManager::instance()->playRawPcm(pcm.data(), pcm.size() / channels, sampleRate, channels); } + +void VoicePlayer::playOpusFromReader(MediaBlockReader& reader) { + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "VoicePlayer::playOpusFromReader: %s", qPrintable(reader.debugPath())); + std::vector pcm; + int sampleRate, channels; + float duration; + if (decodeOpusFromReader(reader, pcm, sampleRate, channels, duration) <= 0) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "VoicePlayer::playOpusFromReader: decode failed"); + return; + } + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "VoicePlayer::playOpusFromReader: decoded %zu samples %dHz %dch %.1fs", + pcm.size(), sampleRate, channels, duration); + SoundManager::instance()->playRawPcm(pcm.data(), pcm.size() / channels, sampleRate, channels); +} + +int VoicePlayer::decodeOpusFromReader(MediaBlockReader& reader, std::vector& outPcm, + int& outSampleRate, int& outChannels, float& outDurationSec) { + QByteArray data = reader.readAll(); + if (data.isEmpty()) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "VoicePlayer::decodeOpusFromReader: empty data"); + return -1; + } + + const uint8_t* ptr = (const uint8_t*)data.constData(); + size_t len = (size_t)data.size(); + size_t off = 0; + + if (off + 12 > len) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "VoicePlayer: header truncated"); return -1; } + uint32_t magic; memcpy(&magic, ptr + off, 4); off += 4; + if (magic != OPUS_MAGIC) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "VoicePlayer: bad magic %08x", magic); return -1; } + uint32_t sr32; memcpy(&sr32, ptr + off, 4); off += 4; + uint16_t ch16; memcpy(&ch16, ptr + off, 2); off += 2; + uint16_t frameSamples; memcpy(&frameSamples, ptr + off, 2); off += 2; + int sampleRate = (int)sr32, channels = (int)ch16; + outSampleRate = sampleRate; outChannels = channels; + + opus_codec_decoder_t* dec = opus_codec_decoder_create(sampleRate, channels); + if (!dec) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "VoicePlayer: decoder create failed"); return -1; } + + outPcm.clear(); + int frameCount = 0; + while (off + 2 <= len) { + uint16_t plen; memcpy(&plen, ptr + off, 2); off += 2; + if (plen == 0) break; + if (off + plen > len) break; + std::vector frameBuf(frameSamples * channels); + int decoded = opus_codec_decode(dec, ptr + off, plen, frameBuf.data(), frameSamples); + off += plen; + if (decoded > 0) { + size_t cur = outPcm.size(); + outPcm.resize(cur + decoded * channels); + memcpy(outPcm.data() + cur, frameBuf.data(), decoded * channels * sizeof(int16_t)); + frameCount++; + } + } + outDurationSec = (float)frameCount * 20.0f / 1000.0f; + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "VoicePlayer: decoded %d frames (%.1fs)", frameCount, outDurationSec); + opus_codec_decoder_destroy(dec); + return frameCount; +} diff --git a/tools/chatgui/src/voiceplayback.h b/tools/chatgui/src/voiceplayback.h index 80fefc93..1a9f9fc2 100644 --- a/tools/chatgui/src/voiceplayback.h +++ b/tools/chatgui/src/voiceplayback.h @@ -4,9 +4,14 @@ #include #include +class MediaBlockReader; + class VoicePlayer { public: static void playOpusFile(const QString& filePath); static int decodeOpusFile(const QString& filePath, std::vector& outPcm, - int& outSampleRate, int& outChannels, float& outDurationSec); + int& outSampleRate, int& outChannels, float& outDurationSec); + static void playOpusFromReader(MediaBlockReader& reader); + static int decodeOpusFromReader(MediaBlockReader& reader, std::vector& outPcm, + int& outSampleRate, int& outChannels, float& outDurationSec); };