feat(chat): add local DM deletion and improve Android member list

Delete local conversations atomically, retain replay protection and sequence counters, and guard asynchronous media work by history generation. Add Android avatar menu and confirmation, persistent file cleanup, and regression coverage.

Fix unnamed member wire padding and compact Android member action spacing.

Validation: 158 core tests passed; targeted DM tests, desktop build, Android unit tests and debug APK build passed.
This commit is contained in:
evgeny committed 2026-10-18 02:04:05 +02:00
1 parent 97710b609f
commit be295f768c
38 files changed
+1005 -126

No files matched your search

+4
View File
@@ -175,6 +175,8 @@ utun_CORE_SOURCES = \
chat/invite_build.c \ chat/invite_build.c \
chat/chat_headless_control.c \ chat/chat_headless_control.c \
dm/dm_core.c \ dm/dm_core.c \
dm/dm_history.c \
dm/dm_history.h \
dm/dm_crypto.c \ dm/dm_crypto.c \
dm/dm_mailbox.c \ dm/dm_mailbox.c \
dm/dm_media.c \ dm/dm_media.c \
@@ -340,6 +342,8 @@ libutun_a_SOURCES = \
chat/invite_build.c \ chat/invite_build.c \
chat/chat_headless_control.c \ chat/chat_headless_control.c \
dm/dm_core.c \ dm/dm_core.c \
dm/dm_history.c \
dm/dm_history.h \
dm/dm_crypto.c \ dm/dm_crypto.c \
dm/dm_mailbox.c \ dm/dm_mailbox.c \
dm/dm_media.c \ dm/dm_media.c \
+1
View File
@@ -53,6 +53,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data,
[55]="RADIO_MUTE_RESULT", [55]="RADIO_MUTE_RESULT",
[56]="MSG_SUBMITTED", [56]="MSG_SUBMITTED",
[57]="CPU_LOAD", [57]="CPU_LOAD",
[59]="DM_DELETE_RESULT",
}; };
const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?"; 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); DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len);
+3 -2
View File
@@ -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_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_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_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_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_RINGING 35 /* [call_id:8] */
#define CHAT_EVT_CALL_ACCEPTED 36 /* [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_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_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_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_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; результат публикации ссылки профиля */ #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_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_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_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); typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len);
+5 -1
View File
@@ -85,10 +85,14 @@ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t*
memcpy(dst + 11, &rtt, 2); memcpy(dst + 11, &rtt, 2);
dst[13] = src->nat_type; dst[13] = src->nat_type;
dst[14] = src->storage; 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); mf_put64(dst + 79, src->avatar_server);
memcpy(dst + 87, src->avatar_file, 70); memcpy(dst + 87, src->avatar_file, 70);
dst[157] = src->storage_role; 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) { int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst) {
+58 -11
View File
@@ -11,6 +11,7 @@
#include "dm_core.h" #include "dm_core.h"
#include "dm_crypto.h" #include "dm_crypto.h"
#include "dm_history.h"
#include "dm_mailbox.h" #include "dm_mailbox.h"
#include "../chat/chat_core.h" #include "../chat/chat_core.h"
#include "../chat/chat_event.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; return -1;
} }
if (dm_verify_message(inst, inst->node_id, msg, len) != 0) 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; uint64_t conv, seq, author;
memcpy(&conv, msg, 8); memcpy(&conv, msg, 8);
memcpy(&seq, msg + 8, 8); memcpy(&seq, msg + 8, 8);
memcpy(&author, msg + 24, 8); memcpy(&author, msg + 24, 8);
char conv_id[64]; char conv_id[64];
snprintf(conv_id, sizeof(conv_id), "%llu", (unsigned long long)conv); snprintf(conv_id, sizeof(conv_id), "%llu", (unsigned long long)conv);
if (deleted) goto receipt;
struct dm_conv c; struct dm_conv c;
int created = dm_conv_load(dm, conv_id, &c) != 0; int created = dm_conv_load(dm, conv_id, &c) != 0;
if (created) { if (created) {
@@ -490,10 +494,12 @@ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len
uint64_t ts; uint64_t ts;
memcpy(&ts, msg + 16, 8); memcpy(&ts, msg + 16, 8);
if (ts > c.last_ts) c.last_ts = ts; 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"); dm_exec(dm, "ROLLBACK");
return -1; return -1;
} }
receipt:
memcpy(receipt, "DMACK001", 8); memcpy(receipt, "DMACK001", 8);
memcpy(receipt + 8, msg, 16); memcpy(receipt + 8, msg, 16);
memcpy(receipt + 24, &author, 8); 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); DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: receipt signing failed conv=%s seq=%llu", DM_ID, conv_id, (unsigned long long)seq);
return -1; return -1;
} }
if (deleted) return 0;
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "%s: committed receive conv=%s seq=%llu new=%d plain=%zu", DM_ID, 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); conv_id, (unsigned long long)seq, inserted, plain_len);
if (inserted) { 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; evt[0] = (uint8_t)cl;
memcpy(evt + 1, conv_id, cl); memcpy(evt + 1, conv_id, cl);
memcpy(evt + 1 + cl, &author, 8); 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); 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, 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); (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); (unsigned long long)c.group_id);
} }
if (dm_history_show(inst, conv_id)) return -1;
/* событие GUI (всегда, чтобы GUI мог открыть/выделить беседу) */ /* событие GUI (всегда, чтобы GUI мог открыть/выделить беседу) */
{ {
size_t cl = strlen(conv_id); size_t cl = strlen(conv_id);
uint8_t evt[1 + 64]; uint8_t evt[1 + 64 + 8];
evt[0] = (uint8_t)cl; evt[0] = (uint8_t)cl;
memcpy(evt + 1, conv_id, 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; 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); DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: send conversation missing/seq exhausted conv=%s", DM_ID, conv_id);
return -1; 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 seq = c.last_out_seq + 1;
uint64_t ts = (uint64_t)(ntp_time_get_us(inst) / 1000); uint64_t ts = (uint64_t)(ntp_time_get_us(inst) / 1000);
uint8_t* body = NULL; uint8_t* body = NULL;
@@ -815,7 +834,7 @@ int dm_core_init(struct UTUN_INSTANCE* inst) {
inst->dm = dm; inst->dm = dm;
dm->inst = inst; dm->inst = inst;
dm->db = inst->topo_sqlite_db; 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; if (etcp_router_bind(inst, ETCP_RT_ID_DM, dm_recv_cb) != 0) goto fail;
dm->initialized = 1; dm->initialized = 1;
dm->timer = uasync_set_timeout(inst->ua, 10000, dm, dm_tick, "dm_outbox"); 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; int failed = strbuf_addf(&json, "[") < 0;
sqlite3_stmt* st = NULL; sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(dm->db, if (sqlite3_prepare_v2(dm->db,
"SELECT conv_id, peer_node_id, COALESCE(peer_name,''), last_ts, last_out_seq, last_in_seq" "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 ORDER BY last_ts DESC LIMIT 200", " 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) { -1, &st, NULL) == SQLITE_OK) {
int first = 1; int first = 1;
while (sqlite3_step(st) == SQLITE_ROW) { 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)); json_escape(name ? name : "", esc_name, sizeof(esc_name));
if (strbuf_addf(&json, if (strbuf_addf(&json,
"%s{\"conv_id\":\"%s\",\"peer_node_id\":%llu,\"peer_name\":\"%s\"," "%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, first ? "" : ",", conv_id, (unsigned long long)peer,
esc_name, (unsigned long long)last_ts, 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; first = 0;
} }
if (sqlite3_errcode(dm->db) != SQLITE_DONE && sqlite3_errcode(dm->db) != SQLITE_OK) failed = 1; 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; if (!req) return;
struct UTUN_INSTANCE* inst = req->inst; struct UTUN_INSTANCE* inst = req->inst;
char conv[64]; strncpy(conv, req->conv_id, sizeof(conv) - 1); conv[sizeof(conv) - 1] = '\0'; 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); u_free(arg);
if (rc != 0) if (rc != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: dm_send_trampoline — dm_send failed conv=%s", 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; if (dm && dm->invite == req) dm->invite = NULL;
uint64_t conv = dm_derive_conv_id(req->inst->node_id, req->peer_node_id); uint64_t conv = dm_derive_conv_id(req->inst->node_id, req->peer_node_id);
int32_t status = result; 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, &req->request_id, 8); memcpy(event + 8, &status, 4);
memcpy(event + 12, &req->peer_node_id, 8); memcpy(event + 20, &conv, 8); 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", 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->request_id, (unsigned long long)req->peer_node_id,
(unsigned long long)req->channel_id, result); (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]; char conv[64];
snprintf(conv, sizeof(conv), "%llu", (unsigned long long)dm_derive_conv_id(req->inst->node_id, req->peer_node_id)); 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); size_t bytes = strlen(link);
sqlite3_stmt* st = NULL; sqlite3_stmt* st = NULL;
char channel[64], payload[DM_DATA_MAX]; 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; 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", 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); (unsigned long long)req->request_id, (unsigned long long)req->peer_node_id, req->source_ch_id, channel, conv);
dm->invite = req; dm->invite = req;
@@ -1154,6 +1182,25 @@ void dm_invite_trampoline(void* arg) {
if (!req->pending) dm_invite_ready(req, CHAT_JOIN_ERROR, NULL); 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-поток. */ /* Единая BGP-проверка для кнопки PM и запуска звонка; только uasync-поток. */
int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id) { 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); struct dm_state* dm = dm_of(inst);
+7 -1
View File
@@ -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, int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content_type,
const uint8_t* data, uint32_t data_len); 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}] */ /* Список бесед в 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); 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 dm_send_req {
struct UTUN_INSTANCE* inst; struct UTUN_INSTANCE* inst;
char conv_id[64]; char conv_id[64];
uint64_t generation; /* 0: захватить при входе в uasync, до любой фоновой работы. */
uint32_t data_len; uint32_t data_len;
uint8_t* data; /* data_len байт (выделяется в хвосте структуры) */ uint8_t* data; /* data_len байт (выделяется в хвосте структуры) */
}; };
@@ -127,7 +133,7 @@ enum dm_invite_result {
}; };
struct dm_invite_req { struct dm_invite_req {
struct UTUN_INSTANCE* inst; 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 source_ch_id[64]; /* общая группа для новой PM; из шапки PM может быть пустой */
char peer_name[128]; char peer_name[128];
int cancel; int cancel;
+177
View File
@@ -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 <sqlite3.h>
#include <openssl/sha.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
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;
}
+21
View File
@@ -0,0 +1,21 @@
/* Локальная история DM: поколения, скрытые беседы и точные отпечатки удалённых сообщений.
* Все операции выполняются в uasync. Удаление сохраняет счётчики dm_conversations. */
#ifndef DM_HISTORY_H
#define DM_HISTORY_H
#include <stdint.h>
#include <stddef.h>
#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
+189 -5
View File
@@ -12,6 +12,7 @@
#include "../../lib/debug_config.h" #include "../../lib/debug_config.h"
#include "../../lib/json_flat.h" #include "../../lib/json_flat.h"
#include "dm_media_priv.h" #include "dm_media_priv.h"
#include "dm_history.h"
#include "dm_crypto.h" #include "dm_crypto.h"
#include "dm_mailbox.h" #include "dm_mailbox.h"
#include "dm_mailbox_media.h" #include "dm_mailbox_media.h"
@@ -34,6 +35,7 @@ struct dm_media_state {
struct dm_file_job* jobs; struct dm_file_job* jobs;
void* timer; void* timer;
int closing; int closing;
int cleanup_active;
}; };
struct dm_file_job { struct dm_file_job {
@@ -48,6 +50,8 @@ struct dm_file_job {
int remove_source; int remove_source;
char input[1024], cipher[1024], plain[1024], temp[1024], output[1024]; char input[1024], cipher[1024], plain[1024], temp[1024], output[1024];
int error; int error;
uint64_t generation;
unsigned held;
}; };
sqlite3_stmt* dm_media_prepare(struct UTUN_INSTANCE* inst, const char* sql) { 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; 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.generation<h.generation AND f.busy<=0 AND f.retry_at<=? ORDER BY f.retry_at,f.path LIMIT 1");
if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)ntp_time_get_seconds(media->inst));
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 не даёт полномочий. */ /* Подписанная роль и живые мемберы в одной области; тип NAT не даёт полномочий. */
int dm_media_scope(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node, int dm_media_scope(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node,
const char* role, uint64_t author, uint64_t recipient) { 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; if (!st) return -1;
sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC);
int rc = sqlite3_step(st); 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))) { 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); DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: receipt does not match outgoing artifact rc=%d", rc);
sqlite3_finalize(st); sqlite3_finalize(st);
@@ -325,10 +467,29 @@ static struct dm_file_job* dm_job_find(struct dm_media_state* media, const uint8
return NULL; 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) { static void dm_job_free(struct dm_file_job* job) {
struct dm_file_job** p = &job->media->jobs; struct dm_file_job** p = &job->media->jobs;
while (*p && *p != job) p = &(*p)->next; while (*p && *p != job) p = &(*p)->next;
if (*p) *p = job->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)); OPENSSL_cleanse(job->key, sizeof(job->key));
u_free(job); u_free(job);
} }
@@ -425,6 +586,10 @@ static void dm_file_done(void* arg, int error) {
} }
int custody = job->work == DM_FILE_CUSTODY; int custody = job->work == DM_FILE_CUSTODY;
if (error || job->error || media->closing) goto fail; 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) { if (job->work == DM_FILE_PREPARE) {
uint8_t data[DM_MEDIA_DESC_SIZE + ATTACHMENT_WIRE_MAX]; 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); 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, ".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; } dm_file_path(media, custody, job->id, custody ? ".cipher" : ".file", job->output, sizeof(job->output))) { u_free(job); return; }
if (!custody) { 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=?"); 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); sqlite3_bind_int64(st, 1, (sqlite3_int64)author);
int rc = sqlite3_step(st); int rc = sqlite3_step(st);
uint8_t pub[32]; 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); sqlite3_finalize(st);
if (!found || dm_derive_content_key(media->inst->my_keys.private_key, pub, job->key)) { 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); DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: target key unavailable author=%llu", (unsigned long long)author);
u_free(job); dm_job_free(job);
return; 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)); struct dm_file_job* job = u_calloc(1, sizeof(*job));
if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: preparation allocation failed"); return -1; } 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->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->conv, sizeof(job->conv), "%s", conv); job->info = *info; job->remove_source = remove_source;
snprintf(job->input, sizeof(job->input), "%s", path); snprintf(job->input, sizeof(job->input), "%s", path);
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT peer_x25519 FROM dm_conversations WHERE conv_id=?"); 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); 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; 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; 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); 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); 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) { void dm_send_file_trampoline(void* arg) {
struct dm_file_req* req = arg; struct dm_file_req* req = arg;
if (!req) return; 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}; 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) { if (req->remove_source) {
media_async_submit(req->inst ? req->inst->media_async : NULL, req->inst ? req->inst->ua : NULL, media_async_submit(req->inst ? req->inst->media_async : NULL, req->inst ? req->inst->ua : NULL,
dm_source_cleanup, req, dm_source_cleaned, req); 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]; uint8_t receipt[DM_RECEIPT_SIZE];
if (dm_accept_message(inst, p, len, receipt)) return; if (dm_accept_message(inst, p, len, receipt)) return;
dm_mailbox_receipt(inst, receipt); 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"); 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; if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)holder); sqlite3_bind_int64(st, 2, (sqlite3_int64)expiry); 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); uint64_t now = ntp_time_get_seconds(media->inst);
dm_files_tick(media, now); dm_files_tick(media, now);
dm_custody_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"); 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"); 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; if (!inst || !inst->dm_media) return;
dm_media_quiesce(inst); dm_media_quiesce(inst);
struct dm_media_state* media = inst->dm_media; 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"); DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: destroy before media_async workers drained");
return; return;
} }
+8
View File
@@ -37,6 +37,7 @@ int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path,
struct dm_file_req { struct dm_file_req {
struct UTUN_INSTANCE* inst; struct UTUN_INSTANCE* inst;
char conv[64]; char conv[64];
uint64_t generation;
char path[1024]; char path[1024];
struct attachment_info info; struct attachment_info info;
int remove_source; /* Только принадлежащий приложению временный файл. */ 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_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); 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. Все пакеты подписаны. */ /* Общие операции control для endpoint/M и mailbox/S. Все пакеты подписаны. */
int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer,
uint8_t cmd, const uint8_t* body, size_t len); uint8_t cmd, const uint8_t* body, size_t len);
+29 -3
View File
@@ -11,6 +11,7 @@
#include "../chat/chat_core.h" #include "../chat/chat_core.h"
#include "../chat/chat_event.h" #include "../chat/chat_event.h"
#include "../dm/dm_media.h" #include "../dm/dm_media.h"
#include "../dm/dm_history.h"
#include "../video/video.h" #include "../video/video.h"
#include "media_async.h" #include "media_async.h"
#include "voice_file.h" #include "voice_file.h"
@@ -57,18 +58,29 @@ done:
} }
/* Один выбор транспорта после общей подготовки; групповой индекс и PM crypto не смешиваются. */ /* Один выбор транспорта после общей подготовки; групповой индекс и 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) { static void attachment_publish(struct attachment_send_req* req, int error) {
attachment_release_pcm(req); /* Submit мог завершиться ошибкой до запуска work. */ 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) { if (req->temporary_source && remove(req->source) && errno != ENOENT) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: abandoned source cleanup failed path=%s", req->source); DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: abandoned source cleanup failed path=%s", req->source);
error = -1; error = -1;
} }
if (error || req->error || attachment_validate(&req->info)) { 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); 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); 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); 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); 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", 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)); struct dm_file_req* file = u_calloc(1, sizeof(*file));
if (file) { if (file) {
file->inst = req->inst; file->info = req->info; file->remove_source = 1; 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->conv, sizeof(file->conv), "%s", req->target);
snprintf(file->path, sizeof(file->path), "%s", req->output); snprintf(file->path, sizeof(file->path), "%s", req->output);
dm_send_file_trampoline(file); 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"); 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) { 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) { static void attachment_prepare_done(void* arg, int error) {
struct attachment_send_req* req = arg; 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 (!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; if (!video_transcode_start(req->inst->media_async, req->inst->ua, req->source, req->output, attachment_video_done, req)) return;
error = -1; error = -1;
@@ -139,6 +153,10 @@ void attachment_send_trampoline(void* arg) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: invalid target/stopped service"); DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: invalid target/stopped service");
attachment_publish(req, -1); return; 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]; uint8_t uuid[16]; char hex[33];
if (RAND_bytes(uuid, sizeof(uuid)) != 1) { if (RAND_bytes(uuid, sizeof(uuid)) != 1) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: UUID generation failed"); attachment_publish(req, -1); return; 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, if (!req->info.name[0]) snprintf(req->info.name, sizeof(req->info.name), "%s.%s", hex,
req->info.kind == ATTACHMENT_VOICE ? "opus" : "mp4"); 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); 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); media_async_submit(req->inst->media_async, req->inst->ua, attachment_prepare_work, req, attachment_prepare_done, req);
} }
+2
View File
@@ -17,6 +17,8 @@ struct attachment_send_req {
struct UTUN_INSTANCE* inst; struct UTUN_INSTANCE* inst;
char target[64]; /* Десятичный channel_id либо conv_id без префикса dm:, зафиксированный до подготовки. */ char target[64]; /* Десятичный channel_id либо conv_id без префикса dm:, зафиксированный до подготовки. */
int is_dm; int is_dm;
uint64_t dm_generation; /* Захватывается до подготовки и сохраняется во всех её стадиях. */
unsigned dm_paths_held;
uint8_t reply_to[32]; /* Оригинал канального ответа, зафиксированный до подготовки. */ uint8_t reply_to[32]; /* Оригинал канального ответа, зафиксированный до подготовки. */
uint64_t request_id; /* CHAT_EVT_MSG_SUBMITTED после публикации либо ошибки. */ uint64_t request_id; /* CHAT_EVT_MSG_SUBMITTED после публикации либо ошибки. */
struct attachment_info info; struct attachment_info info;
+5
View File
@@ -143,6 +143,7 @@ check_PROGRAMS += \
test_group_connect \ test_group_connect \
test_etcp_router \ test_etcp_router \
test_dm_worker \ test_dm_worker \
test_dm_history \
test_chat_join \ test_chat_join \
test_radio \ test_radio \
test_bgp_route_exchange \ 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_CFLAGS = $(test_dm_e2e_CFLAGS)
test_dm_worker_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) 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_call_SOURCES = test_call.c
test_route_tx_stats_SOURCES = test_route_tx_stats.c test_route_tx_stats_SOURCES = test_route_tx_stats.c
test_route_tx_stats_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_route_tx_stats_LDADD = $(LIBUTUN) $(COMMON_LIBS)
+3 -3
View File
@@ -186,11 +186,11 @@ int main(void) {
REQUIRE(!utun_bridge_photo_send(channel, file, directory, 24, 24, original, 5) && wait_result(5) == 1); REQUIRE(!utun_bridge_photo_send(channel, file, directory, 24, 24, original, 5) && wait_result(5) == 1);
REQUIRE(check_reply(channel, hex, "image/jpeg")); REQUIRE(check_reply(channel, hex, "image/jpeg"));
/* Мост принимает уже подготовленный MP4: содержимое не декодируется повторно. */ /* Мост принимает уже подготовленный 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(check_reply(channel, hex, "video/mp4"));
REQUIRE(access(file, F_OK) != 0); 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}; int16_t pcm[960] = {0};
for (int i = 0; i < 30; i++) { REQUIRE(!utun_bridge_voice_feed(pcm, 960)); uasync_poll(async, 20); } for (int i = 0; i < 30; i++) { REQUIRE(!utun_bridge_voice_feed(pcm, 960)); uasync_poll(async, 20); }
/* Poll может вернуться немедленно по событию; длительность измеряется CLOCK_MONOTONIC. */ /* Poll может вернуться немедленно по событию; длительность измеряется CLOCK_MONOTONIC. */
@@ -198,7 +198,7 @@ int main(void) {
int duration = 0; int duration = 0;
REQUIRE(!utun_bridge_voice_stop(&duration) && duration >= 500 && wait_result(7) == 1); REQUIRE(!utun_bridge_voice_stop(&duration) && duration >= 500 && wait_result(7) == 1);
REQUIRE(check_reply(channel, hex, "audio/opus")); 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); uasync_poll(async, 0); REQUIRE(results[8] == 0);
REQUIRE(utun_bridge_voice_stop(&duration) < 0); REQUIRE(utun_bridge_voice_stop(&duration) < 0);
REQUIRE(!utun_bridge_attachment_send(channel, "/nonexistent/android-reply", directory, original, 9) && wait_result(9) == -1); REQUIRE(!utun_bridge_attachment_send(channel, "/nonexistent/android-reply", directory, original, 9) && wait_result(9) == -1);
+213
View File
@@ -0,0 +1,213 @@
/* Настоящие DM/SQLite/workers: удаление, повторы, nonce, rollback и поколения. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/sha.h>
#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;
}
+10 -1
View File
@@ -15,6 +15,7 @@
#include "../src/chat/invite_link.h" #include "../src/chat/invite_link.h"
#include "../src/dm/dm_core.h" #include "../src/dm/dm_core.h"
#include "../src/dm/dm_crypto.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_node_sqlite.h"
#include "../src/routing_layer/topo_group.h" #include "../src/routing_layer/topo_group.h"
#include "../src/transport_layer/secure_channel.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) { 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; uint64_t id, peer, conv; int32_t result;
memcpy(&id, data, 8); memcpy(&result, data + 8, 4); memcpy(&id, data, 8); memcpy(&result, data + 8, 4);
memcpy(&peer, data + 12, 8); memcpy(&conv, data + 20, 8); 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"); CHECK(wait_result(ua, 2, DM_INVITE_BUSY), "busy request retains first operation");
request(1, target, source_id, 1); request(1, target, source_id, 1);
CHECK(wait_result(ua, 1, DM_INVITE_CANCELLED) && !message_count(), "cancel before ACK sends nothing"); 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); request(3, 0, source_id, 0);
CHECK(wait_result(ua, 3, DM_INVITE_BUILD_ERROR) && !message_count(), "invalid group sends nothing"); CHECK(wait_result(ua, 3, DM_INVITE_BUILD_ERROR) && !message_count(), "invalid group sends nothing");
+17
View File
@@ -585,6 +585,23 @@ int main(int argc, char** argv) {
printf("=== test_radio ===\n"); fflush(stdout); 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"); { TEST("radio: members + mute relay + speaker FIN/timeout/off + withdraw"); {
struct UASYNC* ua = uasync_create(); struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; } if (!ua) { FAIL("uasync_create failed"); return 1; }
@@ -218,9 +218,10 @@ class MainActivity : AppCompatActivity() {
var showJoinDialog by rememberSaveable { mutableStateOf(false) } var showJoinDialog by rememberSaveable { mutableStateOf(false) }
var inviteChannelId by rememberSaveable { mutableStateOf<String?>(null) } var inviteChannelId by rememberSaveable { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) } var videoMessage by remember { mutableStateOf<Message?>(null) }
var videoTargetGeneration by rememberSaveable { mutableStateOf(0L) }
var videoTargetId by rememberSaveable { mutableStateOf<String?>(null) } var videoTargetId by rememberSaveable { mutableStateOf<String?>(null) }
val videoTarget = videoTargetId?.let { 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<Message?>(null) } var photoMessage by remember { mutableStateOf<Message?>(null) }
var photoTargetId by rememberSaveable { mutableStateOf<String?>(null) } var photoTargetId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -358,17 +359,23 @@ class MainActivity : AppCompatActivity() {
Screen.ChannelList -> { Screen.ChannelList -> {
val channels by vm.channels.collectAsState() val channels by vm.channels.collectAsState()
val dmConversations by vm.dmConversations.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 stuck by vm.isStuck.collectAsState()
val cpuLoad by vm.cpuLoad.collectAsState() val cpuLoad by vm.cpuLoad.collectAsState()
ChannelListScreen( ChannelListScreen(
channels = channels, channels = channels,
dmConversations = dmConversations, dmConversations = dmConversations,
onDeleteDm = vm::deleteDmConversation,
deletingDm = dmDeleting,
dmDeleteFailed = dmDeleteFailed,
onDismissDmDeleteError = vm::dismissDmDeleteError,
connected = connected, connected = connected,
isStuck = stuck, isStuck = stuck,
cpuLoad = cpuLoad, cpuLoad = cpuLoad,
onCpuLoadVisible = vm::setCpuLoadVisible, onCpuLoadVisible = vm::setCpuLoadVisible,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, 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") }, onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true }, onJoinChannel = { showJoinDialog = true },
onInviteClick = showInvite, onInviteClick = showInvite,
@@ -387,7 +394,6 @@ class MainActivity : AppCompatActivity() {
messages = messages, messages = messages,
connected = connected, connected = connected,
viewModel = vm, viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList }, onMembersClick = { screen = Screen.MemberList },
onFiles = { owner -> onFiles = { owner ->
filesVm.open(channel.id, owner); filesReturnScreen = Screen.Chat; screen = Screen.Files filesVm.open(channel.id, owner); filesReturnScreen = Screen.Chat; screen = Screen.Files
@@ -401,7 +407,8 @@ class MainActivity : AppCompatActivity() {
onRecordVideo = { onRecordVideo = {
if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen
val target = ConversationTarget.Channel(channel.id) 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 = { onCapturePhoto = {
if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen
@@ -450,10 +457,10 @@ class MainActivity : AppCompatActivity() {
conv = dm, conv = dm,
messages = dmMessages, messages = dmMessages,
viewModel = vm, viewModel = vm,
onSendMessage = { vm.sendDmMessage(it) },
onRecordVideo = { onRecordVideo = {
val target = ConversationTarget.Pm(dm.convId) val target = ConversationTarget.Pm(dm.convId, dm.generation)
videoTargetId = target.nativeId; vm.captureCameraReply(target); screen = Screen.VideoRecord videoTargetGeneration = target.generation; videoTargetId = target.nativeId
vm.captureCameraReply(target); screen = Screen.VideoRecord
}, },
onPlayVideo = { videoMessage = it }, onPlayVideo = { videoMessage = it },
onBack = { vm.closeDm(); screen = Screen.ChannelList } onBack = { vm.closeDm(); screen = Screen.ChannelList }
@@ -34,11 +34,11 @@ class AudioRecorderManager {
PackageManager.PERMISSION_GRANTED 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 (isRecording) return false
if (!hasPermission()) return false if (!hasPermission()) return false
val ok = NativeLib.voiceStart(channelId, replyTo, requestId) val ok = NativeLib.voiceStart(channelId, replyTo, requestId, generation)
if (!ok) { if (!ok) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed") LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed")
return false return false
@@ -57,7 +57,7 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va
val peersOnline: Int = 0) val peersOnline: Int = 0)
data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "", 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) } 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, 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 natType = buf.get().toInt() and 0xFF
val storage = buf.get().toInt() != 0 val storage = buf.get().toInt() != 0
val nameBytes = ByteArray(64); buf.get(nameBytes) 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 avatarServer = buf.getLong()
val avatarBytes = ByteArray(70); buf.get(avatarBytes) 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 val storageRole = buf.get().toInt() != 0
return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name, avatarServer, avatarFile, storageRole) 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) = fun dmStart(nodeId: Long, sourceChId: String, peerName: String) =
NativeLib.dmStart(nodeId, sourceChId, peerName) 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<DmConversation> { fun parseDmConversations(json: String): List<DmConversation> {
val list = mutableListOf<DmConversation>() val list = mutableListOf<DmConversation>()
@@ -607,7 +607,7 @@ class ChatRepository {
convId = obj.getString("conv_id"), convId = obj.getString("conv_id"),
peerNodeId = obj.optLong("peer_node_id", 0), peerNodeId = obj.optLong("peer_node_id", 0),
peerName = obj.optString("peer_name", ""), 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) } } catch (e: Exception) { Log.w("utun-gui", "getDmConversations error", e) }
@@ -4,6 +4,6 @@ package com.utun.chat.data
sealed class ConversationTarget { sealed class ConversationTarget {
abstract val id: String abstract val id: String
data class Channel(override val id: String) : ConversationTarget() 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 val nativeId: String get() = if (this is Pm) "dm:$id" else id
} }
@@ -172,8 +172,8 @@ object NativeLib {
fun requestLocalSockets() = nativeRequestLocalSockets() fun requestLocalSockets() = nativeRequestLocalSockets()
/* ── Voice recording ── */ /* ── Voice recording ── */
fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean = fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
!StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId) !StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId, generation)
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples) fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
fun voiceStop(): Int = nativeVoiceStop() fun voiceStop(): Int = nativeVoiceStop()
fun voiceCancel() { nativeVoiceCancel() } fun voiceCancel() { nativeVoiceCancel() }
@@ -195,8 +195,8 @@ object NativeLib {
/* ── Video message ── */ /* ── Video message ── */
fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int, fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int,
replyTo: String = "", requestId: Long = 0): Boolean = replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
nativeVideoSend(channelId, filePath, durationMs, width, height, messageIdBytes(replyTo), requestId) nativeVideoSend(channelId, filePath, durationMs, width, height, messageIdBytes(replyTo), requestId, generation)
/* Live media используют отдельную ограниченную очередь, минуя чатовый event bus. */ /* Live media используют отдельную ограниченную очередь, минуя чатовый event bus. */
fun streamOpen(id: Long, group: Long, source: Long = 0, width: Int = 0, height: Int = 0, fun streamOpen(id: Long, group: Long, source: Long = 0, width: Int = 0, height: Int = 0,
@@ -302,14 +302,15 @@ object NativeLib {
/* ── DM (личные сообщения) ── */ /* ── DM (личные сообщения) ── */
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) } 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 = fun dmInvite(requestId: Long, peer: Long, source: String, channelId: Long, name: String, cancel: Boolean = false): Boolean =
nativeDmInvite(requestId, peer, source, channelId, name, cancel) nativeDmInvite(requestId, peer, source, channelId, name, cancel)
fun isChannelMember(channelId: String, nodeId: Long): Boolean = nativeIsChannelMember(channelId, nodeId) 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 dmRequestConversations() { nativeDmRequestConversations() }
fun dmRequestCallRoute(convId: String) { nativeDmRequestCallRoute(convId) } fun dmRequestCallRoute(convId: String) { nativeDmRequestCallRoute(convId) }
fun dmRequestMessages(convId: String) { nativeDmRequestMessages(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 nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean private external fun nativeStart(configText: String): Boolean
@@ -367,7 +368,7 @@ object NativeLib {
private external fun nativeRequestLocalSockets() private external fun nativeRequestLocalSockets()
/* ── Voice recording JNI ── */ /* ── 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 nativeVoiceFeed(samples: ShortArray): Boolean
private external fun nativeVoiceStop(): Int private external fun nativeVoiceStop(): Int
private external fun nativeVoiceCancel() private external fun nativeVoiceCancel()
@@ -388,7 +389,7 @@ object NativeLib {
/* ── Video message JNI ── */ /* ── Video message JNI ── */
private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int, 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 ── */ /* ── Photo message JNI ── */
private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int, private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int,
@@ -433,12 +434,13 @@ object NativeLib {
private external fun nativeRadioTalkEnd(groupId: Long) private external fun nativeRadioTalkEnd(groupId: Long)
/* ── DM JNI ── */ /* ── 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 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 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 nativeIsChannelMember(channelId: String, nodeId: Long): Boolean
private external fun nativeDmRequestConversations() private external fun nativeDmRequestConversations()
private external fun nativeDmRequestCallRoute(convId: String) private external fun nativeDmRequestCallRoute(convId: String)
private external fun nativeDmRequestMessages(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
} }
@@ -31,13 +31,13 @@ private data class SelectedAttachment(val target: ConversationTarget, val uri: U
/** Общий ввод и вложения; URI принадлежит экрану, адресат операции неизменяем. */ /** Общий ввод и вложения; URI принадлежит экрану, адресат операции неизменяем. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, onSendText: (String) -> Unit, fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
onRecordVideo: () -> Unit, onCapturePhoto: (() -> Unit)? = null) { onRecordVideo: () -> Unit, onCapturePhoto: (() -> Unit)? = null) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val activeTarget by rememberUpdatedState(target) val activeTarget by rememberUpdatedState(target)
var text by rememberSaveable(target.nativeId, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } var text by rememberSaveable(target, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
var textRevision by rememberSaveable(target.nativeId) { mutableStateOf(0L) } var textRevision by rememberSaveable(target) { mutableStateOf(0L) }
val replies by viewModel.replies.collectAsState() val replies by viewModel.replies.collectAsState()
val reply = if (target is ConversationTarget.Channel) replies[target.id] else null val reply = if (target is ConversationTarget.Channel) replies[target.id] else null
val sendFailed by viewModel.messageSendFailed.collectAsState() val sendFailed by viewModel.messageSendFailed.collectAsState()
@@ -126,7 +126,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
if (sentText.isNotBlank()) { if (sentText.isNotBlank()) {
textSaved = if (target is ConversationTarget.Channel) textSaved = if (target is ConversationTarget.Channel)
viewModel.sendChannelText(target, sentText, capturedReply).await() 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 (textSaved && textRevision == revision) { text = TextFieldValue(); textRevision++ }
} }
if (attachmentSaved && textSaved) viewModel.finishReply(capturedReply) if (attachmentSaved && textSaved) viewModel.finishReply(capturedReply)
@@ -49,6 +49,10 @@ fun ChannelListScreen(
onCpuLoadVisible: (Boolean) -> Unit = {}, onCpuLoadVisible: (Boolean) -> Unit = {},
onSelectChannel: (Channel) -> Unit, onSelectChannel: (Channel) -> Unit,
onSelectDm: (DmConversation) -> Unit = {}, onSelectDm: (DmConversation) -> Unit = {},
onDeleteDm: (DmConversation) -> Unit,
deletingDm: Set<String> = emptySet(),
dmDeleteFailed: Boolean = false,
onDismissDmDeleteError: () -> Unit,
onCreateChannel: (String) -> Unit, onCreateChannel: (String) -> Unit,
onJoinChannel: () -> Unit, onJoinChannel: () -> Unit,
onInviteClick: (Channel) -> Unit, onInviteClick: (Channel) -> Unit,
@@ -65,6 +69,8 @@ fun ChannelListScreen(
var menuChannelId by remember { mutableStateOf<String?>(null) } var menuChannelId by remember { mutableStateOf<String?>(null) }
var optionsChannel by remember { mutableStateOf<Channel?>(null) } var optionsChannel by remember { mutableStateOf<Channel?>(null) }
var deleteConfirmChannel by remember { mutableStateOf<Channel?>(null) } var deleteConfirmChannel by remember { mutableStateOf<Channel?>(null) }
var menuDmId by remember { mutableStateOf<String?>(null) }
var deleteConfirmDm by remember { mutableStateOf<DmConversation?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}") 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))) Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFFD2A8FF)))
Row(Modifier.fillMaxWidth().padding(12.dp)) { Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { 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)) PeerStatusDot(dm.online, Modifier.align(Alignment.TopStart).offset(x = (-4).dp, y = (-6).dp))
} }
Spacer(Modifier.width(10.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) { if (showCreateDialog) {
CreateChannelDialog( CreateChannelDialog(
onDismiss = { showCreateDialog = false }, onDismiss = { showCreateDialog = false },
@@ -83,7 +83,6 @@ fun ChatScreen(
messages: List<Message>, messages: List<Message>,
connected: Boolean, connected: Boolean,
viewModel: ChatViewModel, viewModel: ChatViewModel,
onSendMessage: (String) -> Unit,
onMembersClick: () -> Unit, onMembersClick: () -> Unit,
onFiles: (Long) -> Unit = {}, onFiles: (Long) -> Unit = {},
onInvite: () -> 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)
} }
} }
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatViewModel, fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatViewModel,
onSendMessage: (String) -> Unit, onBack: () -> Unit, onBack: () -> Unit,
onRecordVideo: () -> Unit, onPlayVideo: (Message) -> 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 playback by viewModel.playbackState.collectAsState()
val callAvailable by viewModel.dmCallAvailable.collectAsState() val callAvailable by viewModel.dmCallAvailable.collectAsState()
val callState by viewModel.callState.collectAsState() val callState by viewModel.callState.collectAsState()
@@ -87,6 +87,6 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
onOpenFile = { if (it.filePath.isNotEmpty()) { pathToSave = it.filePath; save.launch(it.text) } }, onOpenFile = { if (it.filePath.isNotEmpty()) { pathToSave = it.filePath; save.launch(it.text) } },
onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink) onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink)
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
ConversationComposer(target, viewModel, onSendMessage, onRecordVideo) ConversationComposer(target, viewModel, onRecordVideo)
} }
} }
@@ -65,7 +65,10 @@ fun MemberListScreen(
var showEditDialog by remember { mutableStateOf(false) } var showEditDialog by remember { mutableStateOf(false) }
var editMember by remember { mutableStateOf<ChatMember?>(null) } var editMember by remember { mutableStateOf<ChatMember?>(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 memberLinks by viewModel.memberLinks.collectAsState()
val localSockets by viewModel.localSockets.collectAsState() val localSockets by viewModel.localSockets.collectAsState()
@@ -170,6 +173,7 @@ fun MemberListScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MemberListItem( private fun MemberListItem(
member: ChatMember, member: ChatMember,
@@ -212,27 +216,27 @@ private fun MemberListItem(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (member.isSuper) { if (member.isSuper) {
Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp)) 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) { if (member.isAdmin) {
Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp)) 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) { if (member.isModer) {
Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp)) 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) { if (member.isStorage) {
Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) 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) { if (member.radioActive) {
PortableRadioIcon(stringResource(R.string.radio_active), Modifier.size(16.dp)) PortableRadioIcon(stringResource(R.string.radio_active), Modifier.size(16.dp))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(2.dp))
} }
if (!member.isVerified) { if (!member.isVerified) {
Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp)) 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(
text = member.name, 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) + 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.radioMuted) " · " + stringResource(R.string.radio_member_muted) else ""
if (member.isSelf) { CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { val actionModifier = Modifier.size(width = 36.dp, height = 48.dp)
RadioSpeakerIcon(speaking, false, radioStatus) 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)) }
} }
} }
} }
@@ -137,8 +137,10 @@ class ChatViewModel : ViewModel() {
data class AttachmentPreparation(val jobs: Set<String> = emptySet(), val failed: Boolean = false) data class AttachmentPreparation(val jobs: Set<String> = emptySet(), val failed: Boolean = false)
private val _attachmentPreparations = MutableStateFlow<Map<String, AttachmentPreparation>>(emptyMap()) private val _attachmentPreparations = MutableStateFlow<Map<String, AttachmentPreparation>>(emptyMap())
val attachmentPreparations: StateFlow<Map<String, AttachmentPreparation>> = _attachmentPreparations val attachmentPreparations: StateFlow<Map<String, AttachmentPreparation>> = _attachmentPreparations
private val discardedPreparationJobs = mutableSetOf<String>()
private fun preparationState(target: String, job: String, state: Int) { private fun preparationState(target: String, job: String, state: Int) {
if (job in discardedPreparationJobs) { if (state != 0) discardedPreparationJobs.remove(job); return }
_attachmentPreparations.update { map -> _attachmentPreparations.update { map ->
val previous = map[target] ?: AttachmentPreparation() val previous = map[target] ?: AttachmentPreparation()
val value = if (state == 0) AttachmentPreparation(previous.jobs + job) val value = if (state == 0) AttachmentPreparation(previous.jobs + job)
@@ -296,6 +298,12 @@ class ChatViewModel : ViewModel() {
private val _dmConversations = MutableStateFlow<List<DmConversation>>(emptyList()) private val _dmConversations = MutableStateFlow<List<DmConversation>>(emptyList())
val dmConversations: StateFlow<List<DmConversation>> = _dmConversations val dmConversations: StateFlow<List<DmConversation>> = _dmConversations
private var nextDmDeleteRequest = System.nanoTime().ushr(1)
private val dmDeleteRequests = mutableMapOf<Long, Pair<String, Long>>()
private val _dmDeleting = MutableStateFlow<Set<String>>(emptySet())
val dmDeleting: StateFlow<Set<String>> = _dmDeleting
private val _dmDeleteFailed = MutableStateFlow(false)
val dmDeleteFailed: StateFlow<Boolean> = _dmDeleteFailed
private val _dmMessages = MutableStateFlow<List<Message>>(emptyList()) private val _dmMessages = MutableStateFlow<List<Message>>(emptyList())
val dmMessages: StateFlow<List<Message>> = _dmMessages val dmMessages: StateFlow<List<Message>> = _dmMessages
@@ -563,7 +571,7 @@ class ChatViewModel : ViewModel() {
memberRadios.clear() memberRadios.clear()
memberRadioMutes.clear() memberRadioMutes.clear()
_members.value = _members.value.map { it.copy(radioActive = false) } _members.value = _members.value.map { it.copy(radioActive = false) }
_attachmentPreparations.value = emptyMap() _attachmentPreparations.value = emptyMap(); discardedPreparationJobs.clear()
_pmInvite.value?.let { state -> _pmInvite.value?.let { state ->
if (state.pending) _pmInvite.value = state.copy(pending = false, errorRes = R.string.invite_core_unavailable) 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) if (state.pending) _linkJoin.value = state.copy(pending = false, result = -7)
} }
LogManager.addLog("INFO", "VM", "service stopped; pending invitation UI released") 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] */ 48 -> { /* [request_id:8][result:4][peer:8][conv:8][generation:8] */
if (data == null || data.size != 28) { LogManager.addLog("ERROR", "INVITE", "invalid PM invite result"); return } 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 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 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 val state = _pmInvite.value ?: return
if (!state.pending || state.requestId != request || state.peer != peer) 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") LogManager.addLog(if (result == 0) "INFO" else "ERROR", "INVITE", "PM invite result request=$request peer=$peer result=$result")
if (result == 0) { if (result == 0) {
_pmInvite.value = null _pmInvite.value = null
openDm(conv, peer, state.name); _dmOpen.value = conv; refreshDmConversations() openDm(conv, peer, state.name, generation); _dmOpen.value = conv; refreshDmConversations()
} else { } else {
val error = when (result) { val error = when (result) {
-1 -> R.string.invite_pm_start_failed -1 -> R.string.invite_pm_start_failed
@@ -857,10 +870,10 @@ class ChatViewModel : ViewModel() {
} }
refreshChannels() 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 if (data == null || data.size < 2) return
val cl = data[0].toInt() and 0xFF 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) val convId = String(data, 1, cl)
LogManager.addLog("INFO", "VM", "DM_CONV_UPDATED conv=$convId") LogManager.addLog("INFO", "VM", "DM_CONV_UPDATED conv=$convId")
refreshDmConversations() refreshDmConversations()
@@ -868,7 +881,9 @@ class ChatViewModel : ViewModel() {
val pend = pendingStartPeer val pend = pendingStartPeer
if (pend != null) { if (pend != null) {
pendingStartPeer = 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 _dmCallAvailable.value = false
refreshDmCallRoute() refreshDmCallRoute()
refreshDmMessages() refreshDmMessages()
@@ -904,8 +919,30 @@ class ChatViewModel : ViewModel() {
_dmConversations.value = (repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList()).map { _dmConversations.value = (repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList()).map {
it.copy(online = previous[it.convId]?.online == true) it.copy(online = previous[it.convId]?.online == true)
} }
_currentDm.value?.let { current ->
_dmConversations.value.find { it.convId == current.convId }?.let { _currentDm.value = it }
}
refreshDmCallRoute() 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 -> { 46 -> {
if (data == null || data.size < 18) return if (data == null || data.size < 18) return
val length = data[0].toInt() and 255 val length = data[0].toInt() and 255
@@ -1319,9 +1356,9 @@ class ChatViewModel : ViewModel() {
fun consumeDmOpen() { _dmOpen.value = null } 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 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 _currentDm.value = dm
_dmCallAvailable.value = false _dmCallAvailable.value = false
refreshDmCallRoute() refreshDmCallRoute()
@@ -1335,13 +1372,31 @@ class ChatViewModel : ViewModel() {
_dmMessages.value = emptyList() _dmMessages.value = emptyList()
} }
fun sendDmMessage(text: String) { fun sendDmText(target: ConversationTarget.Pm, text: String): Boolean {
val r = repo ?: return val r = repo ?: return false
val dm = _currentDm.value ?: return if (text.isBlank()) return false
if (text.isBlank()) return val dm = _dmConversations.value.find { it.convId == target.id } ?: _currentDm.value?.takeIf { it.convId == target.id }
r.dmSend(dm.convId, text) 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 = "") { fun showPmInvite(peer: Long, name: String, source: String = "") {
if (peer == 0L || peer == NativeLib.getMyNodeId()) { LogManager.addLog("ERROR", "INVITE", "invalid PM recipient"); return } if (peer == 0L || peer == NativeLib.getMyNodeId()) { LogManager.addLog("ERROR", "INVITE", "invalid PM recipient"); return }
if (_pmInvite.value != null) return if (_pmInvite.value != null) return
@@ -1395,10 +1450,6 @@ class ChatViewModel : ViewModel() {
fun dismissLinkJoin() { _linkJoin.value = null } fun dismissLinkJoin() { _linkJoin.value = null }
fun consumeJoinedChannel() { _joinedChannel.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) { fun refreshMembers(chId: String) {
val r = repo ?: return val r = repo ?: return
val statuses = _nodeStatuses.value val statuses = _nodeStatuses.value
@@ -1549,7 +1600,8 @@ class ChatViewModel : ViewModel() {
val reply = replyFor(target) val reply = replyFor(target)
val request = if (target is ConversationTarget.Channel) ++nextSubmissionId to CompletableDeferred<Boolean>() else null val request = if (target is ConversationTarget.Channel) ++nextSubmissionId to CompletableDeferred<Boolean>() else null
if (request != null) submissions[request.first] = request.second 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) } request?.let { submissions.remove(it.first) }
LogManager.addLog("ERROR", "VM", "voice recording failed to start") LogManager.addLog("ERROR", "VM", "voice recording failed to start")
return return
@@ -1633,7 +1685,7 @@ class ChatViewModel : ViewModel() {
fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name, fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name,
reply: ChannelReply? = replyFor(target)): Deferred<Boolean> = viewModelScope.async { reply: ChannelReply? = replyFor(target)): Deferred<Boolean> = viewModelScope.async {
if (target is ConversationTarget.Pm) { 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 } if (!accepted) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true }
return@async accepted return@async accepted
} }
@@ -1674,7 +1726,7 @@ class ChatViewModel : ViewModel() {
} }
val queue: (Long) -> Boolean = { request -> val queue: (Long) -> Boolean = { request ->
NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third, 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) saved = if (target is ConversationTarget.Channel) submitChannel(target.id, reply, queue) else queue(0)
if (!saved) _messageSendFailed.value = true if (!saved) _messageSendFailed.value = true
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="delete_conversation">Удалить переписку</string>
<string name="delete_conversation_confirm">Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится.</string>
<string name="delete_conversation_failed">Не удалось удалить переписку. Повторите попытку.</string>
<string name="call_settings">Настройки звонка</string> <string name="call_settings">Настройки звонка</string>
<string name="call_controls">Управление звонком</string> <string name="call_controls">Управление звонком</string>
<string name="call_video_local">Своё видео</string> <string name="call_video_local">Своё видео</string>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="delete_conversation">Delete conversation</string>
<string name="delete_conversation_confirm">Delete the conversation with %1$s and its local files? The other person keeps their history.</string>
<string name="delete_conversation_failed">Could not delete the conversation. Please try again.</string>
<string name="call_settings">Call settings</string> <string name="call_settings">Call settings</string>
<string name="call_controls">Call controls</string> <string name="call_controls">Call controls</string>
<string name="call_video_local">Your video</string> <string name="call_video_local">Your video</string>
@@ -6,6 +6,20 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
class MemberAvatarTest { 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() { @Test fun sharedSnapshotKeepsAvatarAndSignedStorageRole() {
val bytes = ByteArray(158) val bytes = ByteArray(158)
val b = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) val b = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
@@ -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); 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; } if (!conv_id || !conv_id[0] || !text) { bridge_log(BLEV_ERROR, "dm_send: invalid args"); return; }
struct UASYNC* ua = instance_lite_get_uasync(); struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "dm_send: uasync not running"); return; } 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; } if (!req) { bridge_log(BLEV_ERROR, "dm_send: OOM"); return; }
req->inst = instance_lite_get_instance(); req->inst = instance_lite_get_instance();
strncpy(req->conv_id, conv_id, sizeof(req->conv_id) - 1); strncpy(req->conv_id, conv_id, sizeof(req->conv_id) - 1);
req->generation = generation;
req->data_len = (uint32_t)len; req->data_len = (uint32_t)len;
req->data = (uint8_t*)(req + 1); req->data = (uint8_t*)(req + 1);
memcpy(req->data, text, len); 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); 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(); 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; } 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)); 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; bridge_log(BLEV_ERROR, "DM file: path, conversation or name too long"); u_free(req); return -1;
} }
req->inst = instance_lite_get_instance(); req->inst = instance_lite_get_instance();
req->generation = generation;
snprintf(req->conv, sizeof(req->conv), "%s", conv_id); snprintf(req->conv, sizeof(req->conv), "%s", conv_id);
snprintf(req->path, sizeof(req->path), "%s", path); snprintf(req->path, sizeof(req->path), "%s", path);
req->info.kind = ATTACHMENT_FILE; req->info.kind = ATTACHMENT_FILE;
@@ -1365,9 +1367,9 @@ int utun_bridge_is_responsive(void) {
* Voice recording * 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 */ 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) { 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 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) {
return video_send(channel_id, file_path, db_path, duration_ms, width, height, reply_to, request_id); 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, 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( 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; (void)thiz;
const char* c = (*env)->GetStringUTFChars(env, convId, NULL); const char* c = (*env)->GetStringUTFChars(env, convId, NULL);
const char* t = (*env)->GetStringUTFChars(env, text, 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 (t) (*env)->ReleaseStringUTFChars(env, text, t);
if (c) (*env)->ReleaseStringUTFChars(env, convId, c); if (c) (*env)->ReleaseStringUTFChars(env, convId, c);
} }
/* UI получает подтверждение commit через CHAT_EVT_DM_INVITE_RESULT, а не через return JNI. */ /* 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( 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) { JNIEnv* env, jobject thiz, jlong request_id, jlong peer, jstring source, jlong channel, jstring name, jboolean cancel) {
(void)thiz; (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( 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; (void)thiz;
const char* c = (*env)->GetStringUTFChars(env, convId, NULL); const char* c = (*env)->GetStringUTFChars(env, convId, NULL);
const char* p = (*env)->GetStringUTFChars(env, path, NULL); const char* p = (*env)->GetStringUTFChars(env, path, NULL);
const char* n = (*env)->GetStringUTFChars(env, name, 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 (n) (*env)->ReleaseStringUTFChars(env, name, n);
if (p) (*env)->ReleaseStringUTFChars(env, path, p); if (p) (*env)->ReleaseStringUTFChars(env, path, p);
if (c) (*env)->ReleaseStringUTFChars(env, convId, c); if (c) (*env)->ReleaseStringUTFChars(env, convId, c);
@@ -2321,12 +2345,12 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeClearDatabase
/* ── Voice recording JNI ── */ /* ── Voice recording JNI ── */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceStart( 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; (void)thiz;
uint8_t reply[32]; uint8_t reply[32];
if (jni_message_id(env, replyTo, reply) < 0) return JNI_FALSE; if (jni_message_id(env, replyTo, reply) < 0) return JNI_FALSE;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); 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); if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
return (rc == 0) ? JNI_TRUE : JNI_FALSE; 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( JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVideoSend(
JNIEnv* env, jobject thiz, jstring channelId, jstring filePath, 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; (void)thiz;
uint8_t reply[32]; uint8_t reply[32];
if (jni_message_id(env, replyTo, reply) < 0) return JNI_FALSE; if (jni_message_id(env, replyTo, reply) < 0) return JNI_FALSE;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
const char* fp = (*env)->GetStringUTFChars(env, filePath, 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 (fp) (*env)->ReleaseStringUTFChars(env, filePath, fp);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch); if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
return (rc == 0) ? JNI_TRUE : JNI_FALSE; return (rc == 0) ? JNI_TRUE : JNI_FALSE;
@@ -86,7 +86,7 @@ int utun_bridge_is_responsive(void);
/* ── Voice recording ── */ /* ── 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_feed(const int16_t* samples, int count);
int utun_bridge_voice_stop(int* out_duration_ms); int utun_bridge_voice_stop(int* out_duration_ms);
void utun_bridge_voice_cancel(void); 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, без транскода) */ /* Отправка видеосообщения (src — готовый mp4, без транскода) */
int utun_bridge_video_send(const char* channel_id, const char* file_path, const char* db_path, 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, без транскода) */ /* Отправка фото (src — готовый jpeg, без транскода) */
int utun_bridge_photo_send(const char* channel_id, const char* file_path, const char* db_path, 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/dm_send выполняются асинхронно через uasync (трамплины dm_core).
* Результат dm_start — событие CHAT_EVT_DM_CONV_UPDATED. */ * Результат 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_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_conversations(void);
void utun_bridge_dm_request_messages(const char* conv_id); 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 #ifdef __cplusplus
} }
@@ -9,7 +9,7 @@
#include "instance_lite.h" #include "instance_lite.h"
int video_send(const char* channel_id, const char* src_file_path, const char* db_path, 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; (void)db_path;
struct UASYNC* ua = instance_lite_get_uasync(); struct UASYNC* ua = instance_lite_get_uasync();
if (!channel_id || !src_file_path || !uasync_is_running(ua) || duration_ms <= 0 || duration_ms > 86400000 || 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 attachment_send_req* req = u_calloc(1, sizeof(*req));
struct posted_task* post = u_calloc(1, sizeof(*post)); 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; } 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); 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; } 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)); snprintf(req->target, sizeof(req->target), "%s", channel_id + (req->is_dm ? 3 : 0));
@@ -11,7 +11,7 @@ extern "C" {
#endif #endif
int video_send(const char* channel_id, const char* src_file_path, const char* db_path, 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 #ifdef __cplusplus
} }
@@ -26,7 +26,7 @@ struct voice_recorder {
char channel_id[64]; char channel_id[64];
uint8_t reply_to[32]; uint8_t reply_to[32];
uint64_t request_id; uint64_t request_id, generation;
int sample_rate; int sample_rate;
int channels; int channels;
int frame_samples; int frame_samples;
@@ -108,7 +108,8 @@ void voice_recorder_deinit(void) {
pthread_mutex_unlock(&g_init_mtx); 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) { 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); DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice start: invalid target or capture format rate=%d channels=%d", sample_rate, channels);
return -1; 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; g_rec->frame_samples = sample_rate * FRAME_MS / 1000;
snprintf(g_rec->channel_id, sizeof(g_rec->channel_id), "%s", channel_id ? channel_id : ""); 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)); 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->pcm_count = 0;
g_rec->active = 1; 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->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->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; } 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)); snprintf(req->target, sizeof(req->target), "%s", g_rec->channel_id + (req->is_dm ? 3 : 0));
if (g_rec->compressor && g_rec->compressor_enabled) { if (g_rec->compressor && g_rec->compressor_enabled) {
@@ -18,7 +18,7 @@ struct voice_recorder;
int voice_recorder_init(const char* db_path); int voice_recorder_init(const char* db_path);
void voice_recorder_deinit(void); 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_feed(const int16_t* samples, int count);
int voice_recorder_stop(int* out_duration_ms); int voice_recorder_stop(int* out_duration_ms);
void voice_recorder_cancel(void); void voice_recorder_cancel(void);
+1 -1
View File
@@ -89,7 +89,7 @@ void DmInviteDialog::sendInvite() {
} }
void DmInviteDialog::onResult(const uint8_t* data, int len) { 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; uint64_t request, peer, conv; int32_t result;
memcpy(&request, data, 8); memcpy(&result, data + 8, 4); memcpy(&request, data, 8); memcpy(&result, data + 8, 4);
memcpy(&peer, data + 12, 8); memcpy(&conv, data + 20, 8); memcpy(&peer, data + 12, 8); memcpy(&conv, data + 20, 8);