Adapt signed history regression fixtures to canonical revision envelopes
This commit is contained in:
1 parent
1afb1010cc
commit
93057c9eb7
13 files changed
+202
-79
No files matched your search
@@ -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};
|
||||
|
||||
+21
-31
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
-6
@@ -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<ChatViewModel.ChannelEdit?>(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()
|
||||
|
||||
+7
-1
@@ -53,13 +53,19 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
|
||||
onReact: (Message, Int?) -> Unit = { _, _ -> },
|
||||
authorName: (Long) -> String = { "0x%016x".format(it) },
|
||||
onReply: (Message) -> Unit = {}, onOriginal: (String) -> Unit = {},
|
||||
originals: Map<String, Message> = emptyMap(), highlightedId: String = "") {
|
||||
originals: Map<String, Message> = emptyMap(), highlightedId: String = "",
|
||||
onEdit: (Message) -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var selectedMessage by remember(target) { mutableStateOf<Message?>(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)
|
||||
|
||||
+13
-5
@@ -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())
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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<ChannelEdit?>(null)
|
||||
val editing: StateFlow<ChannelEdit?> = _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<Boolean> = 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<Map<String, ChannelReply>>(emptyMap())
|
||||
val replies: StateFlow<Map<String, ChannelReply>> = _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
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="edit_message">Редактировать</string>
|
||||
<string name="editing_message">Редактирование сообщения</string>
|
||||
<string name="edit_cancel">Отменить редактирование</string>
|
||||
<string name="edit_failed">Не удалось сохранить правку. Возможно, сообщение уже изменено. Откройте его заново.</string>
|
||||
<string name="message_edited_at">изменено %1$s</string>
|
||||
<string name="radio_call">Вызов по рации</string>
|
||||
<string name="radio_call_start">Вызвать по рации</string>
|
||||
<string name="radio_call_incoming">Входящий вызов по рации</string>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="edit_message">Edit message</string>
|
||||
<string name="editing_message">Editing message</string>
|
||||
<string name="edit_cancel">Cancel editing</string>
|
||||
<string name="edit_failed">Could not save the edit. The message may have changed. Reopen it to retry.</string>
|
||||
<string name="message_edited_at">edited %1$s</string>
|
||||
<string name="radio_call">Radio call</string>
|
||||
<string name="radio_call_start">Call by radio</string>
|
||||
<string name="radio_call_incoming">Incoming radio call</string>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user