From 93057c9eb799f73d79edaa2fbb26ced4588fbc22 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 22:41:56 +0100 Subject: [PATCH] Adapt signed history regression fixtures to canonical revision envelopes --- tests/test_chat_feedback.c | 14 ++--- tests/test_db_sync.c | 52 ++++++++----------- tests/test_db_sync_integrity.c | 45 +++++++++------- .../java/com/utun/chat/data/ChatRepository.kt | 3 +- .../main/java/com/utun/chat/data/NativeLib.kt | 4 ++ .../ui/components/ConversationComposer.kt | 44 +++++++++++++--- .../ui/components/ConversationMessages.kt | 8 ++- .../utun/chat/ui/components/MessageBubble.kt | 18 +++++-- .../com/utun/chat/ui/screens/ChatScreen.kt | 3 +- .../com/utun/chat/viewmodel/ChatViewModel.kt | 39 ++++++++++++-- .../app/src/main/res/values-ru/strings.xml | 5 ++ .../app/src/main/res/values/strings.xml | 5 ++ .../jni_bridge/android_jni_bridge.c | 41 +++++++++++++-- 13 files changed, 202 insertions(+), 79 deletions(-) diff --git a/tests/test_chat_feedback.c b/tests/test_chat_feedback.c index 3efa9412..d1539106 100644 --- a/tests/test_chat_feedback.c +++ b/tests/test_chat_feedback.c @@ -278,13 +278,13 @@ int main(int argc, char** argv) { add_member(&source, &target, channel); add_member(&target, &target, channel); } check_author_rejection(&source); - uint8_t message[32], other_message[32], signature[64] = {7}; - assert(chat_message_id(1001, 123, signature, message) == 0); - assert(chat_message_id(1002, 123, signature, other_message) == 0 && memcmp(message, other_message, 32)); - assert(chat_message_id(1001, 123, signature, other_message) == 0 && !memcmp(message, other_message, 32)); - signature[63]++; - assert(chat_message_id(1001, 123, signature, other_message) == 0 && memcmp(message, other_message, 32)); - puts("PASS: message identity is deterministic and binds channel, timestamp and signature"); + uint8_t message[32], other_message[32], nonce[16] = {7}; + assert(chat_message_id(1001, 123, source.node_id, nonce, message) == 0); + assert(chat_message_id(1002, 123, source.node_id, nonce, other_message) == 0 && memcmp(message, other_message, 32)); + assert(chat_message_id(1001, 123, source.node_id, nonce, other_message) == 0 && !memcmp(message, other_message, 32)); + nonce[15]++; + assert(chat_message_id(1001, 123, source.node_id, nonce, other_message) == 0 && memcmp(message, other_message, 32)); + puts("PASS: message identity is deterministic and binds channel, timestamp, author and nonce"); uint64_t ver; struct chat_feedback_record first, second, cancelled, actual; uint8_t a[] = {1, 0}, b[] = {1, 1}, answer_data[] = {1, 0, 0, 0}; diff --git a/tests/test_db_sync.c b/tests/test_db_sync.c index f26319da..8f970f03 100644 --- a/tests/test_db_sync.c +++ b/tests/test_db_sync.c @@ -198,17 +198,21 @@ static int _cond_all_count(void) { db_sync_count(si_a) == ca_target && db_sync_count(si_b) == cb_target && db_sync_count(si_c) == cc_target; } +static int publish(struct DB_SYNC_INSTANCE* si, struct UTUN_INSTANCE* inst, const char* data, size_t len, uint64_t ts) { + struct db_message_revision rev = {.ver = 1}; uint8_t hash[32], signature[64]; + if (EVP_Digest(data, len, hash, NULL, EVP_sha256(), NULL) != 1) return -1; + memcpy(rev.nonce, hash, 16); + if (db_sync_sign_message(db_sync_instance_group_id(si), ts, inst->node_id, &rev, data, len, + inst->my_ed25519_privkey, signature) < 0) return -1; + return db_sync_insert_signed(si, data, len, signature, 64, ts, NULL, &rev); +} + static int insert_many(struct DB_SYNC_INSTANCE* si, struct UTUN_INSTANCE* inst, int start, int count) { char buf[128]; for (int i = start; i < start + count && test_phase == 0; i++) { snprintf(buf, sizeof(buf), "{\"idx\":%d,\"val\":\"data_%d\"}", i, i); uint64_t ts = db_sync_next_timestamp(si); - uint8_t sig_msg[256]; size_t off = 0; - memcpy(sig_msg + off, &ts, 8); off += 8; - size_t jl = strlen(buf); memcpy(sig_msg + off, buf, jl); off += jl; - uint8_t sig[64]; - if (sc_ed25519_sign(inst->my_ed25519_privkey, sig_msg, off, sig) != SC_OK) { fprintf(stderr,"sign fail\n"); return -1; } - if (db_sync_insert_signed(si, buf, jl, sig, 64, ts, NULL) < 0) { fprintf(stderr,"insert fail\n"); return -1; } + if (publish(si, inst, buf, strlen(buf), ts) < 0) { fprintf(stderr,"insert fail\n"); return -1; } } return 0; } @@ -358,12 +362,7 @@ int main(void) { cb_target = 2; if (!wait_for("seed=2", _cond_cb_count, PHASE_TIMEOUT_TB)) { test_phase = 2; goto done; } { char ebuf[64]; snprintf(ebuf, sizeof(ebuf), "{\"idx\":\"early\",\"val\":\"not_at_tail\"}"); - uint64_t early_ts = 500; uint8_t msg[256]; size_t moff = 0; - memcpy(msg + moff, &early_ts, 8); moff += 8; - size_t jl = strlen(ebuf); memcpy(msg + moff, ebuf, jl); moff += jl; - uint8_t sig[64]; - if (sc_ed25519_sign(inst_a->my_ed25519_privkey, msg, moff, sig) != SC_OK - || db_sync_insert_signed(si_a, ebuf, jl, sig, 64, early_ts, NULL) != 0) + if (publish(si_a, inst_a, ebuf, strlen(ebuf), 500) != 0) { test_phase = 2; goto done; } } ca_target = 3; cb_target = 3; @@ -384,10 +383,8 @@ int main(void) { } snprintf(original, sizeof(original), "%s", sqlite3_column_text(st, 0)); sqlite3_finalize(st); snprintf(json, sizeof(json), "{\"ct\":\"text/plain\",\"d\":\"Answer\",\"reply_to\":\"%s\"}", original); - uint64_t ts = db_sync_next_timestamp(si_a); uint8_t body[264], signature[64]; - memcpy(body, &ts, 8); memcpy(body + 8, json, strlen(json)); - if (sc_ed25519_sign(inst_a->my_ed25519_privkey, body, 8 + strlen(json), signature) != SC_OK - || db_sync_insert_signed(si_a, json, strlen(json), signature, 64, ts, NULL) != 0) { + uint64_t ts = db_sync_next_timestamp(si_a); + if (publish(si_a, inst_a, json, strlen(json), ts) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "reply test: signed submission failed"); test_phase = 2; goto done; } ca_target = cb_target = 4; @@ -411,10 +408,7 @@ int main(void) { delivery_timestamp = db_sync_next_timestamp(si_a); for (int i = 0; i < 2; i++) { char json[64]; snprintf(json, sizeof(json), "{\"delivery\":%d}", i); - uint8_t body[72], signature[64]; size_t len = strlen(json); - memcpy(body, &delivery_timestamp, 8); memcpy(body + 8, json, len); - if (sc_ed25519_sign(inst_a->my_ed25519_privkey, body, len + 8, signature) != SC_OK || - db_sync_insert_signed(si_a, json, len, signature, 64, delivery_timestamp, NULL) != 0) { + if (publish(si_a, inst_a, json, strlen(json), delivery_timestamp) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "delivery test submission failed index=%d", i); test_phase = 2; goto done; } } @@ -440,18 +434,19 @@ int main(void) { sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); test_phase = 2; goto done; } uint64_t ts = (uint64_t)sqlite3_column_int64(st, 0); sqlite3_finalize(st); const char* text = "{\"case\":\"alternate first record\"}"; - uint8_t body[128], signature[64], id[32], aroot[32], broot[32]; size_t len = strlen(text); - memcpy(body, &ts, 8); memcpy(body + 8, text, len); - if (sc_ed25519_sign(inst_a->my_ed25519_privkey, body, 8 + len, signature) != SC_OK || - chat_message_id(90, ts, signature, id) < 0) { test_phase = 2; goto done; } + uint8_t signature[64], id[32], aroot[32], broot[32]; size_t len = strlen(text); + struct db_message_revision rev = {.nonce = {42}, .ver = 1}; + if (db_sync_sign_message(90, ts, inst_a->node_id, &rev, text, len, inst_a->my_ed25519_privkey, signature) < 0 || + chat_message_id(90, ts, inst_a->node_id, rev.nonce, id) < 0) { test_phase = 2; goto done; } uint64_t bucket; memcpy(&bucket, id, 8); bucket = be64toh(bucket) >> 39; int rc = sqlite3_prepare_v2(inst_b->topo_sqlite_db, - "UPDATE long_history SET data=?,author_signature=?,message_id=?,bucket=?,id=id+900 WHERE timestamp=?", -1, &st, NULL); + "UPDATE long_history SET data=?,author_signature=?,message_id=?,bucket=?,id=id+900,nonce=?6 WHERE timestamp=?5", -1, &st, NULL); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, text, (int)len, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, signature, 64, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, id, 32, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 4, (sqlite3_int64)bucket); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 5, (sqlite3_int64)ts); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 6, rev.nonce, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE || db_sync_verify(si_b) || db_sync_read_root(si_a, aroot) || @@ -510,12 +505,7 @@ int main(void) { // 6c: PUSH not-at-tail on A → delivered to B and C → cascade_from, synced_pos reset. { char ebuf[64]; snprintf(ebuf, sizeof(ebuf), "{\"idx\":\"mid\",\"val\":\"triple_insert\"}"); - uint64_t early_ts = 500; uint8_t msg[256]; size_t moff = 0; - memcpy(msg + moff, &early_ts, 8); moff += 8; - size_t jl = strlen(ebuf); memcpy(msg + moff, ebuf, jl); moff += jl; - uint8_t sig[64]; - if (sc_ed25519_sign(inst_a->my_ed25519_privkey, msg, moff, sig) != SC_OK - || db_sync_insert_signed(si_a, ebuf, jl, sig, 64, early_ts, NULL) != 0) + if (publish(si_a, inst_a, ebuf, strlen(ebuf), 500) != 0) { test_phase = 2; goto done; } } ca_target = 6; cb_target = 6; cc_target = 6; diff --git a/tests/test_db_sync_integrity.c b/tests/test_db_sync_integrity.c index 85066eb2..080a340a 100644 --- a/tests/test_db_sync_integrity.c +++ b/tests/test_db_sync_integrity.c @@ -8,9 +8,9 @@ static unsigned fault_hits; static sqlite3* verification_db; static int assert_unlocked; static int concentrate_bucket; -extern int __real_chat_message_id(uint64_t, uint64_t, const uint8_t[64], uint8_t[32]); -int __wrap_chat_message_id(uint64_t group, uint64_t ts, const uint8_t sig[64], uint8_t key[32]) { - int rc = __real_chat_message_id(group, ts, sig, key); +extern int __real_chat_message_id(uint64_t, uint64_t, uint64_t, const uint8_t[16], uint8_t[32]); +int __wrap_chat_message_id(uint64_t group, uint64_t ts, uint64_t author, const uint8_t nonce[16], uint8_t key[32]) { + int rc = __real_chat_message_id(group, ts, author, nonce, key); /* Только для плотного тестового листа: подписи настоящие, оставшиеся223 бита ключа сохраняются. */ if (rc == 0 && concentrate_bucket) { key[0] = key[1] = key[2] = 0; key[3] &= 0x7f; } return rc; @@ -52,6 +52,7 @@ struct fixture { struct DB_SYNC_INSTANCE* si; struct DB_SYNC_INSTANCE* other; uint8_t seed[32]; + struct db_message_revision revision; }; static int empty_hash(void* ctx, const char* ns, uint8_t level, uint64_t prefix, EVP_MD_CTX* hash) { (void)ctx; (void)ns; (void)level; (void)prefix; (void)hash; return 0; @@ -106,11 +107,13 @@ static void destroy(struct fixture* f) { uasync_destroy(f->inst.ua, 0); verification_db = NULL; } static void sign(struct fixture* f, uint64_t ts, const char* text, uint8_t sig[64]) { - uint8_t body[DB_SYNC_MESSAGE_MAX + 8]; size_t len = strlen(text); assert(len <= DB_SYNC_MESSAGE_MAX); - memcpy(body, &ts, 8); memcpy(body + 8, text, len); assert(sc_ed25519_sign(f->seed, body, len + 8, sig) == SC_OK); + uint8_t hash[32]; size_t len = strlen(text); assert(len <= DB_SYNC_MESSAGE_MAX); + assert(EVP_Digest(text, len, hash, NULL, EVP_sha256(), NULL) == 1); + f->revision = (struct db_message_revision){.ver = 1}; memcpy(f->revision.nonce, hash, 16); + assert(db_sync_sign_message(10, ts, f->inst.node_id, &f->revision, text, len, f->seed, sig) == 0); } static int insert(struct fixture* f, uint64_t ts, const char* text, const uint8_t sig[64]) { - return db_sync_insert_signed(f->si, text, strlen(text), sig, 64, ts, NULL); + return db_sync_insert_signed(f->si, text, strlen(text), sig, 64, ts, NULL, &f->revision); } static void intact(struct fixture* f, const char* text) { sqlite3_stmt* st = NULL; @@ -120,7 +123,8 @@ static void intact(struct fixture* f, const char* text) { } static int page(struct fixture* f, const uint8_t sig[64]) { uint8_t id[32], data[16384]; size_t len = sizeof(data); merkle_key_t after = {{0}}, next; int more; - assert(chat_message_id(10, 100, sig, id) == 0); + (void)sig; + assert(chat_message_id(10, 100, f->inst.node_id, f->revision.nonce, id) == 0); return db_message_page(&f->inst, "10", merkle_sync_level_prefix(db_read64(id), MT_MAX_LEVEL), 0, &after, data, &len, &next, &more); } static void test_preservation(void) { @@ -146,7 +150,9 @@ static void test_preservation(void) { static void test_deletion(void) { struct fixture f; init(&f); uint8_t sig[64]; sign(&f, 100, "original", sig); assert(insert(&f, 100, "original", sig) == 0); - assert(db_sync_insert_signed(f.other, "original", 8, sig, 64, 100, NULL) == 0 && db_sync_verify(f.si) == 0); + uint8_t other_sig[64]; + assert(db_sync_sign_message(11, 100, 1, &f.revision, "original", 8, f.seed, other_sig) == 0); + assert(db_sync_insert_signed(f.other, "original", 8, other_sig, 64, 100, NULL, &f.revision) == 0 && db_sync_verify(f.si) == 0); sql(&f, "UPDATE history SET data='modified';CREATE TRIGGER deny_delete BEFORE DELETE ON history BEGIN SELECT RAISE(FAIL,'delete denied');END"); assert(insert(&f, 100, "modified", sig) == -1); intact(&f, "modified"); sql(&f, "DROP TRIGGER deny_delete"); @@ -190,18 +196,19 @@ static void test_index(void) { } static void test_delivery(void) { struct fixture f; init(&f); uint8_t a[64], b[64], id_a[32], id_b[32]; - sign(&f, 100, "first", a); sign(&f, 100, "second", b); - assert(insert(&f, 100, "first", a) == 0 && insert(&f, 100, "second", b) == 0); - assert(chat_message_id(10, 100, a, id_a) == 0 && chat_message_id(10, 100, b, id_b) == 0); - assert(si_delivery_update(f.si, id_a, 2) == 0 && si_delivery_update(f.si, id_a, 2) == 0); + sign(&f, 100, "first", a); assert(insert(&f, 100, "first", a) == 0); + assert(chat_message_id(10, 100, 1, f.revision.nonce, id_a) == 0); + sign(&f, 100, "second", b); assert(insert(&f, 100, "second", b) == 0); + assert(chat_message_id(10, 100, 1, f.revision.nonce, id_b) == 0); + assert(si_delivery_update(f.si, id_a, 1, 2) == 0 && si_delivery_update(f.si, id_a, 1, 2) == 0); sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(f.db.db, "SELECT data,delivered_peers,flags FROM history ORDER BY data", -1, &st, NULL) == SQLITE_OK); assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 1) == 1 && sqlite3_column_int(st, 2) == 1); assert(sqlite3_step(st) == SQLITE_ROW && !sqlite3_column_int(st, 1) && !sqlite3_column_int(st, 2)); assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK); sql(&f, "CREATE TRIGGER deny_ack BEFORE UPDATE ON history BEGIN SELECT RAISE(FAIL,'ACK denied');END"); - assert(si_delivery_update(f.si, id_b, 3) == -1); sql(&f, "DROP TRIGGER deny_ack"); - assert(si_delivery_update(f.si, id_b, 3) == 0); + assert(si_delivery_update(f.si, id_b, 1, 3) == -1); sql(&f, "DROP TRIGGER deny_ack"); + assert(si_delivery_update(f.si, id_b, 1, 3) == 0); destroy(&f); DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS exact same-timestamp ACK identity and idempotence"); } @@ -234,7 +241,7 @@ static void copy_pages(struct fixture* source, struct fixture* target, int inser uint8_t sig[64], key[32]; int found = 0; for (uint64_t ts = 1000; ts < 2000; ts++) { sign(source, ts, "inserted before cursor", sig); - assert(chat_message_id(10, ts, sig, key) == 0); + assert(chat_message_id(10, ts, 1, source->revision.nonce, key) == 0); if (memcmp(key, after.bytes, 32) < 0) { assert(insert(source, ts, "inserted before cursor", sig) == 0); found = 1; break; } @@ -261,8 +268,8 @@ static void test_pages(void) { } static void test_deleted_author(void) { struct fixture f; init(&f); uint8_t sig[64]; sign(&f, 100, "historic", sig); - struct db_record r = {.timestamp = 100, .author = 99, .data = "historic", .data_len = 8, .sig_len = 64}; - memcpy(r.signature, sig, 64); + struct db_record r = {.timestamp = 100, .author = 99, .data = "historic", .data_len = 8, .sig_len = 64, .revision = f.revision}; + assert(db_sync_sign_message(10, 100, 99, &r.revision, r.data, r.data_len, f.seed, r.signature) == 0); sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(f.db.db, "INSERT INTO nodes VALUES(99,?,1)", -1, &st, NULL) == SQLITE_OK); assert(sqlite3_bind_blob(st, 1, f.inst.my_ed25519_pubkey, 32, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_DONE); @@ -273,9 +280,9 @@ static void test_deleted_author(void) { DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS valid history of a deleted author remains in the root and outgoing page"); } static void page_inserted(struct DB_SYNC_INSTANCE* si, const uint8_t id[32], uint64_t ts, - const char* data, size_t len, uint64_t author, int initial, void* arg) { + const char* data, size_t len, uint64_t author, int initial, int updated, void* arg) { (void)id; (void)ts; (void)data; (void)len; (void)author; - assert(initial && sqlite3_get_autocommit(SI_DB(si)) && db_sync_count(si) == 3); + assert(initial && !updated && sqlite3_get_autocommit(SI_DB(si)) && db_sync_count(si) == 3); (*(unsigned*)arg)++; } static void test_page_rollback(void) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 6d0deb19..dfc03ca5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -29,7 +29,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L val downloadState: String = "", /* "", "downloading", "downloaded", "error" */ val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0, val messageId: String = "", val feedback: FeedbackState? = null, val replyTo: String = "", - val encryptionState: String = "") { + val encryptionState: String = "", val version: Long = 1, val editedAt: Long = 0) { val isEncrypted: Boolean get() = encryptionState.isNotEmpty() val isLocked: Boolean get() = isEncrypted && encryptionState != "ready" } @@ -340,6 +340,7 @@ class ChatRepository { list.add(Message( id = obj.getLong("id"), messageId = obj.optString("messageId"), + version = obj.optLong("ver", 1), editedAt = obj.optLong("editedAt"), replyTo = obj.optString("replyTo").takeIf(::validMessageId).orEmpty(), author = obj.getString("author"), authorNodeId = obj.optLong("authorNodeId"), diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index a4f3f5d9..70f95be9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -101,6 +101,9 @@ object NativeLib { fun sendMessage(channelId: String, text: String, replyTo: String = "", requestId: Long = 0): Boolean = nativeSendMessage(channelId, text.toByteArray(Charsets.UTF_8), messageIdBytes(replyTo), requestId) + + fun editMessage(channelId: String, id: String, version: Long, text: String, requestId: Long): Boolean = + nativeEditMessage(channelId, messageIdBytes(id), version, text.toByteArray(Charsets.UTF_8), requestId) fun createChannel(name: String, channelId: String) { nativeCreateChannel(name, channelId) } fun connectNode(address: String, port: Int, pubkeyHex: String) { nativeConnectNode(address, port, pubkeyHex) } private val joinSequence = java.util.concurrent.atomic.AtomicLong(1) @@ -346,6 +349,7 @@ object NativeLib { private external fun nativeSetEventCallback(cb: Any) private external fun nativeDestroy() private external fun nativeSendMessage(channelId: String, text: ByteArray, replyTo: ByteArray, requestId: Long): Boolean + private external fun nativeEditMessage(channelId: String, messageId: ByteArray, version: Long, text: ByteArray, requestId: Long): Boolean private external fun nativeCreateChannel(name: String, channelId: String) private external fun nativeConnectNode(address: String, port: Int, pubkeyHex: String) private external fun nativeJoinChannel(link: String, request: Long): Int diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt index 185cd5f2..e60d8964 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt @@ -40,6 +40,17 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, var textRevision by rememberSaveable(target) { mutableStateOf(0L) } val replies by viewModel.replies.collectAsState() val reply = if (target is ConversationTarget.Channel) replies[target.id] else null + val editing by viewModel.editing.collectAsState() + val edit = editing?.takeIf { target is ConversationTarget.Channel && it.channelId == target.id } + var savedDraft by rememberSaveable(target, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + var activeEdit by remember(target) { mutableStateOf(null) } + LaunchedEffect(edit) { + if (edit != null) { + if (activeEdit == null) savedDraft = text + text = TextFieldValue(edit.message.text); textRevision++ + } else if (activeEdit != null) { text = savedDraft; textRevision++ } + activeEdit = edit + } val sendFailed by viewModel.messageSendFailed.collectAsState() val submitting by viewModel.submittingChannels.collectAsState() val deletingDm by viewModel.dmDeleting.collectAsState() @@ -61,7 +72,10 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, var level by remember { mutableStateOf(0f) } val preparations by viewModel.attachmentPreparations.collectAsState() val preparation = preparations[target.nativeId] - DisposableEffect(target) { onDispose { viewModel.cancelVoiceRecording(target) } } + DisposableEffect(target) { onDispose { + viewModel.cancelVoiceRecording(target) + viewModel.editing.value?.takeIf { it.channelId == target.id }?.let(viewModel::cancelEdit) + } } LaunchedEffect(recording) { while (recording) { level = NativeLib.voiceGetPeakLevel(); delay(80) } level = 0f @@ -88,7 +102,15 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, } } Column { - if (reply != null) Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + if (edit != null) Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + Row(Modifier.fillMaxWidth().padding(start = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.editing_message), Modifier.weight(1f)) + IconButton(onClick = { viewModel.cancelEdit(edit) }, enabled = !pending) { + Icon(Icons.Default.Close, stringResource(R.string.edit_cancel)) + } + } + } + if (edit == null && reply != null) Surface(color = MaterialTheme.colorScheme.surfaceVariant) { Row(Modifier.fillMaxWidth().padding(start = 12.dp), verticalAlignment = Alignment.CenterVertically) { ReplyQuote(reply.message, Modifier.weight(1f)) IconButton(onClick = { viewModel.cancelReply(reply.channelId) }) { @@ -101,9 +123,19 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) key(target) { InputBar( - text = text, onTextChange = { text = it; textRevision++ }, enabled = !preparing && !pending, focusRevision = reply?.revision ?: 0, + text = text, onTextChange = { text = it; textRevision++ }, enabled = !preparing && !pending, + focusRevision = edit?.revision ?: reply?.revision ?: 0, editing = edit != null, onSend = { if (preparing || pending) return@InputBar + if (edit != null) { + val sent = text.text + if (sent.isBlank()) return@InputBar + scope.launch { + error = "" + if (!viewModel.saveEdit(edit, sent).await()) error = AppLanguage.text(R.string.edit_failed) + } + return@InputBar + } val attachment = selected val video = sendAsVideo val sentText = text.text @@ -144,11 +176,11 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, } }, isRecording = recording, isRecordingLocked = locked, recordingDurationMs = duration, recordingLevel = level, - onPttPressed = { viewModel.startVoiceRecording(target) }, + onPttPressed = { if (edit == null) viewModel.startVoiceRecording(target) }, onPttReleased = { if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage() }, onPttLock = { viewModel.lockVoiceRecording() }, onVoiceSend = { viewModel.sendVoiceMessage() }, - onVoiceCancel = { viewModel.cancelVoiceRecording() }, onAttachClicked = { menu = true }, - pendingAttachmentName = selected?.name ?: "", pendingAttachmentSize = selected?.size ?: 0, + onVoiceCancel = { viewModel.cancelVoiceRecording() }, onAttachClicked = { if (edit == null) menu = true }, + pendingAttachmentName = if (edit == null) selected?.name ?: "" else "", pendingAttachmentSize = selected?.size ?: 0, onClearAttachment = { selected = null }, isVideoMode = videoMode, onToggleMode = { viewModel.toggleVideoMode() }, onVideoRecordOpen = onRecordVideo, modifier = Modifier.navigationBarsPadding() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt index 637620c6..7572a3fe 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt @@ -53,13 +53,19 @@ fun ConversationMessages(target: ConversationTarget, messages: List, st onReact: (Message, Int?) -> Unit = { _, _ -> }, authorName: (Long) -> String = { "0x%016x".format(it) }, onReply: (Message) -> Unit = {}, onOriginal: (String) -> Unit = {}, - originals: Map = emptyMap(), highlightedId: String = "") { + originals: Map = emptyMap(), highlightedId: String = "", + onEdit: (Message) -> Unit = {}) { val context = LocalContext.current val clipboard = LocalClipboardManager.current val haptic = LocalHapticFeedback.current var selectedMessage by remember(target) { mutableStateOf(null) } selectedMessage?.let { selected -> ModalBottomSheet(onDismissRequest = { selectedMessage = null }) { + if (target is ConversationTarget.Channel && selected.isOutgoing && !selected.isEncrypted && + selected.contentType in setOf("text", "text/plain")) + ListItem(headlineContent = { Text(stringResource(R.string.edit_message)) }, modifier = Modifier.clickable { + selectedMessage = null; onEdit(selected) + }) ListItem(headlineContent = { Text(stringResource(R.string.reply)) }, modifier = Modifier.clickable { selectedMessage = null onReply(selected) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index a41e7f48..50082a8b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -108,7 +108,8 @@ internal fun MessageWithFooter(message: Message, footer: @Composable () -> Unit, Layout(modifier = modifier, content = { Box { content() } Box { footer() } - Text(listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "), + val edited = if (message.editedAt > 0) stringResource(R.string.message_edited_at, formatEditedTime(message.editedAt)) else "" + Text(listOf(formatTime(message.ts), edited, deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 11.sp, color = ChatColors.Secondary, maxLines = 1, overflow = TextOverflow.Ellipsis) }) { children, constraints -> val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) @@ -611,6 +612,7 @@ fun InputBar( onToggleMode: () -> Unit = {}, onVideoRecordOpen: () -> Unit = {}, focusRevision: Long = 0, + editing: Boolean = false, modifier: Modifier = Modifier ) { val ctx = LocalContext.current @@ -690,7 +692,7 @@ fun InputBar( Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { /* Attach button */ if (!isRecording) { - IconButton(onClick = onAttachClicked, enabled = enabled) { + IconButton(onClick = onAttachClicked, enabled = enabled && !editing) { Text("\uD83D\uDCCE", fontSize = 20.sp) } } else { @@ -767,17 +769,17 @@ fun InputBar( IconButton(onClick = onVoiceSend) { Text("\u25B6", fontSize = 20.sp, color = Color(0xFF81C784)) } - } else if (!isRecording && (text.text.isNotBlank() || hasAttachment)) { + } else if (!isRecording && (editing || text.text.isNotBlank() || hasAttachment)) { /* Send button — round, primary color */ val sendColor = MaterialTheme.colorScheme.primary Box( modifier = Modifier .size(44.dp) .background(sendColor, CircleShape) - .clickable(enabled = enabled) { onSend() }, + .clickable(enabled = enabled && text.text.isNotBlank() || enabled && hasAttachment) { onSend() }, contentAlignment = Alignment.Center ) { - Text("\u27A4", fontSize = 22.sp, color = MaterialTheme.colorScheme.onPrimary) + Text(if (editing) "✓" else "\u27A4", fontSize = 22.sp, color = MaterialTheme.colorScheme.onPrimary) } } else { /* Unified mode button — short tap toggles voice/video, hold activates */ @@ -878,6 +880,12 @@ private fun formatDuration(ms: Int): String { return "%d:%02d".format(secs / 60, secs % 60) } +private fun formatEditedTime(ts: Long): String { + val today = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.getDefault()) + val format = if (today.format(java.util.Date(ts)) == today.format(java.util.Date())) "HH:mm" else "dd.MM.yyyy HH:mm" + return java.text.SimpleDateFormat(format, java.util.Locale.getDefault()).format(java.util.Date(ts)) +} + private fun formatTime(ts: Long): String { if (ts == 0L) return "" val dt = java.time.Instant.ofEpochMilli(ts).atZone(java.time.ZoneId.systemDefault()) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 758d9e4d..42a6dd7c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -277,7 +277,8 @@ fun ChatScreen( onOpenFile = { if (it.filePath.isNotEmpty()) { pendingSaveFilePath = it.filePath; saveFileLauncher.launch(it.text) } }, onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink, reactionCatalog = reactionCatalog, onVote = viewModel::vote, onReact = viewModel::react, authorName = viewModel::radioName, - onReply = viewModel::selectReply, onOriginal = viewModel::showOriginal, originals = originals, highlightedId = highlighted + onReply = viewModel::selectReply, onOriginal = viewModel::showOriginal, originals = originals, highlightedId = highlighted, + onEdit = viewModel::selectEdit ) if (anchor.isNotEmpty()) FilledTonalButton(onClick = viewModel::showLatestMessages, modifier = Modifier.align(Alignment.BottomEnd).padding(12.dp)) { Text(stringResource(R.string.reply_latest)) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 9822027f..d919be06 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -30,6 +30,29 @@ import org.json.JSONArray private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") class ChatViewModel : ViewModel() { + data class ChannelEdit(val channelId: String, val message: Message, val revision: Long) + private val _editing = MutableStateFlow(null) + val editing: StateFlow = _editing + private var editRevision = 0L + + fun selectEdit(message: Message) { + val channel = _currentChannel.value?.id ?: return + if (!message.isOutgoing || message.isEncrypted || message.contentType !in setOf("text", "text/plain") || + !validMessageId(message.messageId) || channel in _submittingChannels.value || _isRecording.value) { + LogManager.addLog("WARN", "Edit", "cannot open editor ch=$channel id=${message.messageId}"); return + } + _editing.value = ChannelEdit(channel, message, ++editRevision) + LogManager.addLog("INFO", "Edit", "opened ch=$channel id=${message.messageId} ver=${message.version}") + } + + fun cancelEdit(edit: ChannelEdit) { if (_editing.value == edit) _editing.value = null } + + fun saveEdit(edit: ChannelEdit, text: String): Deferred = viewModelScope.async { + submitChannel(edit.channelId, null, reportError = false) { + NativeLib.editMessage(edit.channelId, edit.message.messageId, edit.message.version, text, it) + }.also { if (it) cancelEdit(edit) } + } + private val _replies = MutableStateFlow>(emptyMap()) val replies: StateFlow> = _replies private var replyRevision = 0L @@ -61,6 +84,7 @@ class ChatViewModel : ViewModel() { fun selectReply(message: Message) { val channel = _currentChannel.value?.id ?: return if (!validMessageId(message.messageId)) { LogManager.addLog("ERROR", "Reply", "invalid original ID"); return } + _editing.value = null _replies.value = _replies.value + (channel to ChannelReply(channel, message, ++replyRevision)) LogManager.addLog("INFO", "Reply", "selected ch=$channel original=${message.messageId}") } @@ -88,7 +112,8 @@ class ChatViewModel : ViewModel() { result.complete(success) } - private suspend fun submitChannel(channel: String, reply: ChannelReply?, queue: (Long) -> Boolean): Boolean { + private suspend fun submitChannel(channel: String, reply: ChannelReply?, reportError: Boolean = true, + queue: (Long) -> Boolean): Boolean { if (reply != null && reply.channelId != channel) { LogManager.addLog("ERROR", "Reply", "target mismatch ch=$channel reply=${reply.channelId}") _messageSendFailed.value = true @@ -103,12 +128,12 @@ class ChatViewModel : ViewModel() { try { if (!queue(id)) submissionResult(id, false) val success = result.await() - if (!success) _messageSendFailed.value = true + if (!success && reportError) _messageSendFailed.value = true return success } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "Reply", "submission failed ch=$channel request=$id: ${e.message}") - _messageSendFailed.value = true + if (reportError) _messageSendFailed.value = true return false } finally { submissions.remove(id) @@ -515,6 +540,14 @@ class ChatViewModel : ViewModel() { _generatingKeys.value = false } } + 67 -> { /* MSG_UPDATED: существующая строка без уведомлений и unread. */ + val size = data?.firstOrNull()?.toInt()?.and(0xff) ?: 0 + if (size == 0 || data == null || data.size != 1 + size + 32) { + LogManager.addLog("ERROR", "Edit", "invalid update event bytes=${data?.size}"); return + } + val channel = String(data, 1, size, Charsets.UTF_8) + if (_currentChannel.value?.id == channel) refreshMessages(channel) + } 1 -> { /* MSG_RECEIVED: [ch_id_len:1][ch_id:var][author:8] */ if (repo == null || data == null || data.size < 2) return val chLen = data[0].toInt() and 0xFF diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index d4f8a406..3d99334b 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,10 @@ + Редактировать + Редактирование сообщения + Отменить редактирование + Не удалось сохранить правку. Возможно, сообщение уже изменено. Откройте его заново. + изменено %1$s Вызов по рации Вызвать по рации Входящий вызов по рации diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 3a4ce0a3..d44637b4 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -1,5 +1,10 @@ + Edit message + Editing message + Cancel editing + Could not save the edit. The message may have changed. Reopen it to retry. + edited %1$s Radio call Call by radio Incoming radio call diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index c6de1cdc..8df2a1d2 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -498,20 +498,20 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin char table[80], sql[2048], selection[1024]; msg_table_name(channel_id, table, sizeof(table)); if (exact && anchor) snprintf(selection, sizeof(selection), "SELECT * FROM \"%s\" WHERE message_id=?2", table); else if (anchor) snprintf(selection, sizeof(selection), - "SELECT * FROM \"%s\" ORDER BY timestamp DESC,author_signature DESC LIMIT ?1 OFFSET min(" + "SELECT * FROM \"%s\" ORDER BY timestamp DESC,message_id DESC LIMIT ?1 OFFSET min(" "max(0,(SELECT count(*) FROM \"%s\" n JOIN \"%s\" a ON a.message_id=?2 " - "WHERE (n.timestamp,n.author_signature)>(a.timestamp,a.author_signature)) - ?1/2)," + "WHERE (n.timestamp,n.message_id)>(a.timestamp,a.message_id)) - ?1/2)," "max(0,(SELECT count(*) FROM \"%s\")-?1))", table, table, table, table); - else snprintf(selection, sizeof(selection), "SELECT * FROM \"%s\" ORDER BY timestamp DESC,author_signature DESC LIMIT ?1", table); + else snprintf(selection, sizeof(selection), "SELECT * FROM \"%s\" ORDER BY timestamp DESC,message_id DESC LIMIT ?1", table); snprintf(sql, sizeof(sql), "SELECT m.id,m.node_id,m.timestamp,m.local_attrs,m.message_id," "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.ct') END," "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.d') END," "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.reply_to') END," - "a.ready,a.downloaded,a.decrypting,a.error,a.plain_path,a.info,a.content_type,COALESCE(r.state,-1),length(a.key)=32" + "a.ready,a.downloaded,a.decrypting,a.error,a.plain_path,a.info,a.content_type,COALESCE(r.state,-1),length(a.key)=32,m.ver,m.edited_at" " FROM (%s) m LEFT JOIN attachment_keys a ON a.channel=?3 AND a.message_id=m.message_id" " LEFT JOIN attachment_key_requests r ON r.channel=a.channel AND r.message_id=a.message_id AND r.incoming=0" - " ORDER BY m.timestamp,m.author_signature", selection); + " ORDER BY m.timestamp,m.message_id", selection); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: messages query ch=%s: %s", channel_id, sqlite3_errmsg(db)); return u_strdup("[]"); @@ -605,6 +605,8 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin if (result >= 0) result = strbuf_addf(&out, ",\"ts\":%lld,\"isOutgoing\":%s,\"text\":", (long long)ts, node_id == (int64_t)inst->node_id ? "true" : "false"); if (result >= 0) result = strbuf_add_json_string(&out, text); + if (result >= 0) result = strbuf_addf(&out, ",\"ver\":%lld,\"editedAt\":%lld", + (long long)sqlite3_column_int64(st, 17), (long long)sqlite3_column_int64(st, 18)); if (result >= 0) result = strbuf_addf(&out, ",\"contentType\":"); if (result >= 0) result = strbuf_add_json_string(&out, type); if (result >= 0) result = strbuf_addf(&out, ",\"filePath\":"); @@ -2055,6 +2057,35 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage( return rc == 0 ? JNI_TRUE : JNI_FALSE; } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeEditMessage( + JNIEnv* env, jobject thiz, jstring channelId, jbyteArray messageId, jlong version, jbyteArray text, jlong requestId) { + (void)thiz; + struct UASYNC* ua = instance_lite_get_uasync(); uint8_t id[32]; + if (!uasync_is_running(ua) || !text || !channelId || version <= 0 || !messageId || + (*env)->GetArrayLength(env, messageId) != 32 || jni_message_id(env, messageId, id) < 0) { + bridge_log(BLEV_ERROR, "edit: unavailable core/invalid target"); return JNI_FALSE; + } + jsize length = (*env)->GetArrayLength(env, text); + if (length <= 0 || length > DB_SYNC_MESSAGE_MAX) { bridge_log(BLEV_ERROR, "edit: invalid bytes=%d", length); return JNI_FALSE; } + struct chat_msg_submit* req = u_calloc(1, sizeof(*req) + length); + struct posted_task* post = u_calloc(1, sizeof(*post)); + if (!req || !post) { bridge_log(BLEV_ERROR, "edit: allocation failed"); u_free(req); u_free(post); return JNI_FALSE; } + req->inst = instance_lite_get_instance(); req->data = (uint8_t*)(req + 1); req->data_len = length; + req->expected_ver = (uint64_t)version; req->request_id = (uint64_t)requestId; memcpy(req->message_id, id, 32); + (*env)->GetByteArrayRegion(env, text, 0, length, (jbyte*)req->data); + const char* channel = (*env)->GetStringUTFChars(env, channelId, NULL); + if (!channel || (*env)->ExceptionCheck(env) || strlen(channel) >= sizeof(req->channel_id)) { + bridge_log(BLEV_ERROR, "edit: JNI copy failed"); + if (channel) (*env)->ReleaseStringUTFChars(env, channelId, channel); + u_free(req); u_free(post); return JNI_FALSE; + } + snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel); + (*env)->ReleaseStringUTFChars(env, channelId, channel); + post->callback = chat_core_submit_trampoline; post->arg = req; uasync_post_reserved(ua, post); + bridge_log(BLEV_INFO, "edit queued ver=%llu request=%llu", (unsigned long long)version, (unsigned long long)requestId); + return JNI_TRUE; +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCreateChannel( JNIEnv* env, jobject thiz, jstring name, jstring channelId) { (void)thiz;