Browse Source

media: Ed25519-подписи блоков, local_attrs, MediaBlockReader, файловые вложения

topo_upd
Evgeny 2 months ago
parent
commit
d4a396f888
  1. 1
      .gitignore
  2. 1
      src/chat/chat_core.c
  3. 10
      src/chat/chat_core.h
  4. 1
      src/chat/chat_core_priv.h
  5. 199
      src/chat/chat_msg.c
  6. 30
      src/chat/db_sync.c
  7. 3
      src/chat/db_sync.h
  8. 2
      tests/test_chat_sync_stress.c
  9. 6
      tests/test_db_sync.c
  10. 42
      tools/chatgui/CMakeLists.txt
  11. 6
      tools/chatgui/db/db_manager.cpp
  12. 1
      tools/chatgui/db/db_manager.h
  13. 89
      tools/chatgui/src/inputbar.cpp
  14. 11
      tools/chatgui/src/inputbar.h
  15. 288
      tools/chatgui/src/media_blocks.cpp
  16. 72
      tools/chatgui/src/media_blocks.h
  17. 7
      tools/chatgui/src/messagedelegate.h
  18. 84
      tools/chatgui/src/messagelist.cpp
  19. 61
      tools/chatgui/src/voiceplayback.cpp
  20. 7
      tools/chatgui/src/voiceplayback.h

1
.gitignore vendored

@ -2,6 +2,7 @@
/build/
build-win/
tools/chatgui/build/
tools/chatgui/ffmpeg/ffmpeg_build/
# Build outputs
*.o

1
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;

10
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);

1
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;

199
src/chat/chat_msg.c

@ -13,6 +13,15 @@
#include "../../lib/platform_compat.h"
#include <openssl/sha.h>
#include <unistd.h>
#include <sys/stat.h>
#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: <base>|<file_size>|<block_size>|<num_blocks>|<sig_hex>,... */
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 ─── */

30
src/chat/db_sync.c

@ -15,6 +15,7 @@
#include "../../lib/platform_compat.h"
#include <string.h>
#include <openssl/evp.h>
#include <unistd.h>
// ---- 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;
}

3
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);

2
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) {

6
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;

42
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()
# ============================================================================

6
tools/chatgui/db/db_manager.cpp

@ -170,7 +170,7 @@ QString DbManager::getChannelLastMessage(const QString& channelId) const {
QList<MessageRow> DbManager::getMessages(const QString& chId, int limit) const {
QList<MessageRow> 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<MessageRow> 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<MessageRow> DbManager::getMessages(const QString& chId, int limit) const {
QList<MessageRow> DbManager::getMessagesSince(const QString& chId, qint64 sinceId) const {
QList<MessageRow> 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<MessageRow> 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);

1
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;
};

89
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 <QDebug>
#include <QTextEdit>
@ -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) {

11
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;

288
tools/chatgui/src/media_blocks.cpp

@ -0,0 +1,288 @@
#include "media_blocks.h"
#include "../../lib/debug_config.h"
#include <QDir>
#include <QFileInfo>
#include <QDateTime>
#include <QRandomGenerator>
#include <cmath>
/* ─── 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;
}

72
tools/chatgui/src/media_blocks.h

@ -0,0 +1,72 @@
#pragma once
#include <QString>
#include <QList>
#include <QFile>
#include <QJsonObject>
#include <cstdint>
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<int> 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);

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

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

61
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<int16_t> 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<int16_t>& 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<int16_t> 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;
}

7
tools/chatgui/src/voiceplayback.h

@ -4,9 +4,14 @@
#include <vector>
#include <cstdint>
class MediaBlockReader;
class VoicePlayer {
public:
static void playOpusFile(const QString& filePath);
static int decodeOpusFile(const QString& filePath, std::vector<int16_t>& 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<int16_t>& outPcm,
int& outSampleRate, int& outChannels, float& outDurationSec);
};

Loading…
Cancel
Save