diff --git a/src/Makefile.am b/src/Makefile.am index 99894508..74d291cb 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -175,6 +175,8 @@ utun_CORE_SOURCES = \ chat/invite_build.c \ chat/chat_headless_control.c \ dm/dm_core.c \ + dm/dm_history.c \ + dm/dm_history.h \ dm/dm_crypto.c \ dm/dm_mailbox.c \ dm/dm_media.c \ @@ -340,6 +342,8 @@ libutun_a_SOURCES = \ chat/invite_build.c \ chat/chat_headless_control.c \ dm/dm_core.c \ + dm/dm_history.c \ + dm/dm_history.h \ dm/dm_crypto.c \ dm/dm_mailbox.c \ dm/dm_media.c \ diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 48efed2e..00c78ca4 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -53,6 +53,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [55]="RADIO_MUTE_RESULT", [56]="MSG_SUBMITTED", [57]="CPU_LOAD", + [59]="DM_DELETE_RESULT", }; const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 1d3c9cf6..90a5ba24 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -53,7 +53,7 @@ extern "C" { #define CHAT_EVT_INVITE_CANDIDATES 30 /* [ch_id_len:1][ch_id:var][auto_node_id:8][count:2][node_id:8]* — достижимые узлы для invite */ #define CHAT_EVT_CHANNEL_DELETED 31 /* [ch_id_len:1][ch_id:var] — канал удалён локально */ #define CHAT_EVT_DM_MSG_RECEIVED 32 /* [conv_id_len:1][conv_id:var][author_node_id:8] */ -#define CHAT_EVT_DM_CONV_UPDATED 33 /* [conv_id_len:1][conv_id:var] — беседа создана/изменена */ +#define CHAT_EVT_DM_CONV_UPDATED 33 /* [conv_id_len:1][conv_id:var][generation:8LE] — беседа создана/изменена */ #define CHAT_EVT_CALL_INCOMING 34 /* [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */ #define CHAT_EVT_CALL_RINGING 35 /* [call_id:8] */ #define CHAT_EVT_CALL_ACCEPTED 36 /* [call_id:8] */ @@ -69,7 +69,7 @@ extern "C" { #define CHAT_EVT_ATTACHMENT_PREPARATION 46 /* [target_len:1][target:var][job_uuid:16][state:1], 0=start 1=ready 2=failed */ #define CHAT_EVT_DM_CALL_ROUTE 47 /* [conv_id_len:1][conv_id:var][peer_node_id:8][group_id:8], 0 = нет BGP-пути */ -#define CHAT_EVT_DM_INVITE_RESULT 48 /* [request_id:8][result:4][peer_node_id:8][conv_id:8], dm_invite_result */ +#define CHAT_EVT_DM_INVITE_RESULT 48 /* [request_id:8][result:4][peer_node_id:8][conv_id:8][generation:8], dm_invite_result */ #define CHAT_EVT_MEMBER_FILES_RESPONSE 49 /* [group:8LE][server:8LE][mf wire response] */ #define CHAT_EVT_MEMBER_FILES_TRANSFER 50 /* id/group/server/owner/done/total:8LE,status:2LE,state:1 */ #define CHAT_EVT_AVATAR_RESULT 51 /* request_id/group:8LE, failed:1; результат публикации ссылки профиля */ @@ -80,6 +80,7 @@ extern "C" { #define CHAT_EVT_MSG_SUBMITTED 56 /* [request_id:8LE][success:1] — результат локального сохранения сообщения */ #define CHAT_EVT_CPU_LOAD 57 /* [valid:1][average_percent:1] — CPU потока uasync, среднее за секунду, 0..100 */ #define CHAT_EVT_STREAM_SOURCES 58 /* [group_id:8BE][valid:1][JSON:utf8] — доступные публикации выбранного канала */ +#define CHAT_EVT_DM_DELETE_RESULT 59 /* [request:8LE][conv:8LE][generation:8LE][success:1] — после транзакции */ typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); diff --git a/src/chat/chat_member.c b/src/chat/chat_member.c index a286cec1..ac8cc843 100644 --- a/src/chat/chat_member.c +++ b/src/chat/chat_member.c @@ -85,10 +85,14 @@ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* memcpy(dst + 11, &rtt, 2); dst[13] = src->nat_type; dst[14] = src->storage; - memcpy(dst + 15, src->name, 64); + /* Поле на проводе всегда завершается NUL и не содержит хвост памяти исходной строки. */ + strncpy((char*)dst + 15, src->name, sizeof(src->name) - 1); + dst[78] = 0; mf_put64(dst + 79, src->avatar_server); memcpy(dst + 87, src->avatar_file, 70); dst[157] = src->storage_role; + DEBUG_TRACE(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: serialized node=%016llx name_bytes=%zu zero_padded=1", + (unsigned long long)src->node_id, strlen((const char*)dst + 15)); } int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst) { diff --git a/src/dm/dm_core.c b/src/dm/dm_core.c index 863b2255..97eb0c6f 100644 --- a/src/dm/dm_core.c +++ b/src/dm/dm_core.c @@ -11,6 +11,7 @@ #include "dm_core.h" #include "dm_crypto.h" +#include "dm_history.h" #include "dm_mailbox.h" #include "../chat/chat_core.h" #include "../chat/chat_event.h" @@ -450,12 +451,15 @@ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len return -1; } if (dm_verify_message(inst, inst->node_id, msg, len) != 0) return -1; + int deleted = dm_history_deleted(inst, msg, len, 0, NULL); + if (deleted < 0) return -1; uint64_t conv, seq, author; memcpy(&conv, msg, 8); memcpy(&seq, msg + 8, 8); memcpy(&author, msg + 24, 8); char conv_id[64]; snprintf(conv_id, sizeof(conv_id), "%llu", (unsigned long long)conv); + if (deleted) goto receipt; struct dm_conv c; int created = dm_conv_load(dm, conv_id, &c) != 0; if (created) { @@ -490,10 +494,12 @@ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len uint64_t ts; memcpy(&ts, msg + 16, 8); if (ts > c.last_ts) c.last_ts = ts; - if (inserted < 0 || dm_conv_save(dm, &c) != 0 || dm_media_track(inst, 0, msg, len) != 0 || dm_exec(dm, "COMMIT") != 0) { + if (inserted < 0 || dm_conv_save(dm, &c) != 0 || dm_media_track(inst, 0, msg, len) != 0 || + (inserted && dm_history_show(inst, conv_id)) || dm_exec(dm, "COMMIT") != 0) { dm_exec(dm, "ROLLBACK"); return -1; } +receipt: memcpy(receipt, "DMACK001", 8); memcpy(receipt + 8, msg, 16); memcpy(receipt + 24, &author, 8); @@ -503,6 +509,7 @@ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: receipt signing failed conv=%s seq=%llu", DM_ID, conv_id, (unsigned long long)seq); return -1; } + if (deleted) return 0; DEBUG_DEBUG(DEBUG_CATEGORY_DM, "%s: committed receive conv=%s seq=%llu new=%d plain=%zu", DM_ID, conv_id, (unsigned long long)seq, inserted, plain_len); if (inserted) { @@ -511,7 +518,12 @@ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len evt[0] = (uint8_t)cl; memcpy(evt + 1, conv_id, cl); memcpy(evt + 1 + cl, &author, 8); - if (created) chat_event_post(inst, CHAT_EVT_DM_CONV_UPDATED, evt, 1 + (int)cl); + if (created) { + uint64_t generation = dm_history_generation(inst, conv_id); + memcpy(evt + 1 + cl, &generation, 8); + chat_event_post(inst, CHAT_EVT_DM_CONV_UPDATED, evt, 1 + (int)cl + 8); + memcpy(evt + 1 + cl, &author, 8); + } chat_event_post(inst, CHAT_EVT_DM_MSG_RECEIVED, evt, 1 + (int)cl + 8); DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: received conv=%s seq=%llu author=%llu", DM_ID, conv_id, (unsigned long long)seq, (unsigned long long)author); @@ -739,13 +751,17 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id, (unsigned long long)c.group_id); } + if (dm_history_show(inst, conv_id)) return -1; + /* событие GUI (всегда, чтобы GUI мог открыть/выделить беседу) */ { size_t cl = strlen(conv_id); - uint8_t evt[1 + 64]; + uint8_t evt[1 + 64 + 8]; evt[0] = (uint8_t)cl; memcpy(evt + 1, conv_id, cl); - chat_event_post(inst, CHAT_EVT_DM_CONV_UPDATED, evt, 1 + (int)cl); + uint64_t generation = dm_history_generation(inst, conv_id); + memcpy(evt + 1 + cl, &generation, 8); + chat_event_post(inst, CHAT_EVT_DM_CONV_UPDATED, evt, 1 + (int)cl + 8); } return 0; } @@ -763,6 +779,9 @@ int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: send conversation missing/seq exhausted conv=%s", DM_ID, conv_id); return -1; } + if (!dm_history_current(inst, conv_id, dm_history_generation(inst, conv_id))) { + DEBUG_WARN(DEBUG_CATEGORY_DM, "DM send: hidden conversation conv=%s", conv_id); return -1; + } uint64_t seq = c.last_out_seq + 1; uint64_t ts = (uint64_t)(ntp_time_get_us(inst) / 1000); uint8_t* body = NULL; @@ -815,7 +834,7 @@ int dm_core_init(struct UTUN_INSTANCE* inst) { inst->dm = dm; dm->inst = inst; dm->db = inst->topo_sqlite_db; - if (!dm->db || dm_create_tables(dm) != 0) goto fail; + if (!dm->db || dm_create_tables(dm) != 0 || dm_history_init(inst)) goto fail; if (etcp_router_bind(inst, ETCP_RT_ID_DM, dm_recv_cb) != 0) goto fail; dm->initialized = 1; dm->timer = uasync_set_timeout(inst->ua, 10000, dm, dm_tick, "dm_outbox"); @@ -877,8 +896,9 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf int failed = strbuf_addf(&json, "[") < 0; sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(dm->db, - "SELECT conv_id, peer_node_id, COALESCE(peer_name,''), last_ts, last_out_seq, last_in_seq" - " FROM dm_conversations ORDER BY last_ts DESC LIMIT 200", + "SELECT c.conv_id,peer_node_id,COALESCE(peer_name,''),last_ts,last_out_seq,last_in_seq,COALESCE(h.generation,1)" + " FROM dm_conversations c LEFT JOIN dm_history h USING(conv_id) WHERE COALESCE(h.hidden,0)=0" + " ORDER BY last_ts DESC LIMIT 200", -1, &st, NULL) == SQLITE_OK) { int first = 1; while (sqlite3_step(st) == SQLITE_ROW) { @@ -898,10 +918,11 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf json_escape(name ? name : "", esc_name, sizeof(esc_name)); if (strbuf_addf(&json, "%s{\"conv_id\":\"%s\",\"peer_node_id\":%llu,\"peer_name\":\"%s\"," - "\"last_ts\":%llu,\"out_seq\":%llu,\"in_seq\":%llu}", + "\"last_ts\":%llu,\"out_seq\":%llu,\"in_seq\":%llu,\"generation\":%llu}", first ? "" : ",", conv_id, (unsigned long long)peer, esc_name, (unsigned long long)last_ts, - (unsigned long long)out_seq, (unsigned long long)in_seq) < 0) { failed = 1; break; } + (unsigned long long)out_seq, (unsigned long long)in_seq, + (unsigned long long)sqlite3_column_int64(st, 6)) < 0) { failed = 1; break; } first = 0; } if (sqlite3_errcode(dm->db) != SQLITE_DONE && sqlite3_errcode(dm->db) != SQLITE_OK) failed = 1; @@ -1045,7 +1066,8 @@ void dm_send_trampoline(void* arg) { if (!req) return; struct UTUN_INSTANCE* inst = req->inst; char conv[64]; strncpy(conv, req->conv_id, sizeof(conv) - 1); conv[sizeof(conv) - 1] = '\0'; - int rc = dm_send(inst, conv, "text", req->data, req->data_len); + uint64_t generation = req->generation ? req->generation : dm_history_generation(inst, conv); + int rc = dm_history_current(inst, conv, generation) ? dm_send(inst, conv, "text", req->data, req->data_len) : -1; u_free(arg); if (rc != 0) DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: dm_send_trampoline — dm_send failed conv=%s", @@ -1058,9 +1080,11 @@ static void dm_invite_done(struct dm_invite_req* req, int result) { if (dm && dm->invite == req) dm->invite = NULL; uint64_t conv = dm_derive_conv_id(req->inst->node_id, req->peer_node_id); int32_t status = result; - uint8_t event[28]; + uint8_t event[36]; memcpy(event, &req->request_id, 8); memcpy(event + 8, &status, 4); memcpy(event + 12, &req->peer_node_id, 8); memcpy(event + 20, &conv, 8); + char id[64]; snprintf(id, sizeof(id), "%llu", (unsigned long long)conv); + uint64_t generation = dm_history_generation(req->inst, id); memcpy(event + 28, &generation, 8); DEBUG_INFO(DEBUG_CATEGORY_DM, "invite PM: finished request=%llu peer=%llu ch=%llu result=%d", (unsigned long long)req->request_id, (unsigned long long)req->peer_node_id, (unsigned long long)req->channel_id, result); @@ -1080,6 +1104,7 @@ static void dm_invite_ready(void* arg, int result, const char* link) { } char conv[64]; snprintf(conv, sizeof(conv), "%llu", (unsigned long long)dm_derive_conv_id(req->inst->node_id, req->peer_node_id)); + if (!dm_history_current(req->inst, conv, req->generation)) { dm_invite_done(req, DM_INVITE_CANCELLED); return; } size_t bytes = strlen(link); sqlite3_stmt* st = NULL; char channel[64], payload[DM_DATA_MAX]; @@ -1147,6 +1172,9 @@ void dm_invite_trampoline(void* arg) { dm_invite_done(req, DM_INVITE_START_ERROR); return; } } + if (dm_history_show(req->inst, conv)) { dm_invite_done(req, DM_INVITE_START_ERROR); return; } + req->generation = dm_history_generation(req->inst, conv); + if (!dm_history_current(req->inst, conv, req->generation)) { dm_invite_done(req, DM_INVITE_CANCELLED); return; } DEBUG_INFO(DEBUG_CATEGORY_DM, "invite PM: building request=%llu peer=%llu source=%s ch=%s conv=%s", (unsigned long long)req->request_id, (unsigned long long)req->peer_node_id, req->source_ch_id, channel, conv); dm->invite = req; @@ -1154,6 +1182,25 @@ void dm_invite_trampoline(void* arg) { if (!req->pending) dm_invite_ready(req, CHAT_JOIN_ERROR, NULL); } +void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv) { + struct dm_state* dm = dm_of(inst); + if (dm && dm->invite && dm_derive_conv_id(inst->node_id, dm->invite->peer_node_id) == strtoull(conv, NULL, 10)) + chat_invite_build_cancel(dm->invite->pending); +} + +void dm_delete_trampoline(void* arg) { + struct dm_delete_req* req = arg; + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM delete: missing request"); return; } + int success = dm_history_delete(req->inst, req->conv_id, req->generation) == 0; + uint64_t conv = strtoull(req->conv_id, NULL, 10); + uint8_t event[25]; + memcpy(event, &req->request_id, 8); memcpy(event + 8, &conv, 8); memcpy(event + 16, &req->generation, 8); event[24] = success; + DEBUG_INFO(DEBUG_CATEGORY_DM, "DM delete result: request=%llu conv=%s generation=%llu success=%d", + (unsigned long long)req->request_id, req->conv_id, (unsigned long long)req->generation, success); + chat_event_post(req->inst, CHAT_EVT_DM_DELETE_RESULT, event, sizeof(event)); + u_free(req); +} + /* Единая BGP-проверка для кнопки PM и запуска звонка; только uasync-поток. */ int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id) { struct dm_state* dm = dm_of(inst); diff --git a/src/dm/dm_core.h b/src/dm/dm_core.h index 30c4f5f1..2045235c 100644 --- a/src/dm/dm_core.h +++ b/src/dm/dm_core.h @@ -86,6 +86,11 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id, int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content_type, const uint8_t* data, uint32_t data_len); +/* Локальное удаление; generation защищает повторно открытую беседу от старого запроса. */ +struct dm_delete_req { struct UTUN_INSTANCE* inst; char conv_id[64]; uint64_t generation, request_id; }; +void dm_delete_trampoline(void* arg); +void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv); + /* Список бесед в JSON: [{conv_id,peer_node_id,peer_name,last_ts,out_seq,in_seq}] */ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len); @@ -111,6 +116,7 @@ void dm_start_trampoline(void* arg); struct dm_send_req { struct UTUN_INSTANCE* inst; char conv_id[64]; + uint64_t generation; /* 0: захватить при входе в uasync, до любой фоновой работы. */ uint32_t data_len; uint8_t* data; /* data_len байт (выделяется в хвосте структуры) */ }; @@ -127,7 +133,7 @@ enum dm_invite_result { }; struct dm_invite_req { struct UTUN_INSTANCE* inst; - uint64_t request_id, peer_node_id, channel_id; + uint64_t request_id, peer_node_id, channel_id, generation; char source_ch_id[64]; /* общая группа для новой PM; из шапки PM может быть пустой */ char peer_name[128]; int cancel; diff --git a/src/dm/dm_history.c b/src/dm/dm_history.c new file mode 100644 index 00000000..761e62d6 --- /dev/null +++ b/src/dm/dm_history.c @@ -0,0 +1,177 @@ +#include "dm_history.h" +#include "dm_core.h" +#include "dm_crypto.h" +#include "../utun_instance.h" +#include "../../lib/debug_config.h" +#include +#include +#include +#include +#include + +static int history_error(struct UTUN_INSTANCE* inst, const char* operation, const char* conv) { + DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM history: %s conv=%s sql=%s", operation, conv ? conv : "", + inst && inst->topo_sqlite_db ? sqlite3_errmsg(inst->topo_sqlite_db) : "database unavailable"); + return -1; +} + +int dm_history_init(struct UTUN_INSTANCE* inst) { + const char* sql = + "CREATE TABLE IF NOT EXISTS dm_history(conv_id TEXT PRIMARY KEY,generation INTEGER NOT NULL,hidden INTEGER NOT NULL);" + "CREATE TABLE IF NOT EXISTS dm_deleted_messages(conv_id TEXT NOT NULL,dir INTEGER NOT NULL,seq INTEGER NOT NULL," + "body_hash BLOB NOT NULL,media_id BLOB,media_receipt BLOB,PRIMARY KEY(conv_id,dir,seq));" + "CREATE TABLE IF NOT EXISTS dm_local_files(path TEXT PRIMARY KEY,conv_id TEXT NOT NULL,generation INTEGER NOT NULL," + "busy INTEGER NOT NULL DEFAULT 0,retry_at INTEGER NOT NULL DEFAULT 0);" + "UPDATE dm_local_files SET busy=0;"; + if (sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) != SQLITE_OK) return history_error(inst, "init", NULL); + DEBUG_INFO(DEBUG_CATEGORY_DM, "DM history: initialized persistent generations, replay records and file cleanup"); + return 0; +} + +uint64_t dm_history_generation(struct UTUN_INSTANCE* inst, const char* conv) { + sqlite3_stmt* st = NULL; + if (!inst || !inst->topo_sqlite_db || !conv || sqlite3_prepare_v2(inst->topo_sqlite_db, + "SELECT COALESCE(h.generation,1) FROM dm_conversations c LEFT JOIN dm_history h USING(conv_id) WHERE c.conv_id=?", + -1, &st, NULL) != SQLITE_OK) { history_error(inst, "generation prepare", conv); return 0; } + sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); + int rc = sqlite3_step(st); + uint64_t generation = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : 0; + if (rc != SQLITE_ROW && rc != SQLITE_DONE) history_error(inst, "generation read", conv); + sqlite3_finalize(st); + return generation; +} + +int dm_history_current(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation) { + sqlite3_stmt* st = NULL; + if (!inst || !inst->topo_sqlite_db || !conv || !generation || sqlite3_prepare_v2(inst->topo_sqlite_db, + "SELECT 1 FROM dm_conversations c LEFT JOIN dm_history h USING(conv_id)" + " WHERE c.conv_id=? AND COALESCE(h.generation,1)=? AND COALESCE(h.hidden,0)=0", -1, &st, NULL) != SQLITE_OK) { + history_error(inst, "current prepare", conv); return 0; + } + sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)generation); + int rc = sqlite3_step(st), current = rc == SQLITE_ROW; + if (rc != SQLITE_ROW && rc != SQLITE_DONE) history_error(inst, "current read", conv); + sqlite3_finalize(st); + if (!current) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM history: stale/hidden target conv=%s generation=%llu", conv, + (unsigned long long)generation); + return current; +} + +int dm_history_show(struct UTUN_INSTANCE* inst, const char* conv) { + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, "UPDATE dm_history SET hidden=0 WHERE conv_id=?", -1, &st, NULL) != SQLITE_OK) + return history_error(inst, "show prepare", conv); + sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); + int rc = sqlite3_step(st); sqlite3_finalize(st); + if (rc != SQLITE_DONE) return history_error(inst, "show", conv); + return 0; +} + +int dm_history_deleted(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int dir, uint8_t* media_receipt) { + uint64_t conv, seq; char id[64]; + if (media_receipt) memset(media_receipt, 0, DM_MEDIA_RECEIPT_SIZE); + size_t actual; + if (!dm_msg_len(body, len, &actual) || actual != len) return history_error(inst, "invalid replay body", NULL); + memcpy(&conv, body, 8); memcpy(&seq, body + 8, 8); + snprintf(id, sizeof(id), "%llu", (unsigned long long)conv); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, + "SELECT body_hash,media_receipt FROM dm_deleted_messages WHERE conv_id=? AND dir=? AND seq=?", -1, &st, NULL) != SQLITE_OK) + return history_error(inst, "replay prepare", id); + sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC); sqlite3_bind_int(st, 2, dir); sqlite3_bind_int64(st, 3, (sqlite3_int64)seq); + int rc = sqlite3_step(st), result = rc == SQLITE_DONE ? 0 : -1; + if (rc == SQLITE_ROW) { + uint8_t hash[32]; SHA256(body, len, hash); + if (sqlite3_column_bytes(st, 0) == sizeof(hash) && !memcmp(hash, sqlite3_column_blob(st, 0), sizeof(hash))) { + result = 1; + if (media_receipt && sqlite3_column_bytes(st, 1) == DM_MEDIA_RECEIPT_SIZE) + memcpy(media_receipt, sqlite3_column_blob(st, 1), DM_MEDIA_RECEIPT_SIZE); + DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM history: deleted replay conv=%s dir=%d seq=%llu", id, dir, (unsigned long long)seq); + } else DEBUG_WARN(DEBUG_CATEGORY_DM, "DM history: conflicting deleted message conv=%s dir=%d seq=%llu", + id, dir, (unsigned long long)seq); + } else if (rc != SQLITE_DONE) history_error(inst, "replay read", id); + sqlite3_finalize(st); + return result; +} + +/* Каноническое тело восстанавливается из сохранённых полей; у file оно уже есть в dm_files. */ +static int history_remember(struct UTUN_INSTANCE* inst, const char* conv) { + sqlite3_stmt* rows = NULL; sqlite3_stmt* put = NULL; + const char* sql = "SELECT m.dir,m.seq,m.ts,m.author,m.ct,m.data,m.sig,f.body,f.receipt" + " FROM dm_messages m LEFT JOIN dm_files f ON f.conv_id=m.conv_id AND f.dir=m.dir AND f.seq=m.seq WHERE m.conv_id=?"; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &rows, NULL) != SQLITE_OK || + sqlite3_prepare_v2(inst->topo_sqlite_db, + "INSERT INTO dm_deleted_messages(conv_id,dir,seq,body_hash,media_id,media_receipt) VALUES(?,?,?,?,?,?)", + -1, &put, NULL) != SQLITE_OK) goto fail; + sqlite3_bind_text(rows, 1, conv, -1, SQLITE_STATIC); + int rc; + while ((rc = sqlite3_step(rows)) == SQLITE_ROW) { + uint8_t body[DM_MSG_FIXED_HDR + 255 + 2 + DM_DATA_MAX + 16 + DM_MEDIA_DESC_SIZE + DM_MSG_SIG_SIZE], hash[32]; + size_t length = 0; + uint64_t number = strtoull(conv, NULL, 10), seq = (uint64_t)sqlite3_column_int64(rows, 1); + uint64_t ts = (uint64_t)sqlite3_column_int64(rows, 2), author = (uint64_t)sqlite3_column_int64(rows, 3); + int ct = sqlite3_column_bytes(rows, 4), data = sqlite3_column_bytes(rows, 5), dir = sqlite3_column_int(rows, 0); + const char* type = (const char*)sqlite3_column_text(rows, 4); + if (ct == 4 && type && !memcmp(type, "file", 4)) { + int bytes = sqlite3_column_bytes(rows, 7); + if (bytes <= 0 || bytes > sizeof(body)) goto fail; + length = (size_t)bytes; memcpy(body, sqlite3_column_blob(rows, 7), length); + } else { + if (ct > 255 || data < 16 || data > DM_DATA_MAX + 16 || sqlite3_column_bytes(rows, 6) != 64) goto fail; + memcpy(body, &number, 8); memcpy(body + 8, &seq, 8); memcpy(body + 16, &ts, 8); memcpy(body + 24, &author, 8); + body[32] = (uint8_t)ct; memcpy(body + 33, type, (size_t)ct); + uint16_t size = (uint16_t)data; memcpy(body + 33 + ct, &size, 2); + memcpy(body + 35 + ct, sqlite3_column_blob(rows, 5), (size_t)data); + memcpy(body + 35 + ct + data, sqlite3_column_blob(rows, 6), 64); length = (size_t)(35 + ct + data + 64); + } + size_t actual; + if (!dm_msg_len(body, length, &actual) || actual != length) goto fail; + SHA256(body, length, hash); + sqlite3_reset(put); sqlite3_clear_bindings(put); + sqlite3_bind_text(put, 1, conv, -1, SQLITE_STATIC); sqlite3_bind_int(put, 2, dir); + sqlite3_bind_int64(put, 3, (sqlite3_int64)seq); sqlite3_bind_blob(put, 4, hash, sizeof(hash), SQLITE_TRANSIENT); + const uint8_t* media = dm_message_media(body, length); + if (media) sqlite3_bind_blob(put, 5, media, 16, SQLITE_TRANSIENT); + if (sqlite3_column_bytes(rows, 8) == DM_MEDIA_RECEIPT_SIZE) + sqlite3_bind_blob(put, 6, sqlite3_column_blob(rows, 8), DM_MEDIA_RECEIPT_SIZE, SQLITE_TRANSIENT); + if (sqlite3_step(put) != SQLITE_DONE) goto fail; + } + if (rc != SQLITE_DONE) goto fail; + sqlite3_finalize(rows); sqlite3_finalize(put); + return 0; +fail: + sqlite3_finalize(rows); sqlite3_finalize(put); + return history_error(inst, "remember deleted bodies", conv); +} + +int dm_history_delete(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation) { + if (!inst || !inst->dm || !inst->dm_media || generation >= INT64_MAX || !dm_history_current(inst, conv, generation)) { + DEBUG_WARN(DEBUG_CATEGORY_DM, "DM history: delete rejected conv=%s generation=%llu", conv ? conv : "", + (unsigned long long)generation); return -1; + } + sqlite3* db = inst->topo_sqlite_db; sqlite3_stmt* st = NULL; + if (sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) return history_error(inst, "begin delete", conv); + if (history_remember(inst, conv) || dm_media_forget_prepare(inst, conv, generation)) goto fail; + const char* statements[] = { + "DELETE FROM dm_outbox WHERE conv_id=?", "DELETE FROM dm_messages WHERE conv_id=?", + "INSERT INTO dm_history(conv_id,generation,hidden) VALUES(?,?,1)" + " ON CONFLICT(conv_id) DO UPDATE SET generation=excluded.generation,hidden=1" + }; + for (size_t i = 0; i < sizeof(statements) / sizeof(statements[0]); i++) { + if (sqlite3_prepare_v2(db, statements[i], -1, &st, NULL) != SQLITE_OK) goto fail; + sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); + if (i == 2) sqlite3_bind_int64(st, 2, (sqlite3_int64)(generation + 1)); + int rc = sqlite3_step(st); sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_DONE) goto fail; + } + if (sqlite3_exec(db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_DM, "DM history: deleted conv=%s generation=%llu -> %llu; counters retained", conv, + (unsigned long long)generation, (unsigned long long)(generation + 1)); + dm_media_forget_committed(inst, conv); + dm_cancel_pending_history(inst, conv); + return 0; +fail: + history_error(inst, "delete rolled back", conv); sqlite3_finalize(st); + if (sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL) != SQLITE_OK) history_error(inst, "rollback delete", conv); + return -1; +} diff --git a/src/dm/dm_history.h b/src/dm/dm_history.h new file mode 100644 index 00000000..e0631d31 --- /dev/null +++ b/src/dm/dm_history.h @@ -0,0 +1,21 @@ +/* Локальная история DM: поколения, скрытые беседы и точные отпечатки удалённых сообщений. + * Все операции выполняются в uasync. Удаление сохраняет счётчики dm_conversations. */ +#ifndef DM_HISTORY_H +#define DM_HISTORY_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +struct UTUN_INSTANCE; +int dm_history_init(struct UTUN_INSTANCE* inst); +uint64_t dm_history_generation(struct UTUN_INSTANCE* inst, const char* conv); +int dm_history_current(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation); +int dm_history_show(struct UTUN_INSTANCE* inst, const char* conv); +/* 1=точный удалённый повтор, 0=не удалено, -1=конфликт/ошибка. receipt может быть NULL. */ +int dm_history_deleted(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int dir, uint8_t* media_receipt); +int dm_history_delete(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/dm/dm_media.c b/src/dm/dm_media.c index b6496e7c..515f73f6 100644 --- a/src/dm/dm_media.c +++ b/src/dm/dm_media.c @@ -12,6 +12,7 @@ #include "../../lib/debug_config.h" #include "../../lib/json_flat.h" #include "dm_media_priv.h" +#include "dm_history.h" #include "dm_crypto.h" #include "dm_mailbox.h" #include "dm_mailbox_media.h" @@ -34,6 +35,7 @@ struct dm_media_state { struct dm_file_job* jobs; void* timer; int closing; + int cleanup_active; }; struct dm_file_job { @@ -48,6 +50,8 @@ struct dm_file_job { int remove_source; char input[1024], cipher[1024], plain[1024], temp[1024], output[1024]; int error; + uint64_t generation; + unsigned held; }; sqlite3_stmt* dm_media_prepare(struct UTUN_INSTANCE* inst, const char* sql) { @@ -152,6 +156,129 @@ static int dm_file_remove(const char* path) { return 0; } +/* Манифест хранит только пути приложения. Поколение делает очередь удаления durable. */ +int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, const char* path) { + if (!path || !path[0] || !dm_history_current(inst, conv, generation)) { + DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: hold rejected conv=%s path=%s", conv ? conv : "", path ? path : ""); return -1; + } + sqlite3_stmt* st = dm_media_prepare(inst, + "INSERT INTO dm_local_files(path,conv_id,generation,busy) VALUES(?,?,?,1) ON CONFLICT(path) DO UPDATE SET busy=busy+1" + " WHERE conv_id=excluded.conv_id AND generation=excluded.generation AND busy>=0"); + if (!st) return -1; + sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, conv, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)generation); + if (dm_media_step(inst, st)) return -1; + if (sqlite3_changes(inst->topo_sqlite_db) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: path belongs to another task/generation path=%s", path); return -1; + } + DEBUG_TRACE(DEBUG_CATEGORY_DM, "DM files: hold conv=%s generation=%llu path=%s", conv, (unsigned long long)generation, path); + return 0; +} + +void dm_media_file_release(struct UTUN_INSTANCE* inst, const char* path) { + sqlite3_stmt* st = dm_media_prepare(inst, "UPDATE dm_local_files SET busy=busy-1 WHERE path=? AND busy>0"); + if (!st) return; + sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); + if (!dm_media_step(inst, st) && sqlite3_changes(inst->topo_sqlite_db) != 1) + DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: release without owner path=%s", path); +} + +struct dm_cleanup_job { struct dm_media_state* media; char path[1024]; int error; }; +static void dm_cleanup_pump(struct dm_media_state* media); + +static void dm_cleanup_work(void* arg) { + struct dm_cleanup_job* job = arg; + job->error = dm_file_remove(job->path); + if (!job->error) job->error = dm_dir_sync(job->path); +} + +static void dm_cleanup_done(void* arg, int error) { + struct dm_cleanup_job* job = arg; + struct dm_media_state* media = job->media; + media->cleanup_active = 0; + int success = !error && !job->error; + sqlite3_stmt* st = dm_media_prepare(media->inst, success ? "DELETE FROM dm_local_files WHERE path=? AND busy=-1" : + "UPDATE dm_local_files SET busy=0,retry_at=?2 WHERE path=?1 AND busy=-1"); + if (st && !success) sqlite3_bind_int64(st, 2, (sqlite3_int64)(ntp_time_get_seconds(media->inst) + 30)); + if (st) { sqlite3_bind_text(st, 1, job->path, -1, SQLITE_STATIC); if (dm_media_step(media->inst, st)) success = 0; } + else success = 0; + if (success) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: removed deleted history path=%s", job->path); + else DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: cleanup retained for retry path=%s callback=%d worker=%d", job->path, error, job->error); + u_free(job); + if (success && !media->closing) dm_cleanup_pump(media); +} + +static void dm_cleanup_pump(struct dm_media_state* media) { + if (media->closing || media->cleanup_active) return; + sqlite3_stmt* st = dm_media_prepare(media->inst, + "SELECT f.path FROM dm_local_files f JOIN dm_history h USING(conv_id)" + " WHERE f.generationinst)); + int rc = sqlite3_step(st); + if (rc != SQLITE_ROW) { + if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cleanup scan failed rc=%d", rc); + sqlite3_finalize(st); return; + } + struct dm_cleanup_job* job = u_calloc(1, sizeof(*job)); + if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cleanup allocation failed"); sqlite3_finalize(st); return; } + const char* path = (const char*)sqlite3_column_text(st, 0); + if (!path || strlen(path) >= sizeof(job->path)) { + DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: invalid cleanup path"); sqlite3_finalize(st); u_free(job); return; + } + job->media = media; snprintf(job->path, sizeof(job->path), "%s", path); sqlite3_finalize(st); + st = dm_media_prepare(media->inst, "UPDATE dm_local_files SET busy=-1 WHERE path=? AND busy<=0"); + if (!st) { u_free(job); return; } + sqlite3_bind_text(st, 1, job->path, -1, SQLITE_STATIC); + if (dm_media_step(media->inst, st)) { u_free(job); return; } + media->cleanup_active = 1; + media_async_submit(media->inst->media_async, media->inst->ua, dm_cleanup_work, job, dm_cleanup_done, job); +} + +int dm_media_forget_prepare(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation) { + struct dm_media_state* media = inst->dm_media; + sqlite3_stmt* rows = dm_media_prepare(inst, "SELECT id FROM dm_files WHERE conv_id=?"); + if (!rows) return -1; + sqlite3_bind_text(rows, 1, conv, -1, SQLITE_STATIC); + int rc, result = 0; + while ((rc = sqlite3_step(rows)) == SQLITE_ROW) { + if (sqlite3_column_bytes(rows, 0) != 16) { result = -1; break; } + const char* suffixes[] = {".file", ".cipher", ".encrypt", ".download", ".decrypt"}; + for (unsigned i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { + char path[1024]; + if (dm_file_path(media, 0, sqlite3_column_blob(rows, 0), suffixes[i], path, sizeof(path))) { result = -1; break; } + sqlite3_stmt* st = dm_media_prepare(inst, + "INSERT INTO dm_local_files(path,conv_id,generation) VALUES(?,?,?) ON CONFLICT(path) DO NOTHING"); + if (!st) { result = -1; break; } + sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, conv, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)generation); + if (dm_media_step(inst, st)) { result = -1; break; } + } + if (result) break; + } + sqlite3_finalize(rows); + if (result || rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: delete manifest failed conv=%s rc=%d", conv, rc); return -1; } + sqlite3_stmt* st = dm_media_prepare(inst, "DELETE FROM dm_files WHERE conv_id=?"); + if (!st) return -1; + sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); + return dm_media_step(inst, st); +} + +void dm_media_forget_committed(struct UTUN_INSTANCE* inst, const char* conv) { + sqlite3_stmt* st = dm_media_prepare(inst, "SELECT media_id FROM dm_deleted_messages WHERE conv_id=? AND media_id IS NOT NULL"); + if (st) { + sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); + int rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + if (sqlite3_column_bytes(st, 0) == 16) file_transfer_cancel_object(inst->file_transfer, sqlite3_column_blob(st, 0)); + else DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: damaged deleted media ID conv=%s", conv); + } + if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cancellation scan failed conv=%s rc=%d", conv, rc); + sqlite3_finalize(st); + } + dm_cleanup_pump(inst->dm_media); +} + /* Подписанная роль и живые мемберы в одной области; тип NAT не даёт полномочий. */ int dm_media_scope(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node, const char* role, uint64_t author, uint64_t recipient) { @@ -276,6 +403,21 @@ int dm_media_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t* r) { if (!st) return -1; sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC); int rc = sqlite3_step(st); + if (rc == SQLITE_DONE) { + sqlite3_finalize(st); + st = dm_media_prepare(inst, + "SELECT body_hash FROM dm_deleted_messages WHERE conv_id=? AND dir=1 AND seq=? AND media_id=?"); + if (!st) return -1; + uint64_t conv, seq; char id[64]; memcpy(&conv, r + 8, 8); memcpy(&seq, r + 16, 8); + snprintf(id, sizeof(id), "%llu", (unsigned long long)conv); + sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)seq); + sqlite3_bind_blob(st, 3, r + 40, 16, SQLITE_STATIC); + rc = sqlite3_step(st); + int exact = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), r + 56, 32); + sqlite3_finalize(st); + if (exact) { DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: late receipt for deleted file conv=%s", id); return 0; } + DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: receipt for unknown/conflicting deleted file conv=%s", id); return -1; + } if (rc != SQLITE_ROW || dm_media_verify_receipt(inst, r, sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0))) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: receipt does not match outgoing artifact rc=%d", rc); sqlite3_finalize(st); @@ -325,10 +467,29 @@ static struct dm_file_job* dm_job_find(struct dm_media_state* media, const uint8 return NULL; } +static int dm_job_paths(struct dm_file_job* job, const char** paths) { + if (job->work != DM_FILE_PREPARE && job->work != DM_FILE_RECEIVE) return 0; + paths[0] = job->cipher; paths[1] = job->temp; + paths[2] = job->work == DM_FILE_PREPARE ? job->plain : job->output; + if (job->work == DM_FILE_PREPARE && job->remove_source) { paths[3] = job->input; return 4; } + return 3; +} + +static int dm_job_hold(struct dm_file_job* job) { + const char* paths[4]; int count = dm_job_paths(job, paths); + for (int i = 0; i < count; i++) { + if (dm_media_file_hold(job->media->inst, job->conv, job->generation, paths[i])) return -1; + job->held++; + } + return 0; +} + static void dm_job_free(struct dm_file_job* job) { struct dm_file_job** p = &job->media->jobs; while (*p && *p != job) p = &(*p)->next; if (*p) *p = job->next; + const char* paths[4]; dm_job_paths(job, paths); + for (unsigned i = 0; i < job->held; i++) dm_media_file_release(job->media->inst, paths[i]); OPENSSL_cleanse(job->key, sizeof(job->key)); u_free(job); } @@ -425,6 +586,10 @@ static void dm_file_done(void* arg, int error) { } int custody = job->work == DM_FILE_CUSTODY; if (error || job->error || media->closing) goto fail; + if (!custody && !dm_history_current(inst, job->conv, job->generation)) { + DEBUG_INFO(DEBUG_CATEGORY_DM, "DM files: discard deleted preparation conv=%s generation=%llu work=%d", job->conv, + (unsigned long long)job->generation, job->work); goto fail; + } if (job->work == DM_FILE_PREPARE) { uint8_t data[DM_MEDIA_DESC_SIZE + ATTACHMENT_WIRE_MAX]; memcpy(data, job->id, 16); memcpy(data + 16, &job->size, 8); memcpy(data + 24, job->hash, 32); @@ -523,8 +688,11 @@ static void dm_file_pull(struct dm_media_state* media, int custody, uint64_t gro dm_file_path(media, custody, job->id, ".decrypt", job->temp, sizeof(job->temp)) || dm_file_path(media, custody, job->id, custody ? ".cipher" : ".file", job->output, sizeof(job->output))) { u_free(job); return; } if (!custody) { + snprintf(job->conv, sizeof(job->conv), "%llu", (unsigned long long)dm_derive_conv_id(media->inst->node_id, author)); + job->generation = dm_history_generation(media->inst, job->conv); + if (dm_job_hold(job)) { dm_job_free(job); return; } sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT x25519_pubkey FROM nodes WHERE node_id=?"); - if (!st) { u_free(job); return; } + if (!st) { dm_job_free(job); return; } sqlite3_bind_int64(st, 1, (sqlite3_int64)author); int rc = sqlite3_step(st); uint8_t pub[32]; @@ -533,7 +701,7 @@ static void dm_file_pull(struct dm_media_state* media, int custody, uint64_t gro sqlite3_finalize(st); if (!found || dm_derive_content_key(media->inst->my_keys.private_key, pub, job->key)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: target key unavailable author=%llu", (unsigned long long)author); - u_free(job); + dm_job_free(job); return; } } @@ -590,6 +758,8 @@ int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, struct dm_file_job* job = u_calloc(1, sizeof(*job)); if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: preparation allocation failed"); return -1; } job->media = media; job->work = DM_FILE_PREPARE; job->author = inst->node_id; + job->generation = dm_history_generation(inst, conv); + if (!dm_history_current(inst, conv, job->generation)) { u_free(job); return -1; } snprintf(job->conv, sizeof(job->conv), "%s", conv); job->info = *info; job->remove_source = remove_source; snprintf(job->input, sizeof(job->input), "%s", path); sqlite3_stmt* st = dm_media_prepare(inst, "SELECT peer_x25519 FROM dm_conversations WHERE conv_id=?"); @@ -606,6 +776,7 @@ int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: preparation keys/paths failed conv=%s", conv); OPENSSL_cleanse(job->key, sizeof(job->key)); u_free(job); return -1; } + if (dm_job_hold(job)) { dm_job_free(job); return -1; } job->next = media->jobs; media->jobs = job; DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: preparing conv=%s kind=%d name=%s", conv, info->kind, info->name); chat_event_attachment(inst, 1, conv, job->id, 0); @@ -630,9 +801,12 @@ static void dm_source_cleaned(void* arg, int error) { void dm_send_file_trampoline(void* arg) { struct dm_file_req* req = arg; if (!req) return; - if (dm_send_file(req->inst, req->conv, req->path, &req->info, req->remove_source)) { + uint64_t generation = req->generation ? req->generation : dm_history_generation(req->inst, req->conv); + int current = dm_history_current(req->inst, req->conv, generation); + if (!current || dm_send_file(req->inst, req->conv, req->path, &req->info, req->remove_source)) { + DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: send rejected conv=%s generation=%llu", req->conv, (unsigned long long)generation); const uint8_t rejected[16] = {0}; - chat_event_attachment(req->inst, 1, req->conv, rejected, 2); + if (current) chat_event_attachment(req->inst, 1, req->conv, rejected, 2); if (req->remove_source) { media_async_submit(req->inst ? req->inst->media_async : NULL, req->inst ? req->inst->ua : NULL, dm_source_cleanup, req, dm_source_cleaned, req); @@ -831,6 +1005,15 @@ static void dm_media_offer(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t uint8_t receipt[DM_RECEIPT_SIZE]; if (dm_accept_message(inst, p, len, receipt)) return; dm_mailbox_receipt(inst, receipt); + uint8_t file_receipt[DM_MEDIA_RECEIPT_SIZE]; + int deleted = dm_history_deleted(inst, p, len, 0, file_receipt); + if (deleted) { + if (deleted > 0 && !memcmp(file_receipt, "DMFILE01", 8)) { + dm_media_send(inst, group, author, DM_MEDIA_RECEIPT, file_receipt, sizeof(file_receipt)); + dm_mailbox_media_receipt(inst, file_receipt); + } + return; + } sqlite3_stmt* st = dm_media_prepare(inst, "UPDATE dm_files SET holder=?,expires=?,group_id=?,retry_at=0 WHERE id=? AND ready<=0"); if (!st) return; sqlite3_bind_int64(st, 1, (sqlite3_int64)holder); sqlite3_bind_int64(st, 2, (sqlite3_int64)expiry); @@ -983,6 +1166,7 @@ static void dm_media_tick(void* arg) { uint64_t now = ntp_time_get_seconds(media->inst); dm_files_tick(media, now); dm_custody_tick(media, now); + dm_cleanup_pump(media); media->timer = uasync_set_timeout(media->inst->ua, 10000, media, dm_media_tick, "dm_media"); if (!media->timer) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: retry timer allocation failed"); } @@ -1043,7 +1227,7 @@ void dm_media_destroy(struct UTUN_INSTANCE* inst) { if (!inst || !inst->dm_media) return; dm_media_quiesce(inst); struct dm_media_state* media = inst->dm_media; - if (media->jobs) { + if (media->jobs || media->cleanup_active) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: destroy before media_async workers drained"); return; } diff --git a/src/dm/dm_media.h b/src/dm/dm_media.h index b902836a..fd811317 100644 --- a/src/dm/dm_media.h +++ b/src/dm/dm_media.h @@ -37,6 +37,7 @@ int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, struct dm_file_req { struct UTUN_INSTANCE* inst; char conv[64]; + uint64_t generation; char path[1024]; struct attachment_info info; int remove_source; /* Только принадлежащий приложению временный файл. */ @@ -50,6 +51,13 @@ uint64_t dm_media_cipher_size(uint64_t plain_size); int dm_media_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt, const uint8_t* msg, size_t len); int dm_media_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt); +/* Удаление: prepare внутри транзакции, committed после commit (callbacks отмены синхронны). */ +int dm_media_forget_prepare(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation); +void dm_media_forget_committed(struct UTUN_INSTANCE* inst, const char* conv); +/* Владение путями worker: hold до запуска, release после завершения. Уборка ждёт busy=0. */ +int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, const char* path); +void dm_media_file_release(struct UTUN_INSTANCE* inst, const char* path); + /* Общие операции control для endpoint/M и mailbox/S. Все пакеты подписаны. */ int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t cmd, const uint8_t* body, size_t len); diff --git a/src/media_async/attachment_send.c b/src/media_async/attachment_send.c index 19cfab73..f4627818 100644 --- a/src/media_async/attachment_send.c +++ b/src/media_async/attachment_send.c @@ -11,6 +11,7 @@ #include "../chat/chat_core.h" #include "../chat/chat_event.h" #include "../dm/dm_media.h" +#include "../dm/dm_history.h" #include "../video/video.h" #include "media_async.h" #include "voice_file.h" @@ -57,18 +58,29 @@ done: } /* Один выбор транспорта после общей подготовки; групповой индекс и PM crypto не смешиваются. */ +static void attachment_free(struct attachment_send_req* req) { + if (req->dm_paths_held & 1) dm_media_file_release(req->inst, req->output); + if (req->dm_paths_held & 2) dm_media_file_release(req->inst, req->source); + u_free(req); +} + static void attachment_publish(struct attachment_send_req* req, int error) { attachment_release_pcm(req); /* Submit мог завершиться ошибкой до запуска work. */ + int abandoned = req->is_dm && req->dm_generation && !dm_history_current(req->inst, req->target, req->dm_generation); + if (abandoned) { + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment: discarded deleted PM preparation target=%s generation=%llu", + req->target, (unsigned long long)req->dm_generation); error = MEDIA_ASYNC_CANCELLED; + } if (req->temporary_source && remove(req->source) && errno != ENOENT) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: abandoned source cleanup failed path=%s", req->source); error = -1; } if (error || req->error || attachment_validate(&req->info)) { - chat_event_attachment(req->inst, req->is_dm, req->target, req->id, 2); + if (!abandoned || req->dm_paths_held) chat_event_attachment(req->inst, req->is_dm, req->target, req->id, 2); DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: preparation failed target=%s error=%d worker=%d", req->target, error, req->error); chat_event_submitted(req->inst, req->request_id, 0); if (remove(req->output) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment: output cleanup failed path=%s", req->output); - u_free(req); return; + attachment_free(req); return; } chat_event_attachment(req->inst, req->is_dm, req->target, req->id, 1); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment: prepared target=%s pm=%d kind=%d duration=%u dimensions=%ux%u", @@ -77,6 +89,7 @@ static void attachment_publish(struct attachment_send_req* req, int error) { struct dm_file_req* file = u_calloc(1, sizeof(*file)); if (file) { file->inst = req->inst; file->info = req->info; file->remove_source = 1; + file->generation = req->dm_generation; snprintf(file->conv, sizeof(file->conv), "%s", req->target); snprintf(file->path, sizeof(file->path), "%s", req->output); dm_send_file_trampoline(file); @@ -108,7 +121,7 @@ static void attachment_publish(struct attachment_send_req* req, int error) { if (remove(req->output)) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment: rejected output cleanup failed"); } } - u_free(req); + attachment_free(req); } static void attachment_video_done(void* arg, int error, const struct video_info* info) { @@ -123,6 +136,7 @@ static void attachment_video_done(void* arg, int error, const struct video_info* static void attachment_prepare_done(void* arg, int error) { struct attachment_send_req* req = arg; + if (req->is_dm && !dm_history_current(req->inst, req->target, req->dm_generation)) error = MEDIA_ASYNC_CANCELLED; if (!error && !req->error && req->transcode) { if (!video_transcode_start(req->inst->media_async, req->inst->ua, req->source, req->output, attachment_video_done, req)) return; error = -1; @@ -139,6 +153,10 @@ void attachment_send_trampoline(void* arg) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: invalid target/stopped service"); attachment_publish(req, -1); return; } + if (req->is_dm) { + if (!req->dm_generation) req->dm_generation = dm_history_generation(req->inst, req->target); + if (!dm_history_current(req->inst, req->target, req->dm_generation)) { attachment_publish(req, -1); return; } + } uint8_t uuid[16]; char hex[33]; if (RAND_bytes(uuid, sizeof(uuid)) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: UUID generation failed"); attachment_publish(req, -1); return; @@ -153,6 +171,14 @@ void attachment_send_trampoline(void* arg) { } if (!req->info.name[0]) snprintf(req->info.name, sizeof(req->info.name), "%s.%s", hex, req->info.kind == ATTACHMENT_VOICE ? "opus" : "mp4"); + if (req->is_dm) { + if (dm_media_file_hold(req->inst, req->target, req->dm_generation, req->output)) { attachment_publish(req, -1); return; } + req->dm_paths_held |= 1; + if (req->temporary_source) { + if (dm_media_file_hold(req->inst, req->target, req->dm_generation, req->source)) { attachment_publish(req, -1); return; } + req->dm_paths_held |= 2; + } + } chat_event_attachment(req->inst, req->is_dm, req->target, req->id, 0); media_async_submit(req->inst->media_async, req->inst->ua, attachment_prepare_work, req, attachment_prepare_done, req); } diff --git a/src/media_async/attachment_send.h b/src/media_async/attachment_send.h index ebf2413f..a3e03a4c 100644 --- a/src/media_async/attachment_send.h +++ b/src/media_async/attachment_send.h @@ -17,6 +17,8 @@ struct attachment_send_req { struct UTUN_INSTANCE* inst; char target[64]; /* Десятичный channel_id либо conv_id без префикса dm:, зафиксированный до подготовки. */ int is_dm; + uint64_t dm_generation; /* Захватывается до подготовки и сохраняется во всех её стадиях. */ + unsigned dm_paths_held; uint8_t reply_to[32]; /* Оригинал канального ответа, зафиксированный до подготовки. */ uint64_t request_id; /* CHAT_EVT_MSG_SUBMITTED после публикации либо ошибки. */ struct attachment_info info; diff --git a/tests/Makefile.am b/tests/Makefile.am index cbf3872e..db17c63f 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -143,6 +143,7 @@ check_PROGRAMS += \ test_group_connect \ test_etcp_router \ test_dm_worker \ + test_dm_history \ test_chat_join \ test_radio \ test_bgp_route_exchange \ @@ -686,6 +687,10 @@ test_dm_worker_SOURCES = test_dm_worker.c test_dm_worker_CFLAGS = $(test_dm_e2e_CFLAGS) test_dm_worker_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_dm_history_SOURCES = test_dm_history.c +test_dm_history_CFLAGS = $(test_dm_e2e_CFLAGS) +test_dm_history_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + test_call_SOURCES = test_call.c test_route_tx_stats_SOURCES = test_route_tx_stats.c test_route_tx_stats_LDADD = $(LIBUTUN) $(COMMON_LIBS) diff --git a/tests/test_android_replies.c b/tests/test_android_replies.c index 7bbd0e11..f1b75a5c 100644 --- a/tests/test_android_replies.c +++ b/tests/test_android_replies.c @@ -186,11 +186,11 @@ int main(void) { REQUIRE(!utun_bridge_photo_send(channel, file, directory, 24, 24, original, 5) && wait_result(5) == 1); REQUIRE(check_reply(channel, hex, "image/jpeg")); /* Мост принимает уже подготовленный MP4: содержимое не декодируется повторно. */ - REQUIRE(!utun_bridge_video_send(channel, file, directory, 1000, 24, 24, original, 6) && wait_result(6) == 1); + REQUIRE(!utun_bridge_video_send(channel, file, directory, 1000, 24, 24, original, 6, 0) && wait_result(6) == 1); REQUIRE(check_reply(channel, hex, "video/mp4")); REQUIRE(access(file, F_OK) != 0); - REQUIRE(!utun_bridge_voice_start(channel, original, 7)); + REQUIRE(!utun_bridge_voice_start(channel, original, 7, 0)); int16_t pcm[960] = {0}; for (int i = 0; i < 30; i++) { REQUIRE(!utun_bridge_voice_feed(pcm, 960)); uasync_poll(async, 20); } /* Poll может вернуться немедленно по событию; длительность измеряется CLOCK_MONOTONIC. */ @@ -198,7 +198,7 @@ int main(void) { int duration = 0; REQUIRE(!utun_bridge_voice_stop(&duration) && duration >= 500 && wait_result(7) == 1); REQUIRE(check_reply(channel, hex, "audio/opus")); - REQUIRE(!utun_bridge_voice_start(channel, original, 8)); utun_bridge_voice_cancel(); + REQUIRE(!utun_bridge_voice_start(channel, original, 8, 0)); utun_bridge_voice_cancel(); uasync_poll(async, 0); REQUIRE(results[8] == 0); REQUIRE(utun_bridge_voice_stop(&duration) < 0); REQUIRE(!utun_bridge_attachment_send(channel, "/nonexistent/android-reply", directory, original, 9) && wait_result(9) == -1); diff --git a/tests/test_dm_history.c b/tests/test_dm_history.c new file mode 100644 index 00000000..6d7b7f8f --- /dev/null +++ b/tests/test_dm_history.c @@ -0,0 +1,213 @@ +/* Настоящие DM/SQLite/workers: удаление, повторы, nonce, rollback и поколения. */ +#include +#include +#include +#include +#include "../src/utun_instance.h" +#include "../src/dm/dm_core.h" +#include "../src/dm/dm_crypto.h" +#include "../src/dm/dm_history.h" +#include "../src/chat/chat_core.h" +#include "../src/chat/chat_event.h" +#include "../src/media_async/attachment_send.h" +#include "../lib/mem.h" +#include "../lib/debug_config.h" +#include "test_utils.h" + +#define REQUIRE(value) do { if (!(value)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM history test failed line=%d", __LINE__); return 1; } } while (0) +static int messages, delete_results, delete_success, preparation_failures; +static struct SC_MYKEYS identities[2]; +static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { + (void)inst; + if (type == CHAT_EVT_DM_MSG_RECEIVED) messages++; + if (type == CHAT_EVT_ATTACHMENT_PREPARATION && len >= 18 && data[len - 1] == 2) preparation_failures++; + if (type == CHAT_EVT_DM_DELETE_RESULT && len == 25) { delete_results++; delete_success = data[24]; } +} + +static int scalar(struct UTUN_INSTANCE* inst, const char* sql) { + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL)) return -1; + int n = sqlite3_step(st) == SQLITE_ROW ? sqlite3_column_int(st, 0) : -1; + sqlite3_finalize(st); return n; +} + +static int body(struct UTUN_INSTANCE* inst, int seq, uint8_t* out, size_t* len) { + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT body FROM dm_outbox WHERE seq=?", -1, &st, NULL)) return -1; + sqlite3_bind_int(st, 1, seq); int rc = sqlite3_step(st); + int n = rc == SQLITE_ROW ? sqlite3_column_bytes(st, 0) : -1; + if (n > 0 && (size_t)n <= *len) { memcpy(out, sqlite3_column_blob(st, 0), (size_t)n); *len = (size_t)n; } + else rc = SQLITE_ERROR; + sqlite3_finalize(st); return rc == SQLITE_ROW ? 0 : -1; +} + +static int peer(struct UTUN_INSTANCE* a, struct UTUN_INSTANCE* b, char conv[64]) { + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(a->topo_sqlite_db, "INSERT OR REPLACE INTO nodes(node_id,x25519_pubkey,ed25519_pubkey) VALUES(?,?,?)", + -1, &st, NULL)) return -1; + sqlite3_bind_int64(st, 1, (sqlite3_int64)b->node_id); sqlite3_bind_blob(st, 2, b->my_keys.public_key, 32, SQLITE_STATIC); + sqlite3_bind_blob(st, 3, b->my_ed25519_pubkey, 32, SQLITE_STATIC); + int rc = sqlite3_step(st); sqlite3_finalize(st); + if (rc != SQLITE_DONE || dm_start(a, b->node_id, b->my_keys.public_key, b->my_ed25519_pubkey, "peer", NULL)) return -1; + snprintf(conv, 64, "%llu", (unsigned long long)dm_derive_conv_id(a->node_id, b->node_id)); return 0; +} + +static struct UTUN_INSTANCE* start(struct UASYNC* ua, const char* directory, int index) { + char config[1536], pub[65], priv[65]; + for (unsigned i = 0; i < 32; i++) { + snprintf(pub + 2 * i, 3, "%02x", identities[index].public_key[i]); + snprintf(priv + 2 * i, 3, "%02x", identities[index].private_key[i]); + } + snprintf(config, sizeof(config), "[global]\nmy_public_key=%s\nmy_private_key=%s\ndb_path=%s/%d\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", pub, priv, directory, index); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config); + if (!inst || utun_core_start(inst) || chat_service_start(inst)) return NULL; + chat_event_set_handler(inst, event); return inst; +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + if (getenv("UTUN_TEST_DEBUG")) debug_set_category_level(DEBUG_CATEGORY_DM, DEBUG_LEVEL_DEBUG); + char directory[512] = "/tmp/utun_dm_history_XXXXXX", conv[64], path[768]; + REQUIRE(!test_mkdtemp(directory)); + REQUIRE(!sc_generate_keypair(&identities[0]) && !sc_generate_keypair(&identities[1])); + struct UASYNC* ua = uasync_create(); REQUIRE(ua); + struct UTUN_INSTANCE* a = start(ua, directory, 0); struct UTUN_INSTANCE* b = start(ua, directory, 1); + REQUIRE(a && b && !peer(a, b, conv) && !peer(b, a, conv)); + REQUIRE(dm_history_generation(b, conv) == 1); + REQUIRE(!dm_send(a, conv, "text", (const uint8_t*)"one", 3)); + REQUIRE(!dm_send(a, conv, "text", (const uint8_t*)"two", 3)); + uint8_t first[2048], second[2048], file_body[2048], receipt[DM_RECEIPT_SIZE]; + size_t first_len = sizeof(first), second_len = sizeof(second), file_len = sizeof(file_body); + REQUIRE(!body(a, 1, first, &first_len) && !body(a, 2, second, &second_len)); + REQUIRE(!dm_accept_message(b, second, second_len, receipt)); /* seq=1 ещё не получен. */ + REQUIRE(!dm_send(b, conv, "text", (const uint8_t*)"reply", 5)); + int before = messages; + REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "CREATE TEMP TRIGGER fail_delete BEFORE DELETE ON dm_messages" + " BEGIN SELECT RAISE(ABORT,'injected history delete failure'); END", NULL, NULL, NULL)); + REQUIRE(dm_history_delete(b, conv, 1) < 0 && dm_history_generation(b, conv) == 1); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_deleted_messages") == 0 && scalar(b, "SELECT count(*) FROM dm_messages") == 2); + REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "DROP TRIGGER fail_delete", NULL, NULL, NULL)); + struct dm_delete_req* deletion = u_calloc(1, sizeof(*deletion)); REQUIRE(deletion); + deletion->inst = b; deletion->generation = 1; deletion->request_id = 123; snprintf(deletion->conv_id, 64, "%s", conv); + dm_delete_trampoline(deletion); + REQUIRE(delete_results == 1 && delete_success && dm_history_generation(b, conv) == 2); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT count(*) FROM dm_outbox") == 0); + REQUIRE(scalar(b, "SELECT last_out_seq FROM dm_conversations") == 1); + char json[8192]; size_t length; + REQUIRE(!dm_list_conversations_json(b, json, sizeof(json), &length) && !strcmp(json, "[]")); + REQUIRE(!dm_accept_message(b, second, second_len, receipt) && messages == before); + REQUIRE(!dm_verify_receipt(a, receipt, second, second_len)); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT hidden FROM dm_history") == 1); + uint8_t conflict[2048]; memcpy(conflict, second, second_len); conflict[16] ^= 1; + REQUIRE(!sc_ed25519_sign(a->my_ed25519_privkey, conflict, second_len - 64, conflict + second_len - 64)); + REQUIRE(dm_accept_message(b, conflict, second_len, receipt) < 0); + REQUIRE(!dm_accept_message(b, first, first_len, receipt) && messages == before + 1); + REQUIRE(scalar(b, "SELECT hidden FROM dm_history") == 0); /* Впервые доставленное seq=1 остаётся новым. */ + REQUIRE(!dm_send(b, conv, "text", (const uint8_t*)"after", 5)); + REQUIRE(scalar(b, "SELECT last_out_seq FROM dm_conversations") == 2); + puts("PASS: atomic delete/rollback, exact replay receipts/conflicts, out-of-order delivery and preserved outgoing seq"); + + snprintf(path, sizeof(path), "%s/source.bin", directory); + FILE* source = fopen(path, "wb"); REQUIRE(source); + REQUIRE(fwrite("payload", 1, 7, source) == 7 && !fclose(source)); + struct attachment_info info = { .kind = ATTACHMENT_FILE, .name = "source.bin" }; + REQUIRE(!dm_send_file(a, conv, path, &info, 0)); + for (unsigned i = 0; i < 500 && scalar(a, "SELECT count(*) FROM dm_files") == 0; i++) uasync_poll(ua, 20); + REQUIRE(!body(a, 3, file_body, &file_len) && !dm_accept_message(b, file_body, file_len, receipt)); + REQUIRE(!dm_history_delete(b, conv, 2)); + REQUIRE(!dm_accept_message(b, file_body, file_len, receipt)); + uint8_t saved[DM_MEDIA_RECEIPT_SIZE]; memset(saved, 0x55, sizeof(saved)); + REQUIRE(dm_history_deleted(b, file_body, file_len, 0, saved) == 1 && saved[0] == 0); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_files") == 0); + + /* Подписанная квитанция загрузки хранится отдельно от квитанции сообщения. */ + REQUIRE(!dm_send_file(a, conv, path, &info, 0)); + for (unsigned i = 0; i < 500 && scalar(a, "SELECT count(*) FROM dm_files") < 2; i++) uasync_poll(ua, 20); + file_len = sizeof(file_body); REQUIRE(!body(a, 4, file_body, &file_len) && !dm_accept_message(b, file_body, file_len, receipt)); + uint8_t file_receipt[DM_MEDIA_RECEIPT_SIZE]; + memcpy(file_receipt, "DMFILE01", 8); memcpy(file_receipt + 8, file_body, 16); memcpy(file_receipt + 24, file_body + 24, 8); + memcpy(file_receipt + 32, &b->node_id, 8); memcpy(file_receipt + 40, dm_message_media(file_body, file_len), 16); + SHA256(file_body, file_len, file_receipt + 56); + REQUIRE(!sc_ed25519_sign(b->my_ed25519_privkey, file_receipt, 88, file_receipt + 88)); + REQUIRE(!dm_media_verify_receipt(a, file_receipt, file_body, file_len)); + sqlite3_stmt* complete = NULL; + REQUIRE(!sqlite3_prepare_v2(b->topo_sqlite_db, "UPDATE dm_files SET receipt=?,ready=1", -1, &complete, NULL)); + sqlite3_bind_blob(complete, 1, file_receipt, sizeof(file_receipt), SQLITE_STATIC); + REQUIRE(sqlite3_step(complete) == SQLITE_DONE); sqlite3_finalize(complete); + REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "CREATE TEMP TRIGGER fail_late_delete BEFORE INSERT ON dm_history" + " BEGIN SELECT RAISE(ABORT,'injected failure after media manifest'); END", NULL, NULL, NULL)); + int remembered = scalar(b, "SELECT count(*) FROM dm_deleted_messages"); + REQUIRE(dm_history_delete(b, conv, 3) < 0 && scalar(b, "SELECT count(*) FROM dm_files") == 1); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_deleted_messages") == remembered && dm_history_generation(b, conv) == 3); + REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "DROP TRIGGER fail_late_delete", NULL, NULL, NULL)); + REQUIRE(!dm_history_delete(b, conv, 3)); + REQUIRE(dm_history_deleted(b, file_body, file_len, 0, saved) == 1 && !memcmp(saved, file_receipt, sizeof(saved))); + REQUIRE(!dm_history_delete(a, conv, 1)); + REQUIRE(!dm_media_accept_receipt(a, file_receipt)); /* Поздний ACK удалённого исходящего файла. */ + file_receipt[56] ^= 1; + REQUIRE(!sc_ed25519_sign(b->my_ed25519_privkey, file_receipt, 88, file_receipt + 88)); + REQUIRE(dm_media_accept_receipt(a, file_receipt) < 0); + puts("PASS: media metadata rollback, retained real file receipts and exact validation of late outgoing receipts"); + REQUIRE(!peer(b, a, conv)); + uint64_t generation = dm_history_generation(b, conv); + REQUIRE(generation == 4 && !dm_send_file(b, conv, path, &info, 0)); + REQUIRE(!dm_history_delete(b, conv, generation) && !peer(b, a, conv)); + REQUIRE(dm_history_delete(b, conv, generation) < 0); /* Старый запрос не удаляет новую историю. */ + struct dm_send_req* stale = u_calloc(1, sizeof(*stale) + 4); REQUIRE(stale); + stale->inst = b; stale->generation = generation; stale->data_len = 4; stale->data = (uint8_t*)(stale + 1); + snprintf(stale->conv_id, sizeof(stale->conv_id), "%s", conv); memcpy(stale->data, "late", 4); dm_send_trampoline(stale); + for (unsigned i = 0; i < 500 && scalar(b, "SELECT count(*) FROM dm_local_files WHERE busy>0") > 0; i++) uasync_poll(ua, 20); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_files") == 0 && scalar(b, "SELECT count(*) FROM dm_messages") == 0); + REQUIRE(!access(path, F_OK)); /* Исходный пользовательский файл не принадлежит задаче. */ + struct attachment_send_req* req = u_calloc(1, sizeof(*req)); REQUIRE(req); + req->inst = b; req->is_dm = 1; req->dm_generation = dm_history_generation(b, conv); req->info.kind = ATTACHMENT_VOICE; + req->preset = 1; req->pcm = u_calloc(48000, sizeof(int16_t)); req->pcm_count = 48000; REQUIRE(req->pcm); + snprintf(req->target, sizeof(req->target), "%s", conv); attachment_send_trampoline(req); + generation = dm_history_generation(b, conv); + REQUIRE(!dm_history_delete(b, conv, generation) && !peer(b, a, conv)); + for (unsigned i = 0; i < 500 && scalar(b, "SELECT count(*) FROM dm_local_files WHERE busy>0") > 0; i++) uasync_poll(ua, 20); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT count(*) FROM dm_files") == 0); + int failures_before = preparation_failures; + req = u_calloc(1, sizeof(*req)); REQUIRE(req); + req->inst = b; req->is_dm = 1; req->dm_generation = generation; req->info.kind = ATTACHMENT_VOICE; + snprintf(req->target, sizeof(req->target), "%s", conv); attachment_send_trampoline(req); + struct dm_file_req* old_file = u_calloc(1, sizeof(*old_file)); REQUIRE(old_file); + old_file->inst = b; old_file->generation = generation; old_file->info = info; + snprintf(old_file->conv, sizeof(old_file->conv), "%s", conv); snprintf(old_file->path, sizeof(old_file->path), "%s", path); + dm_send_file_trampoline(old_file); + REQUIRE(preparation_failures == failures_before); + puts("PASS: deleted media replays do not register files or fake download receipts; old file/voice workers cannot send into reopened PM"); + + /* Перезапуск с занятой durable записью: владельца уже нет, уборка должна продолжиться. */ + snprintf(path, sizeof(path), "%s/restart-owned.bin", directory); + source = fopen(path, "wb"); REQUIRE(source); REQUIRE(fputc(42, source) != EOF && !fclose(source)); + uint64_t current = dm_history_generation(b, conv); + REQUIRE(!dm_media_file_hold(b, conv, current, path)); + REQUIRE(!dm_history_delete(b, conv, current)); + utun_instance_destroy(b); + b = start(ua, directory, 1); REQUIRE(b); + REQUIRE(dm_history_generation(b, conv) == current + 1 && !dm_history_current(b, conv, current)); + REQUIRE(!dm_accept_message(b, first, first_len, receipt)); + REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT last_out_seq FROM dm_conversations") == 2); + dm_media_forget_committed(b, conv); + for (unsigned i = 0; i < 500 && !access(path, F_OK); i++) uasync_poll(ua, 20); + REQUIRE(access(path, F_OK) != 0); + REQUIRE(!dm_list_conversations_json(b, json, sizeof(json), &length) && !strcmp(json, "[]")); + puts("PASS: restart retains replay protection, counters and hidden state; durable file cleanup resumes after owner loss"); + REQUIRE(!peer(b, a, conv) && !dm_send(b, conv, "", (const uint8_t*)"x", 1)); + first_len = sizeof(first); REQUIRE(!body(b, 3, first, &first_len)); + REQUIRE(!dm_history_delete(b, conv, dm_history_generation(b, conv))); + REQUIRE(dm_history_deleted(b, first, first_len, 1, NULL) == 1); + REQUIRE(!peer(a, b, conv) && !dm_send(a, conv, "file_ab", (const uint8_t*)"x", 1)); + first_len = sizeof(first); REQUIRE(!body(a, 5, first, &first_len)); + memcpy(first + 33, "file\0ab", 7); + REQUIRE(!sc_ed25519_sign(a->my_ed25519_privkey, first, first_len - 64, first + first_len - 64)); + REQUIRE(!dm_accept_message(b, first, first_len, receipt)); + REQUIRE(!dm_history_delete(b, conv, dm_history_generation(b, conv))); + REQUIRE(dm_history_deleted(b, first, first_len, 0, NULL) == 1); + puts("PASS: replay hashes preserve exact protocol bytes for empty and embedded-NUL content types"); + utun_instance_destroy(b); utun_instance_destroy(a); uasync_destroy(ua, 0); + puts("test_dm_history: ALL PASS"); + return 0; +} diff --git a/tests/test_dm_invite.c b/tests/test_dm_invite.c index c3e5ea92..f8d82c51 100644 --- a/tests/test_dm_invite.c +++ b/tests/test_dm_invite.c @@ -15,6 +15,7 @@ #include "../src/chat/invite_link.h" #include "../src/dm/dm_core.h" #include "../src/dm/dm_crypto.h" +#include "../src/dm/dm_history.h" #include "../src/routing_layer/topo_node_sqlite.h" #include "../src/routing_layer/topo_group.h" #include "../src/transport_layer/secure_channel.h" @@ -51,7 +52,7 @@ static void drop_history(struct ETCP_CONN* conn, struct ll_entry* entry) { } static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { - if (type == CHAT_EVT_DM_INVITE_RESULT && len == 28) { + if (type == CHAT_EVT_DM_INVITE_RESULT && len == 36) { uint64_t id, peer, conv; int32_t result; memcpy(&id, data, 8); memcpy(&result, data + 8, 4); memcpy(&peer, data + 12, 8); memcpy(&conv, data + 20, 8); @@ -191,6 +192,14 @@ int main(void) { CHECK(wait_result(ua, 2, DM_INVITE_BUSY), "busy request retains first operation"); request(1, target, source_id, 1); CHECK(wait_result(ua, 1, DM_INVITE_CANCELLED) && !message_count(), "cancel before ACK sends nothing"); + request(7, target, source_id, 0); + CHECK(!calls[7], "invite waits for ACK before delete"); + char deleted_conv[64]; + snprintf(deleted_conv, sizeof(deleted_conv), "%llu", (unsigned long long)dm_derive_conv_id(nodes[0]->node_id, nodes[1]->node_id)); + CHECK(!dm_history_delete(nodes[0], deleted_conv, dm_history_generation(nodes[0], deleted_conv)), "delete with pending invitation"); + CHECK(wait_result(ua, 7, DM_INVITE_CANCELLED) && !message_count(), "delete cancels invitation without publishing PM"); + CHECK(!dm_start(nodes[0], nodes[1]->node_id, nodes[1]->my_keys.public_key, nodes[1]->my_ed25519_pubkey, "B", source_id), + "explicit start reopens PM after deletion"); request(3, 0, source_id, 0); CHECK(wait_result(ua, 3, DM_INVITE_BUILD_ERROR) && !message_count(), "invalid group sends nothing"); diff --git a/tests/test_radio.c b/tests/test_radio.c index 685db398..d8c43dbc 100644 --- a/tests/test_radio.c +++ b/tests/test_radio.c @@ -585,6 +585,23 @@ int main(int argc, char** argv) { printf("=== test_radio ===\n"); fflush(stdout); + TEST("member snapshot: short names have zero padding"); { + const char* names[] = {"", "0x06B9", "Без имени", "123456789012345678901234567890123456789012345678901234567890123"}; + int valid = 1; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + struct chat_member_display member = {0}; + member.node_id = UINT64_C(0x212732e6bc9c06b9); + memset(member.name, 0xFF, sizeof(member.name)); + snprintf(member.name, sizeof(member.name), "%s", names[i]); + uint8_t wire[CHAT_MEMBER_DISPLAY_SIZE]; + chat_core_serialize_member(&member, wire); + char expected[64] = {0}; + snprintf(expected, sizeof(expected), "%s", names[i]); + if (memcmp(wire + 15, expected, sizeof(expected))) { valid = 0; break; } + } + if (valid) OK(); else FAIL("name field contains bytes after NUL"); + } + TEST("radio: members + mute relay + speaker FIN/timeout/off + withdraw"); { struct UASYNC* ua = uasync_create(); if (!ua) { FAIL("uasync_create failed"); return 1; } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 4f379d38..a139f487 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -218,9 +218,10 @@ class MainActivity : AppCompatActivity() { var showJoinDialog by rememberSaveable { mutableStateOf(false) } var inviteChannelId by rememberSaveable { mutableStateOf(null) } var videoMessage by remember { mutableStateOf(null) } + var videoTargetGeneration by rememberSaveable { mutableStateOf(0L) } var videoTargetId by rememberSaveable { mutableStateOf(null) } val videoTarget = videoTargetId?.let { - if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:")) else ConversationTarget.Channel(it) + if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:"), videoTargetGeneration) else ConversationTarget.Channel(it) } var photoMessage by remember { mutableStateOf(null) } var photoTargetId by rememberSaveable { mutableStateOf(null) } @@ -358,17 +359,23 @@ class MainActivity : AppCompatActivity() { Screen.ChannelList -> { val channels by vm.channels.collectAsState() val dmConversations by vm.dmConversations.collectAsState() + val dmDeleting by vm.dmDeleting.collectAsState() + val dmDeleteFailed by vm.dmDeleteFailed.collectAsState() val stuck by vm.isStuck.collectAsState() val cpuLoad by vm.cpuLoad.collectAsState() ChannelListScreen( channels = channels, dmConversations = dmConversations, + onDeleteDm = vm::deleteDmConversation, + deletingDm = dmDeleting, + dmDeleteFailed = dmDeleteFailed, + onDismissDmDeleteError = vm::dismissDmDeleteError, connected = connected, isStuck = stuck, cpuLoad = cpuLoad, onCpuLoadVisible = vm::setCpuLoadVisible, onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, - onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm }, + onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName, dm.generation); screen = Screen.Dm }, onCreateChannel = { name -> vm.createChannel(name, "channel_1") }, onJoinChannel = { showJoinDialog = true }, onInviteClick = showInvite, @@ -387,7 +394,6 @@ class MainActivity : AppCompatActivity() { messages = messages, connected = connected, viewModel = vm, - onSendMessage = { vm.sendMessage(it) }, onMembersClick = { screen = Screen.MemberList }, onFiles = { owner -> filesVm.open(channel.id, owner); filesReturnScreen = Screen.Chat; screen = Screen.Files @@ -401,7 +407,8 @@ class MainActivity : AppCompatActivity() { onRecordVideo = { if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen val target = ConversationTarget.Channel(channel.id) - videoTargetId = target.nativeId; vm.captureCameraReply(target); screen = Screen.VideoRecord + videoTargetGeneration = 0; videoTargetId = target.nativeId + vm.captureCameraReply(target); screen = Screen.VideoRecord }, onCapturePhoto = { if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen @@ -450,10 +457,10 @@ class MainActivity : AppCompatActivity() { conv = dm, messages = dmMessages, viewModel = vm, - onSendMessage = { vm.sendDmMessage(it) }, onRecordVideo = { - val target = ConversationTarget.Pm(dm.convId) - videoTargetId = target.nativeId; vm.captureCameraReply(target); screen = Screen.VideoRecord + val target = ConversationTarget.Pm(dm.convId, dm.generation) + videoTargetGeneration = target.generation; videoTargetId = target.nativeId + vm.captureCameraReply(target); screen = Screen.VideoRecord }, onPlayVideo = { videoMessage = it }, onBack = { vm.closeDm(); screen = Screen.ChannelList } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index d0060c40..29e46d8a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -34,11 +34,11 @@ class AudioRecorderManager { PackageManager.PERMISSION_GRANTED } - fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean { + fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean { if (isRecording) return false if (!hasPermission()) return false - val ok = NativeLib.voiceStart(channelId, replyTo, requestId) + val ok = NativeLib.voiceStart(channelId, replyTo, requestId, generation) if (!ok) { LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed") return false diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index d0c9116b..ff24f431 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -57,7 +57,7 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va val peersOnline: Int = 0) data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "", - val lastTs: Long = 0, val online: Boolean = false) { + val lastTs: Long = 0, val online: Boolean = false, val generation: Long = 1) { val displayName: String get() = peerName.ifBlank { "0x%016x".format(peerNodeId) } } data class Message(val id: Long, val author: String, val text: String, val ts: Long, @@ -198,10 +198,10 @@ fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? { val natType = buf.get().toInt() and 0xFF val storage = buf.get().toInt() != 0 val nameBytes = ByteArray(64); buf.get(nameBytes) - val name = String(nameBytes).trimEnd('\u0000') + val name = String(nameBytes, Charsets.UTF_8).substringBefore('\u0000') val avatarServer = buf.getLong() val avatarBytes = ByteArray(70); buf.get(avatarBytes) - val avatarFile = String(avatarBytes).trimEnd('\u0000') + val avatarFile = String(avatarBytes, Charsets.UTF_8).substringBefore('\u0000') val storageRole = buf.get().toInt() != 0 return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name, avatarServer, avatarFile, storageRole) } @@ -595,7 +595,7 @@ class ChatRepository { fun dmStart(nodeId: Long, sourceChId: String, peerName: String) = NativeLib.dmStart(nodeId, sourceChId, peerName) - fun dmSend(convId: String, text: String) = NativeLib.dmSend(convId, text) + fun dmSend(convId: String, text: String, generation: Long) = NativeLib.dmSend(convId, text, generation) fun parseDmConversations(json: String): List { val list = mutableListOf() @@ -607,7 +607,7 @@ class ChatRepository { convId = obj.getString("conv_id"), peerNodeId = obj.optLong("peer_node_id", 0), peerName = obj.optString("peer_name", ""), - lastTs = obj.optLong("last_ts", 0) + lastTs = obj.optLong("last_ts", 0), generation = obj.getLong("generation") )) } } catch (e: Exception) { Log.w("utun-gui", "getDmConversations error", e) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt index 3fa77105..877e3892 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt @@ -4,6 +4,6 @@ package com.utun.chat.data sealed class ConversationTarget { abstract val id: String data class Channel(override val id: String) : ConversationTarget() - data class Pm(override val id: String) : ConversationTarget() + data class Pm(override val id: String, val generation: Long) : ConversationTarget() val nativeId: String get() = if (this is Pm) "dm:$id" else id } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 240173db..06871570 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -172,8 +172,8 @@ object NativeLib { fun requestLocalSockets() = nativeRequestLocalSockets() /* ── Voice recording ── */ - fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean = - !StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId) + fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean = + !StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId, generation) fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples) fun voiceStop(): Int = nativeVoiceStop() fun voiceCancel() { nativeVoiceCancel() } @@ -195,8 +195,8 @@ object NativeLib { /* ── Video message ── */ fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int, - replyTo: String = "", requestId: Long = 0): Boolean = - nativeVideoSend(channelId, filePath, durationMs, width, height, messageIdBytes(replyTo), requestId) + replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean = + nativeVideoSend(channelId, filePath, durationMs, width, height, messageIdBytes(replyTo), requestId, generation) /* Live media используют отдельную ограниченную очередь, минуя чатовый event bus. */ fun streamOpen(id: Long, group: Long, source: Long = 0, width: Int = 0, height: Int = 0, @@ -302,14 +302,15 @@ object NativeLib { /* ── DM (личные сообщения) ── */ fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) } - fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) } + fun dmSend(convId: String, text: String, generation: Long) { nativeDmSend(convId, text, generation) } fun dmInvite(requestId: Long, peer: Long, source: String, channelId: Long, name: String, cancel: Boolean = false): Boolean = nativeDmInvite(requestId, peer, source, channelId, name, cancel) fun isChannelMember(channelId: String, nodeId: Long): Boolean = nativeIsChannelMember(channelId, nodeId) + fun dmDelete(convId: String, generation: Long, request: Long): Boolean = nativeDmDelete(convId, generation, request) fun dmRequestConversations() { nativeDmRequestConversations() } fun dmRequestCallRoute(convId: String) { nativeDmRequestCallRoute(convId) } fun dmRequestMessages(convId: String) { nativeDmRequestMessages(convId) } - fun dmSendFile(convId: String, path: String, name: String): Boolean = nativeDmSendFile(convId, path, name) + fun dmSendFile(convId: String, path: String, name: String, generation: Long): Boolean = nativeDmSendFile(convId, path, name, generation) private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean private external fun nativeStart(configText: String): Boolean @@ -367,7 +368,7 @@ object NativeLib { private external fun nativeRequestLocalSockets() /* ── Voice recording JNI ── */ - private external fun nativeVoiceStart(channelId: String, replyTo: ByteArray, requestId: Long): Boolean + private external fun nativeVoiceStart(channelId: String, replyTo: ByteArray, requestId: Long, generation: Long): Boolean private external fun nativeVoiceFeed(samples: ShortArray): Boolean private external fun nativeVoiceStop(): Int private external fun nativeVoiceCancel() @@ -388,7 +389,7 @@ object NativeLib { /* ── Video message JNI ── */ private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int, - replyTo: ByteArray, requestId: Long): Boolean + replyTo: ByteArray, requestId: Long, generation: Long): Boolean /* ── Photo message JNI ── */ private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int, @@ -433,12 +434,13 @@ object NativeLib { private external fun nativeRadioTalkEnd(groupId: Long) /* ── DM JNI ── */ + private external fun nativeDmDelete(convId: String, generation: Long, request: Long): Boolean private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String) - private external fun nativeDmSend(convId: String, text: String) + private external fun nativeDmSend(convId: String, text: String, generation: Long) private external fun nativeDmInvite(requestId: Long, peer: Long, source: String, channelId: Long, name: String, cancel: Boolean): Boolean private external fun nativeIsChannelMember(channelId: String, nodeId: Long): Boolean private external fun nativeDmRequestConversations() private external fun nativeDmRequestCallRoute(convId: String) private external fun nativeDmRequestMessages(convId: String) - private external fun nativeDmSendFile(convId: String, path: String, name: String): Boolean + private external fun nativeDmSendFile(convId: String, path: String, name: String, generation: Long): Boolean } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt index 21cd83bb..ce193bad 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt @@ -31,13 +31,13 @@ private data class SelectedAttachment(val target: ConversationTarget, val uri: U /** Общий ввод и вложения; URI принадлежит экрану, адресат операции неизменяем. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, onSendText: (String) -> Unit, +fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, onRecordVideo: () -> Unit, onCapturePhoto: (() -> Unit)? = null) { val context = LocalContext.current val scope = rememberCoroutineScope() val activeTarget by rememberUpdatedState(target) - var text by rememberSaveable(target.nativeId, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } - var textRevision by rememberSaveable(target.nativeId) { mutableStateOf(0L) } + var text by rememberSaveable(target, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + var textRevision by rememberSaveable(target) { mutableStateOf(0L) } val replies by viewModel.replies.collectAsState() val reply = if (target is ConversationTarget.Channel) replies[target.id] else null val sendFailed by viewModel.messageSendFailed.collectAsState() @@ -126,7 +126,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o if (sentText.isNotBlank()) { textSaved = if (target is ConversationTarget.Channel) viewModel.sendChannelText(target, sentText, capturedReply).await() - else { onSendText(sentText); true } + else viewModel.sendDmText(target as ConversationTarget.Pm, sentText) if (textSaved && textRevision == revision) { text = TextFieldValue(); textRevision++ } } if (attachmentSaved && textSaved) viewModel.finishReply(capturedReply) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index c151ec1d..14770a4d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -49,6 +49,10 @@ fun ChannelListScreen( onCpuLoadVisible: (Boolean) -> Unit = {}, onSelectChannel: (Channel) -> Unit, onSelectDm: (DmConversation) -> Unit = {}, + onDeleteDm: (DmConversation) -> Unit, + deletingDm: Set = emptySet(), + dmDeleteFailed: Boolean = false, + onDismissDmDeleteError: () -> Unit, onCreateChannel: (String) -> Unit, onJoinChannel: () -> Unit, onInviteClick: (Channel) -> Unit, @@ -65,6 +69,8 @@ fun ChannelListScreen( var menuChannelId by remember { mutableStateOf(null) } var optionsChannel by remember { mutableStateOf(null) } var deleteConfirmChannel by remember { mutableStateOf(null) } + var menuDmId by remember { mutableStateOf(null) } + var deleteConfirmDm by remember { mutableStateOf(null) } LaunchedEffect(Unit) { LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}") @@ -195,7 +201,16 @@ fun ChannelListScreen( Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFFD2A8FF))) Row(Modifier.fillMaxWidth().padding(12.dp)) { Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { - UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp) + IconButton(onClick = { menuDmId = dm.convId }, modifier = Modifier.size(44.dp)) { + UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp) + } + DropdownMenu(expanded = menuDmId == dm.convId, onDismissRequest = { menuDmId = null }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.delete_conversation), color = MaterialTheme.colorScheme.error) }, + enabled = dm.convId !in deletingDm, + onClick = { menuDmId = null; deleteConfirmDm = dm } + ) + } PeerStatusDot(dm.online, Modifier.align(Alignment.TopStart).offset(x = (-4).dp, y = (-6).dp)) } Spacer(Modifier.width(10.dp)) @@ -213,6 +228,25 @@ fun ChannelListScreen( } } + deleteConfirmDm?.let { dm -> + AlertDialog( + onDismissRequest = { deleteConfirmDm = null }, + title = { Text(stringResource(R.string.delete_conversation)) }, + text = { Text(stringResource(R.string.delete_conversation_confirm, dm.displayName)) }, + confirmButton = { + TextButton(onClick = { deleteConfirmDm = null; onDeleteDm(dm) }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { TextButton(onClick = { deleteConfirmDm = null }) { Text(stringResource(R.string.cancel)) } } + ) + } + if (dmDeleteFailed) { + AlertDialog(onDismissRequest = onDismissDmDeleteError, text = { Text(stringResource(R.string.delete_conversation_failed)) }, + confirmButton = { TextButton(onClick = onDismissDmDeleteError) { Text(stringResource(android.R.string.ok)) } }) + } + if (showCreateDialog) { CreateChannelDialog( onDismiss = { showCreateDialog = false }, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index bbbd53ab..e1d8397a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -83,7 +83,6 @@ fun ChatScreen( messages: List, connected: Boolean, viewModel: ChatViewModel, - onSendMessage: (String) -> Unit, onMembersClick: () -> Unit, onFiles: (Long) -> Unit = {}, onInvite: () -> Unit, @@ -276,7 +275,7 @@ fun ChatScreen( ) } } - ConversationComposer(ConversationTarget.Channel(channel.id), viewModel, onSendMessage, onRecordVideo, onCapturePhoto) + ConversationComposer(ConversationTarget.Channel(channel.id), viewModel, onRecordVideo, onCapturePhoto) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt index 4fb6f390..0357f7a4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt @@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatViewModel, - onSendMessage: (String) -> Unit, onBack: () -> Unit, + onBack: () -> Unit, onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) { - val target = ConversationTarget.Pm(conv.convId) + val target = ConversationTarget.Pm(conv.convId, conv.generation) val playback by viewModel.playbackState.collectAsState() val callAvailable by viewModel.dmCallAvailable.collectAsState() val callState by viewModel.callState.collectAsState() @@ -87,6 +87,6 @@ fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatV onOpenFile = { if (it.filePath.isNotEmpty()) { pathToSave = it.filePath; save.launch(it.text) } }, onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink) if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) - ConversationComposer(target, viewModel, onSendMessage, onRecordVideo) + ConversationComposer(target, viewModel, onRecordVideo) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index c9c8d19d..60af25a1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -65,7 +65,10 @@ fun MemberListScreen( var showEditDialog by remember { mutableStateOf(false) } var editMember by remember { mutableStateOf(null) } - LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) } + LaunchedEffect(channel.id) { + Log.d("utun-gui", "Member list: channel=${channel.id} actionWidth=36dp statusIconGap=2dp") + viewModel.refreshMembers(channel.id) + } val memberLinks by viewModel.memberLinks.collectAsState() val localSockets by viewModel.localSockets.collectAsState() @@ -170,6 +173,7 @@ fun MemberListScreen( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MemberListItem( member: ChatMember, @@ -212,27 +216,27 @@ private fun MemberListItem( Row(verticalAlignment = Alignment.CenterVertically) { if (member.isSuper) { Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(2.dp)) } if (member.isAdmin) { Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(2.dp)) } if (member.isModer) { Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(2.dp)) } if (member.isStorage) { Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(2.dp)) } if (member.radioActive) { PortableRadioIcon(stringResource(R.string.radio_active), Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(2.dp)) } if (!member.isVerified) { Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(2.dp)) } Text( text = member.name, @@ -261,18 +265,27 @@ private fun MemberListItem( val radioStatus = stringResource(if (speaking) R.string.radio_member_speaking else R.string.radio_member_silent) + if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else "" - if (member.isSelf) { - Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { - RadioSpeakerIcon(speaking, false, radioStatus) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + val actionModifier = Modifier.size(width = 36.dp, height = 48.dp) + if (member.isSelf) { + Box(actionModifier, contentAlignment = Alignment.Center) { + RadioSpeakerIcon(speaking, false, radioStatus) + } + } else { + IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }, modifier = actionModifier) { + RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " + + stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute)) + } + IconButton(onClick = onInvite, modifier = actionModifier) { + Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) + } + IconButton(onClick = onCall, enabled = member.online, modifier = actionModifier) { + Icon(Icons.Filled.Call, stringResource(R.string.call)) + } + IconButton(onClick = onMessage, modifier = actionModifier) { + Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) + } } - } else { - IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }) { - RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " + - stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute)) - } - IconButton(onClick = onInvite) { Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) } - IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) } - IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) } } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index c0081310..4bfc044f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -137,8 +137,10 @@ class ChatViewModel : ViewModel() { data class AttachmentPreparation(val jobs: Set = emptySet(), val failed: Boolean = false) private val _attachmentPreparations = MutableStateFlow>(emptyMap()) val attachmentPreparations: StateFlow> = _attachmentPreparations + private val discardedPreparationJobs = mutableSetOf() private fun preparationState(target: String, job: String, state: Int) { + if (job in discardedPreparationJobs) { if (state != 0) discardedPreparationJobs.remove(job); return } _attachmentPreparations.update { map -> val previous = map[target] ?: AttachmentPreparation() val value = if (state == 0) AttachmentPreparation(previous.jobs + job) @@ -296,6 +298,12 @@ class ChatViewModel : ViewModel() { private val _dmConversations = MutableStateFlow>(emptyList()) val dmConversations: StateFlow> = _dmConversations + private var nextDmDeleteRequest = System.nanoTime().ushr(1) + private val dmDeleteRequests = mutableMapOf>() + private val _dmDeleting = MutableStateFlow>(emptySet()) + val dmDeleting: StateFlow> = _dmDeleting + private val _dmDeleteFailed = MutableStateFlow(false) + val dmDeleteFailed: StateFlow = _dmDeleteFailed private val _dmMessages = MutableStateFlow>(emptyList()) val dmMessages: StateFlow> = _dmMessages @@ -563,7 +571,7 @@ class ChatViewModel : ViewModel() { memberRadios.clear() memberRadioMutes.clear() _members.value = _members.value.map { it.copy(radioActive = false) } - _attachmentPreparations.value = emptyMap() + _attachmentPreparations.value = emptyMap(); discardedPreparationJobs.clear() _pmInvite.value?.let { state -> if (state.pending) _pmInvite.value = state.copy(pending = false, errorRes = R.string.invite_core_unavailable) } @@ -571,17 +579,22 @@ class ChatViewModel : ViewModel() { if (state.pending) _linkJoin.value = state.copy(pending = false, result = -7) } LogManager.addLog("INFO", "VM", "service stopped; pending invitation UI released") + if (dmDeleteRequests.isNotEmpty()) { + dmDeleteRequests.clear(); _dmDeleting.value = emptySet(); _dmDeleteFailed.value = true + LogManager.addLog("WARN", "PM", "Service stopped before pending delete results") + } } - 48 -> { /* [request_id:8][result:4][peer:8][conv:8] */ - if (data == null || data.size != 28) { LogManager.addLog("ERROR", "INVITE", "invalid PM invite result"); return } + 48 -> { /* [request_id:8][result:4][peer:8][conv:8][generation:8] */ + if (data == null || data.size != 36) { LogManager.addLog("ERROR", "INVITE", "invalid PM invite result"); return } val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) val request = buf.long; val result = buf.int; val peer = buf.long; val conv = buf.long.toULong().toString() + val generation = buf.long val state = _pmInvite.value ?: return if (!state.pending || state.requestId != request || state.peer != peer) return LogManager.addLog(if (result == 0) "INFO" else "ERROR", "INVITE", "PM invite result request=$request peer=$peer result=$result") if (result == 0) { _pmInvite.value = null - openDm(conv, peer, state.name); _dmOpen.value = conv; refreshDmConversations() + openDm(conv, peer, state.name, generation); _dmOpen.value = conv; refreshDmConversations() } else { val error = when (result) { -1 -> R.string.invite_pm_start_failed @@ -857,10 +870,10 @@ class ChatViewModel : ViewModel() { } refreshChannels() } - 33 -> { /* DM_CONV_UPDATED: [conv_id_len:1][conv_id:var] */ + 33 -> { /* DM_CONV_UPDATED: [conv_id_len:1][conv_id:var][generation:8] */ if (data == null || data.size < 2) return val cl = data[0].toInt() and 0xFF - if (data.size < 1 + cl) return + if (data.size != cl + 9) { LogManager.addLog("ERROR", "PM", "Invalid conversation update bytes=${data.size}"); return } val convId = String(data, 1, cl) LogManager.addLog("INFO", "VM", "DM_CONV_UPDATED conv=$convId") refreshDmConversations() @@ -868,7 +881,9 @@ class ChatViewModel : ViewModel() { val pend = pendingStartPeer if (pend != null) { pendingStartPeer = null - _currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second) + val generation = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long + _currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second, + generation = generation) _dmCallAvailable.value = false refreshDmCallRoute() refreshDmMessages() @@ -904,8 +919,30 @@ class ChatViewModel : ViewModel() { _dmConversations.value = (repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList()).map { it.copy(online = previous[it.convId]?.online == true) } + _currentDm.value?.let { current -> + _dmConversations.value.find { it.convId == current.convId }?.let { _currentDm.value = it } + } refreshDmCallRoute() } + 59 -> { /* DM_DELETE_RESULT: request, conv, generation, success. */ + if (data == null || data.size != 25) { LogManager.addLog("ERROR", "PM", "Invalid delete result"); return } + val b = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val request = b.long; val conv = b.long.toULong().toString(); val generation = b.long; val success = b.get().toInt() == 1 + val expected = dmDeleteRequests[request] ?: return + if (expected != (conv to generation)) { LogManager.addLog("ERROR", "PM", "Delete result target mismatch"); return } + dmDeleteRequests.remove(request); _dmDeleting.value = dmDeleteRequests.values.map { it.first }.toSet() + LogManager.addLog(if (success) "INFO" else "ERROR", "PM", "Delete result conv=$conv generation=$generation success=$success") + if (!success) { _dmDeleteFailed.value = true; return } + val deleted = _dmConversations.value.find { it.convId == conv && it.generation == generation } + _dmConversations.value = _dmConversations.value.filterNot { it.convId == conv && it.generation == generation } + _currentDm.value?.let { if (it.convId == conv && it.generation == generation) closeDm() } + val target = ConversationTarget.Pm(conv, generation) + _attachmentPreparations.value[target.nativeId]?.let { discardedPreparationJobs.addAll(it.jobs) } + _attachmentPreparations.value = _attachmentPreparations.value - target.nativeId + cancelVoiceRecording(target) + if (deleted != null && _pmInvite.value?.peer == deleted.peerNodeId) dismissPmInvite() + refreshDmConversations() + } 46 -> { if (data == null || data.size < 18) return val length = data[0].toInt() and 255 @@ -1319,9 +1356,9 @@ class ChatViewModel : ViewModel() { fun consumeDmOpen() { _dmOpen.value = null } - fun openDm(convId: String, peerNodeId: Long, peerName: String) { + fun openDm(convId: String, peerNodeId: Long, peerName: String, generation: Long) { if (repo == null) return - val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName) + val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName, generation = generation) _currentDm.value = dm _dmCallAvailable.value = false refreshDmCallRoute() @@ -1335,13 +1372,31 @@ class ChatViewModel : ViewModel() { _dmMessages.value = emptyList() } - fun sendDmMessage(text: String) { - val r = repo ?: return - val dm = _currentDm.value ?: return - if (text.isBlank()) return - r.dmSend(dm.convId, text) + fun sendDmText(target: ConversationTarget.Pm, text: String): Boolean { + val r = repo ?: return false + if (text.isBlank()) return false + val dm = _dmConversations.value.find { it.convId == target.id } ?: _currentDm.value?.takeIf { it.convId == target.id } + if (dm?.generation != target.generation || target.id in _dmDeleting.value) { + LogManager.addLog("INFO", "PM", "Discarded stale text conv=${target.id} generation=${target.generation}"); return false + } + r.dmSend(target.id, text, target.generation) + return true } + fun deleteDmConversation(dm: DmConversation) { + if (dm.convId in _dmDeleting.value) return + val request = ++nextDmDeleteRequest + dmDeleteRequests[request] = dm.convId to dm.generation + _dmDeleting.value = _dmDeleting.value + dm.convId + LogManager.addLog("INFO", "PM", "Delete requested conv=${dm.convId} generation=${dm.generation} request=$request") + if (!NativeLib.dmDelete(dm.convId, dm.generation, request)) { + dmDeleteRequests.remove(request); _dmDeleting.value = _dmDeleting.value - dm.convId; _dmDeleteFailed.value = true + LogManager.addLog("ERROR", "PM", "Delete request rejected conv=${dm.convId}") + } + } + + fun dismissDmDeleteError() { _dmDeleteFailed.value = false } + fun showPmInvite(peer: Long, name: String, source: String = "") { if (peer == 0L || peer == NativeLib.getMyNodeId()) { LogManager.addLog("ERROR", "INVITE", "invalid PM recipient"); return } if (_pmInvite.value != null) return @@ -1395,10 +1450,6 @@ class ChatViewModel : ViewModel() { fun dismissLinkJoin() { _linkJoin.value = null } fun consumeJoinedChannel() { _joinedChannel.value = null } - fun sendDmFile(convId: String, path: String, name: String) { - if (repo != null) NativeLib.dmSendFile(convId, path, name) - } - fun refreshMembers(chId: String) { val r = repo ?: return val statuses = _nodeStatuses.value @@ -1549,7 +1600,8 @@ class ChatViewModel : ViewModel() { val reply = replyFor(target) val request = if (target is ConversationTarget.Channel) ++nextSubmissionId to CompletableDeferred() else null if (request != null) submissions[request.first] = request.second - if (!audioRecorder.startRecording(target.nativeId, reply?.message?.messageId.orEmpty(), request?.first ?: 0)) { + if (!audioRecorder.startRecording(target.nativeId, reply?.message?.messageId.orEmpty(), request?.first ?: 0, + (target as? ConversationTarget.Pm)?.generation ?: 0)) { request?.let { submissions.remove(it.first) } LogManager.addLog("ERROR", "VM", "voice recording failed to start") return @@ -1633,7 +1685,7 @@ class ChatViewModel : ViewModel() { fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name, reply: ChannelReply? = replyFor(target)): Deferred = viewModelScope.async { if (target is ConversationTarget.Pm) { - val accepted = NativeLib.dmSendFile(target.id, filePath, name) + val accepted = NativeLib.dmSendFile(target.id, filePath, name, target.generation) if (!accepted) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true } return@async accepted } @@ -1674,7 +1726,7 @@ class ChatViewModel : ViewModel() { } val queue: (Long) -> Boolean = { request -> NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third, - reply?.message?.messageId.orEmpty(), request).also { accepted = it } + reply?.message?.messageId.orEmpty(), request, (target as? ConversationTarget.Pm)?.generation ?: 0).also { accepted = it } } saved = if (target is ConversationTarget.Channel) submitChannel(target.id, reply, queue) else queue(0) if (!saved) _messageSendFailed.value = true diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 148ea525..799275ca 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,8 @@ + Удалить переписку + Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится. + Не удалось удалить переписку. Повторите попытку. Настройки звонка Управление звонком Своё видео diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 0b061b48..d4a7cc62 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -1,5 +1,8 @@ + Delete conversation + Delete the conversation with %1$s and its local files? The other person keeps their history. + Could not delete the conversation. Please try again. Call settings Call controls Your video diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt index 72cb36c1..822e7258 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt @@ -6,6 +6,20 @@ import java.nio.ByteBuffer import java.nio.ByteOrder class MemberAvatarTest { + @Test fun snapshotStringsStopAtFirstNullBeforeDirtyPadding() { + for (name in listOf("", "0x06B9", "Без имени", "x".repeat(63))) { + val bytes = ByteArray(MEMBER_WIRE_SIZE) + bytes.fill(0xff.toByte(), 15, 79) + val encoded = name.toByteArray(Charsets.UTF_8) + encoded.copyInto(bytes, 15); bytes[15 + encoded.size] = 0 + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putLong(79, 456) + bytes.fill(0xff.toByte(), 87, 157); bytes[87] = 0; bytes[157] = 1 + val member = requireNotNull(parseMemberWire(bytes)).toChatMember() + assertEquals(name, member.name) + assertEquals(456L, member.avatarServer); assertEquals("", member.avatarFile); assertTrue(member.storageRole) + } + } + @Test fun sharedSnapshotKeepsAvatarAndSignedStorageRole() { val bytes = ByteArray(158) val b = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 2783a0cf..7e54d711 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -614,7 +614,7 @@ void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char uasync_post(ua, dm_start_trampoline, req); } -void utun_bridge_dm_send(const char* conv_id, const char* text) { +void utun_bridge_dm_send(const char* conv_id, const char* text, uint64_t generation) { if (!conv_id || !conv_id[0] || !text) { bridge_log(BLEV_ERROR, "dm_send: invalid args"); return; } struct UASYNC* ua = instance_lite_get_uasync(); if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "dm_send: uasync not running"); return; } @@ -623,6 +623,7 @@ void utun_bridge_dm_send(const char* conv_id, const char* text) { if (!req) { bridge_log(BLEV_ERROR, "dm_send: OOM"); return; } req->inst = instance_lite_get_instance(); strncpy(req->conv_id, conv_id, sizeof(req->conv_id) - 1); + req->generation = generation; req->data_len = (uint32_t)len; req->data = (uint8_t*)(req + 1); memcpy(req->data, text, len); @@ -649,7 +650,7 @@ void utun_bridge_dm_request_messages(const char* conv_id) { uasync_post(ua, dm_messages_trampoline, req); } -int utun_bridge_dm_send_file(const char* conv_id, const char* path, const char* name) { +int utun_bridge_dm_send_file(const char* conv_id, const char* path, const char* name, uint64_t generation) { struct UASYNC* ua = instance_lite_get_uasync(); if (!conv_id || !path || !name || !uasync_is_running(ua)) { bridge_log(BLEV_WARN, "DM file: invalid request/core stopped"); return -1; } struct dm_file_req* req = u_calloc(1, sizeof(*req)); @@ -658,6 +659,7 @@ int utun_bridge_dm_send_file(const char* conv_id, const char* path, const char* bridge_log(BLEV_ERROR, "DM file: path, conversation or name too long"); u_free(req); return -1; } req->inst = instance_lite_get_instance(); + req->generation = generation; snprintf(req->conv, sizeof(req->conv), "%s", conv_id); snprintf(req->path, sizeof(req->path), "%s", path); req->info.kind = ATTACHMENT_FILE; @@ -1365,9 +1367,9 @@ int utun_bridge_is_responsive(void) { * Voice recording * ────────────────────────────────────────────────────────────────── */ -int utun_bridge_voice_start(const char* channel_id, const uint8_t reply_to[32], uint64_t request_id) { +int utun_bridge_voice_start(const char* channel_id, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation) { voice_recorder_init(g_db_path); /* idempotent: гарантирует g_rec после stop/restart */ - return voice_recorder_start(channel_id, 48000, 1, reply_to, request_id); + return voice_recorder_start(channel_id, 48000, 1, reply_to, request_id, generation); } int utun_bridge_voice_feed(const int16_t* samples, int count) { @@ -1431,8 +1433,8 @@ int utun_bridge_attachment_download(const char* channel_id, int64_t msg_id) { } int utun_bridge_video_send(const char* channel_id, const char* file_path, const char* db_path, - int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id) { - return video_send(channel_id, file_path, db_path, duration_ms, width, height, reply_to, request_id); + int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation) { + return video_send(channel_id, file_path, db_path, duration_ms, width, height, reply_to, request_id, generation); } int utun_bridge_photo_send(const char* channel_id, const char* file_path, const char* db_path, @@ -2055,16 +2057,38 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmStart( } JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmSend( - JNIEnv* env, jobject thiz, jstring convId, jstring text) { + JNIEnv* env, jobject thiz, jstring convId, jstring text, jlong generation) { (void)thiz; const char* c = (*env)->GetStringUTFChars(env, convId, NULL); const char* t = (*env)->GetStringUTFChars(env, text, NULL); - utun_bridge_dm_send(c, t); + utun_bridge_dm_send(c, t, (uint64_t)generation); if (t) (*env)->ReleaseStringUTFChars(env, text, t); if (c) (*env)->ReleaseStringUTFChars(env, convId, c); } /* UI получает подтверждение commit через CHAT_EVT_DM_INVITE_RESULT, а не через return JNI. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeDmDelete( + JNIEnv* env, jobject thiz, jstring conversation, jlong generation, jlong request) { + (void)thiz; + struct UASYNC* ua = instance_lite_get_uasync(); + if (!uasync_is_running(ua) || generation <= 0 || request <= 0) { + bridge_log(BLEV_WARN, "DM delete: stopped core or invalid generation/request"); return JNI_FALSE; + } + const char* conv = (*env)->GetStringUTFChars(env, conversation, NULL); + struct dm_delete_req* req = u_calloc(1, sizeof(*req)); + struct posted_task* post = u_calloc(1, sizeof(*post)); + if (!conv || !req || !post || !conv[0] || strlen(conv) >= sizeof(req->conv_id) || strspn(conv, "0123456789") != strlen(conv)) { + bridge_log(BLEV_ERROR, "DM delete: invalid conversation or allocation failed"); + if (conv) (*env)->ReleaseStringUTFChars(env, conversation, conv); + u_free(req); u_free(post); return JNI_FALSE; + } + req->inst = instance_lite_get_instance(); req->generation = (uint64_t)generation; req->request_id = (uint64_t)request; + snprintf(req->conv_id, sizeof(req->conv_id), "%s", conv); + (*env)->ReleaseStringUTFChars(env, conversation, conv); + post->callback = dm_delete_trampoline; post->arg = req; uasync_post_reserved(ua, post); + return JNI_TRUE; +} + JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeDmInvite( JNIEnv* env, jobject thiz, jlong request_id, jlong peer, jstring source, jlong channel, jstring name, jboolean cancel) { (void)thiz; @@ -2131,12 +2155,12 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmRequestMessages } JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeDmSendFile( - JNIEnv* env, jobject thiz, jstring convId, jstring path, jstring name) { + JNIEnv* env, jobject thiz, jstring convId, jstring path, jstring name, jlong generation) { (void)thiz; const char* c = (*env)->GetStringUTFChars(env, convId, NULL); const char* p = (*env)->GetStringUTFChars(env, path, NULL); const char* n = (*env)->GetStringUTFChars(env, name, NULL); - int rc = utun_bridge_dm_send_file(c, p, n); + int rc = utun_bridge_dm_send_file(c, p, n, (uint64_t)generation); if (n) (*env)->ReleaseStringUTFChars(env, name, n); if (p) (*env)->ReleaseStringUTFChars(env, path, p); if (c) (*env)->ReleaseStringUTFChars(env, convId, c); @@ -2321,12 +2345,12 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeClearDatabase /* ── Voice recording JNI ── */ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceStart( - JNIEnv* env, jobject thiz, jstring channelId, jbyteArray replyTo, jlong requestId) { + JNIEnv* env, jobject thiz, jstring channelId, jbyteArray replyTo, jlong requestId, jlong generation) { (void)thiz; uint8_t reply[32]; if (jni_message_id(env, replyTo, reply) < 0) return JNI_FALSE; const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); - int rc = utun_bridge_voice_start(ch, reply, (uint64_t)requestId); + int rc = utun_bridge_voice_start(ch, reply, (uint64_t)requestId, (uint64_t)generation); if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch); return (rc == 0) ? JNI_TRUE : JNI_FALSE; } @@ -2425,13 +2449,13 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeAttachmentDow JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVideoSend( JNIEnv* env, jobject thiz, jstring channelId, jstring filePath, - jint durationMs, jint width, jint height, jbyteArray replyTo, jlong requestId) { + jint durationMs, jint width, jint height, jbyteArray replyTo, jlong requestId, jlong generation) { (void)thiz; uint8_t reply[32]; if (jni_message_id(env, replyTo, reply) < 0) return JNI_FALSE; const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); const char* fp = (*env)->GetStringUTFChars(env, filePath, NULL); - int rc = utun_bridge_video_send(ch, fp, g_db_path, (int)durationMs, (int)width, (int)height, reply, (uint64_t)requestId); + int rc = utun_bridge_video_send(ch, fp, g_db_path, (int)durationMs, (int)width, (int)height, reply, (uint64_t)requestId, (uint64_t)generation); if (fp) (*env)->ReleaseStringUTFChars(env, filePath, fp); if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch); return (rc == 0) ? JNI_TRUE : JNI_FALSE; diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 5dca214c..d8fc932a 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -86,7 +86,7 @@ int utun_bridge_is_responsive(void); /* ── Voice recording ── */ -int utun_bridge_voice_start(const char* channel_id, const uint8_t reply_to[32], uint64_t request_id); +int utun_bridge_voice_start(const char* channel_id, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation); int utun_bridge_voice_feed(const int16_t* samples, int count); int utun_bridge_voice_stop(int* out_duration_ms); void utun_bridge_voice_cancel(void); @@ -107,7 +107,7 @@ int utun_bridge_attachment_download(const char* channel_id, int64_t msg_id); /* Отправка видеосообщения (src — готовый mp4, без транскода) */ int utun_bridge_video_send(const char* channel_id, const char* file_path, const char* db_path, - int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id); + int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation); /* Отправка фото (src — готовый jpeg, без транскода) */ int utun_bridge_photo_send(const char* channel_id, const char* file_path, const char* db_path, @@ -211,10 +211,10 @@ void utun_bridge_collect_conn_metrics(uint64_t peer_node_id); * dm_start/dm_send выполняются асинхронно через uasync (трамплины dm_core). * Результат dm_start — событие CHAT_EVT_DM_CONV_UPDATED. */ void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char* peer_name); -void utun_bridge_dm_send(const char* conv_id, const char* text); +void utun_bridge_dm_send(const char* conv_id, const char* text, uint64_t generation); void utun_bridge_dm_request_conversations(void); void utun_bridge_dm_request_messages(const char* conv_id); -int utun_bridge_dm_send_file(const char* conv_id, const char* path, const char* name); +int utun_bridge_dm_send_file(const char* conv_id, const char* path, const char* name, uint64_t generation); #ifdef __cplusplus } diff --git a/tools/chatgui-android/libutun_lite/video_sender.c b/tools/chatgui-android/libutun_lite/video_sender.c index 9e9c229f..5db96cb4 100644 --- a/tools/chatgui-android/libutun_lite/video_sender.c +++ b/tools/chatgui-android/libutun_lite/video_sender.c @@ -9,7 +9,7 @@ #include "instance_lite.h" int video_send(const char* channel_id, const char* src_file_path, const char* db_path, - int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id) { + int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation) { (void)db_path; struct UASYNC* ua = instance_lite_get_uasync(); if (!channel_id || !src_file_path || !uasync_is_running(ua) || duration_ms <= 0 || duration_ms > 86400000 || @@ -19,6 +19,7 @@ int video_send(const char* channel_id, const char* src_file_path, const char* db struct attachment_send_req* req = u_calloc(1, sizeof(*req)); struct posted_task* post = u_calloc(1, sizeof(*post)); if (!req || !post) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "video send: allocation failed"); u_free(req); u_free(post); return -1; } + req->dm_generation = generation; req->inst = instance_lite_get_instance(); req->is_dm = !strncmp(channel_id, "dm:", 3); if (!req->is_dm) { memcpy(req->reply_to, reply_to, sizeof(req->reply_to)); req->request_id = request_id; } snprintf(req->target, sizeof(req->target), "%s", channel_id + (req->is_dm ? 3 : 0)); diff --git a/tools/chatgui-android/libutun_lite/video_sender.h b/tools/chatgui-android/libutun_lite/video_sender.h index 2a54ff87..4f474ff8 100644 --- a/tools/chatgui-android/libutun_lite/video_sender.h +++ b/tools/chatgui-android/libutun_lite/video_sender.h @@ -11,7 +11,7 @@ extern "C" { #endif int video_send(const char* channel_id, const char* src_file_path, const char* db_path, - int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id); + int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation); #ifdef __cplusplus } diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.c b/tools/chatgui-android/libutun_lite/voice_recorder.c index 3416beb0..6bba9e37 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.c +++ b/tools/chatgui-android/libutun_lite/voice_recorder.c @@ -26,7 +26,7 @@ struct voice_recorder { char channel_id[64]; uint8_t reply_to[32]; - uint64_t request_id; + uint64_t request_id, generation; int sample_rate; int channels; int frame_samples; @@ -108,7 +108,8 @@ void voice_recorder_deinit(void) { pthread_mutex_unlock(&g_init_mtx); } -int voice_recorder_start(const char* channel_id, int sample_rate, int channels, const uint8_t reply_to[32], uint64_t request_id) { +int voice_recorder_start(const char* channel_id, int sample_rate, int channels, const uint8_t reply_to[32], + uint64_t request_id, uint64_t generation) { if (!channel_id || !channel_id[0] || strlen(channel_id) >= 64 || sample_rate != 48000 || channels != 1) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice start: invalid target or capture format rate=%d channels=%d", sample_rate, channels); return -1; @@ -132,7 +133,7 @@ int voice_recorder_start(const char* channel_id, int sample_rate, int channels, g_rec->frame_samples = sample_rate * FRAME_MS / 1000; snprintf(g_rec->channel_id, sizeof(g_rec->channel_id), "%s", channel_id ? channel_id : ""); memcpy(g_rec->reply_to, reply_to, sizeof(g_rec->reply_to)); - g_rec->request_id = request_id; + g_rec->request_id = request_id; g_rec->generation = generation; g_rec->pcm_count = 0; g_rec->active = 1; @@ -217,6 +218,7 @@ int voice_recorder_stop(int* out_duration_ms) { } req->inst = instance_lite_get_instance(); req->info.kind = ATTACHMENT_VOICE; req->preset = g_rec->preset; req->is_dm = !strncmp(g_rec->channel_id, "dm:", 3); + req->dm_generation = g_rec->generation; if (!req->is_dm) { memcpy(req->reply_to, g_rec->reply_to, sizeof(req->reply_to)); req->request_id = g_rec->request_id; } snprintf(req->target, sizeof(req->target), "%s", g_rec->channel_id + (req->is_dm ? 3 : 0)); if (g_rec->compressor && g_rec->compressor_enabled) { diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.h b/tools/chatgui-android/libutun_lite/voice_recorder.h index 1d2e19af..2291dd7f 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.h +++ b/tools/chatgui-android/libutun_lite/voice_recorder.h @@ -18,7 +18,7 @@ struct voice_recorder; int voice_recorder_init(const char* db_path); void voice_recorder_deinit(void); -int voice_recorder_start(const char* channel_id, int sample_rate, int channels, const uint8_t reply_to[32], uint64_t request_id); +int voice_recorder_start(const char* channel_id, int sample_rate, int channels, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation); int voice_recorder_feed(const int16_t* samples, int count); int voice_recorder_stop(int* out_duration_ms); void voice_recorder_cancel(void); diff --git a/tools/chatgui/src/dminvitedialog.cpp b/tools/chatgui/src/dminvitedialog.cpp index d3955b87..56efc884 100644 --- a/tools/chatgui/src/dminvitedialog.cpp +++ b/tools/chatgui/src/dminvitedialog.cpp @@ -89,7 +89,7 @@ void DmInviteDialog::sendInvite() { } void DmInviteDialog::onResult(const uint8_t* data, int len) { - if (!data || len != 28) { GUI_ERROR("invite PM: invalid result bytes=%d", len); return; } + if (!data || len != 36) { GUI_ERROR("invite PM: invalid result bytes=%d", len); return; } uint64_t request, peer, conv; int32_t result; memcpy(&request, data, 8); memcpy(&result, data + 8, 4); memcpy(&peer, data + 12, 8); memcpy(&conv, data + 20, 8);