Browse Source

Render encrypted group attachments correctly on Android

stream-multiview
evgeny 6 days ago
parent
commit
aad295c9d0
  1. 6
      doc/encrypted_attachments.md
  2. 7
      doc/tasks.md
  3. 59
      tests/test_android_replies.c
  4. 45
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  5. 27
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  6. 1
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt
  7. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  8. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  9. 8
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  10. 8
      tools/chatgui-android/app/src/main/res/values/strings.xml
  11. 76
      tools/chatgui-android/jni_bridge/android_jni_bridge.c

6
doc/encrypted_attachments.md

@ -6,6 +6,12 @@
флажок. Выбор фиксируется вместе с адресатом при отправке, до подготовки.
В DM флажок скрыт: используется существующее автоматическое E2E-шифрование лички.
Android показывает зашифрованное вложение отдельной карточкой со статусом запроса
ключа. После одобрения карточка обновляется на исходный файл, изображение, видео
или голосовое. В интерфейс передаётся только расшифрованный путь; контейнер `.uenc`
нельзя открыть или сохранить как готовое вложение. Цитаты и список чатов показывают
название «Зашифрованное вложение» вместо manifest.
Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую
подготовку. Ключ записывается в локальную SQLite до шифрования. Готовый контейнер
передаётся через обычные media_index/media_delivery. UUID транспортного медиа

7
doc/tasks.md

@ -934,6 +934,13 @@
Двойной free по TIME_WAIT уже закрыт (detach по локальному закрытию + guard abort).
## Открытые флаки
[ ] **test_etcp_transport: таймаут восстановления REALITY-сессии** — 18.10.2026:
в общем `./check.sh` сценарий `reality=1 simultaneous=0 restart=0` завершился
`Session recovery timed out: phase=1 stream=15 control=6`. Перед таймаутом —
`record socket read failed: err=104`, клиент ждал server-handshake.
Отдельный повтор того же бинарника прошёл. Причина нестабильности пока не установлена;
изменения Android JNI/UI транспортные исходники не затрагивали.
[ ] **test_etcp_reconnect** — флаки под параллельной нагрузкой `make check -j4`:
phase 4 (reconnect после server restart) таймаутит (`sent=480 recv=0`), при одиночном
запуске стабильно `TEST PASSED`. Наш код (chat_join/chat_sync) его не трогает.

59
tests/test_android_replies.c

@ -8,6 +8,8 @@
#include "../src/utun_instance.h"
#include "../src/chat/chat_core.h"
#include "../src/chat/chat_event.h"
#include "../src/media_async/attachment_send.h"
#include "../src/media_async/encrypted_file.h"
#include "../src/routing_layer/topo_node_sqlite.h"
#include "../src/transport_layer/etcp_connections.h"
#include "../src/config_parser.h"
@ -52,6 +54,62 @@ static int check_reply(const char* channel, const char* original, const char* ty
u_free(json); return ok;
}
/* Настоящий контейнер проходит через bridge; ciphertext и manifest не становятся доступным файлом в UI. */
static int check_encrypted_attachment(const char* channel, const char* directory) {
char source[1024], value[1024];
snprintf(source, sizeof(source), "%s/private-source", directory);
FILE* file = fopen(source, "wb"); REQUIRE(file);
REQUIRE(fwrite("private image", 1, 13, file) == 13 && !fclose(file));
struct attachment_send_req* req = u_calloc(1, sizeof(*req)); REQUIRE(req);
req->inst = instance; req->encrypted = 1; req->request_id = 10;
snprintf(req->target, sizeof(req->target), "%s", channel);
snprintf(req->source, sizeof(req->source), "%s", source);
snprintf(req->info.name, sizeof(req->info.name), "Секрет | 😀.png");
snprintf(req->content_type, sizeof(req->content_type), "image/png");
req->info.kind = ATTACHMENT_FILE;
attachment_send_trampoline(req); REQUIRE(wait_result(10) == 1);
sqlite3_stmt* st = NULL; uint8_t id[32];
REQUIRE(sqlite3_prepare_v2(instance->topo_sqlite_db, "SELECT message_id FROM attachment_keys", -1, &st, NULL) == SQLITE_OK);
REQUIRE(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32);
memcpy(id, sqlite3_column_blob(st, 0), sizeof(id)); sqlite3_finalize(st);
char* json = utun_bridge_get_messages_json(channel, 1, id, 1); REQUIRE(json);
REQUIRE(!extract(json, "$[0].encryptionState", value, sizeof(value)) && !strcmp(value, "ready"));
REQUIRE(!extract(json, "$[0].text", value, sizeof(value)) && !strcmp(value, "Секрет | 😀.png"));
REQUIRE(!extract(json, "$[0].contentType", value, sizeof(value)) && !strcmp(value, "image/png"));
REQUIRE(!extract(json, "$[0].filePath", value, sizeof(value)) && !access(value, F_OK) && !strstr(value, ".uenc"));
REQUIRE(!extract(json, "$[0].fileSize", value, sizeof(value)) && !strcmp(value, "13"));
REQUIRE(!extract(json, "$[0].width", value, sizeof(value)) && !strcmp(value, "0"));
REQUIRE(!extract(json, "$[0].height", value, sizeof(value)) && !strcmp(value, "0")); u_free(json);
json = utun_bridge_get_channels_json(); REQUIRE(json);
REQUIRE(!extract(json, "$[0].lastMsgEncrypted", value, sizeof(value)) && !strcmp(value, "1"));
REQUIRE(!strstr(json, ".uenc")); u_free(json);
/* Состояния получателя читаются в одной транзакции, затем восстанавливается авторская запись. */
REQUIRE(sqlite3_exec(instance->topo_sqlite_db, "BEGIN", NULL, NULL, NULL) == SQLITE_OK);
const struct { const char* state; const char* sql; } states[] = {
{"download", "UPDATE attachment_keys SET ready=0,downloaded=0,key=NULL"},
{"requesting", "INSERT INTO attachment_key_requests SELECT channel,message_id,1,0,0,0 FROM attachment_keys"},
{"pending", "UPDATE attachment_key_requests SET state=1"},
{"denied", "UPDATE attachment_key_requests SET state=3"},
{"decrypting", "UPDATE attachment_key_requests SET state=1; UPDATE attachment_keys SET decrypting=1"},
{"error", "UPDATE attachment_keys SET error=1"},
{"error", "UPDATE attachment_keys SET error=0,ready=1,info=X'00'"},
{"download", "UPDATE attachment_keys SET ready=1,decrypting=0,plain_path='/nonexistent/android-private-file'"}
};
for (size_t i = 0; i < sizeof(states) / sizeof(states[0]); i++) {
REQUIRE(sqlite3_exec(instance->topo_sqlite_db, states[i].sql, NULL, NULL, NULL) == SQLITE_OK);
json = utun_bridge_get_messages_json(channel, 1, id, 1); REQUIRE(json);
REQUIRE(!extract(json, "$[0].encryptionState", value, sizeof(value)) && !strcmp(value, states[i].state));
REQUIRE(!extract(json, "$[0].text", value, sizeof(value)) && !value[0]);
REQUIRE(!extract(json, "$[0].filePath", value, sizeof(value)) && !value[0]);
REQUIRE(!extract(json, "$[0].contentType", value, sizeof(value)) && !strcmp(value, ENCRYPTED_ATTACHMENT_TYPE));
u_free(json);
}
REQUIRE(sqlite3_exec(instance->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
printf("[PASS] Android encrypted attachment: private metadata, access states and cache recovery\n");
return 0;
}
/* JSON адресов для Android сохраняет IPv4/IPv6, протокол, порт, socket_id и тип профиля. */
static int check_addresses(const char *channel) {
struct TOPO_SOCKMETA4 meta4 = {.id = 5, .config_type = CFG_SERVER_TYPE_PUBLIC};
@ -207,6 +265,7 @@ int main(void) {
/* Ссылка остаётся в JSON после перезапуска сервиса. */
chat_service_stop(instance); REQUIRE(!chat_service_start(instance));
REQUIRE(check_reply(channel, hex, "audio/opus"));
REQUIRE(!check_encrypted_attachment(channel, directory));
utun_bridge_destroy();
utun_instance_destroy(instance); uasync_destroy(async, 0);
printf("test_android_replies: text, media, voice, results, history and restart PASS\n");

45
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -54,7 +54,7 @@ fun parseNodeStatus(data: ByteArray): NodeStatus? {
}
data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "",
val peersOnline: Int = 0)
val peersOnline: Int = 0, val lastMsgEncrypted: Boolean = false)
data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "",
val lastTs: Long = 0, val online: Boolean = false, val generation: Long = 1) {
@ -68,7 +68,11 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0,
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 messageId: String = "", val feedback: FeedbackState? = null, val replyTo: String = "",
val encryptionState: String = "") {
val isEncrypted: Boolean get() = encryptionState.isNotEmpty()
val isLocked: Boolean get() = isEncrypted && encryptionState != "ready"
}
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
val isAdmin: Boolean = false, val isSuper: Boolean = false,
@ -286,7 +290,8 @@ class ChatRepository {
name = obj.optString("name", obj.getString("id")),
lastMsgAt = obj.optLong("lastMsgAt", 0),
lastMsg = obj.optString("lastMsg", ""),
peersOnline = obj.optInt("peersOnline", 0)
peersOnline = obj.optInt("peersOnline", 0),
lastMsgEncrypted = obj.optBoolean("lastMsgEncrypted")
))
}
} catch (e: Exception) { Log.w("utun-gui", "getChannels error", e) }
@ -313,19 +318,23 @@ class ChatRepository {
val text = obj.optString("text", "")
val contentType = obj.optString("contentType", "text/plain")
val filePath = obj.optString("filePath", "")
val encryptionState = obj.optString("encryptionState")
val encrypted = encryptionState.isNotEmpty()
var displayText = text
var fileSize = 0L
var fileSize = obj.optLong("fileSize")
var downloadState = ""
val localAttrs = obj.optString("localAttrs", "")
if (filePath.isNotEmpty()) downloadState = "downloaded"
else if (localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded"
else if (!encrypted && localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded"
else if (localAttrs.contains("\"st\":\"er\"")) downloadState = "error"
var waveform = emptyList<Float>()
var voiceDurationMs = 0
if (contentType == "audio/opus" && text.startsWith("wf=")) {
var waveform = obj.optString("waveform").map {
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf(it).coerceAtLeast(0) / 63f
}
var voiceDurationMs = obj.optInt("durationMs")
if (!encrypted && contentType == "audio/opus" && text.startsWith("wf=")) {
try {
val wfEnd = text.indexOf(";dur=")
if (wfEnd > 0) {
@ -356,7 +365,7 @@ class ChatRepository {
} catch (_: Exception) {}
}
if (contentType == "application/octet-stream" || contentType.startsWith("video/") || contentType.startsWith("image/")) {
if (!encrypted && (contentType == "application/octet-stream" || contentType.startsWith("video/") || contentType.startsWith("image/"))) {
try {
val pipeIdx = text.indexOf('|')
if (pipeIdx > 0) {
@ -368,9 +377,9 @@ class ChatRepository {
} catch (_: Exception) {}
}
var imageWidth = 0
var imageHeight = 0
if (contentType.startsWith("image/")) {
var imageWidth = obj.optInt("width")
var imageHeight = obj.optInt("height")
if (!encrypted && contentType.startsWith("image/")) {
try {
for (part in text.substringAfter('|').split("|")) {
when {
@ -381,10 +390,10 @@ class ChatRepository {
} catch (_: Exception) {}
}
var videoWidth = 0
var videoHeight = 0
var videoDurationMs = 0
if (contentType.startsWith("video/")) {
var videoWidth = obj.optInt("width")
var videoHeight = obj.optInt("height")
var videoDurationMs = obj.optInt("durationMs")
if (!encrypted && contentType.startsWith("video/")) {
try {
for (part in text.substringAfter('|').split("|")) {
when {
@ -415,7 +424,9 @@ class ChatRepository {
videoWidth = videoWidth,
videoHeight = videoHeight,
videoDurationMs = videoDurationMs,
downloadState = downloadState
downloadState = downloadState,
encryptionState = encryptionState,
deliveryStatusRes = if (encryptionState == "ready") R.string.attachment_encrypted else 0
))
}
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }

27
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -193,6 +193,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
Spacer(Modifier.height(2.dp))
when {
message.isLocked -> EncryptedAttachmentBubble(message, textColor, onDownloadFile)
message.inviteGroupName != null -> {
val clipboard = LocalClipboardManager.current
LaunchedEffect(message.id, message.inviteGroupName) {
@ -223,6 +224,32 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
}
}
}
@Composable
private fun EncryptedAttachmentBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit) {
val canDownload = message.encryptionState == "download" || message.encryptionState == "error"
val status = when {
message.downloadState == "error" -> R.string.download_failed_retry
message.downloadState == "downloading" -> R.string.downloading
message.encryptionState == "download" -> R.string.attachment_encrypted_download
message.encryptionState == "requesting" -> R.string.attachment_key_requesting
message.encryptionState == "pending" -> R.string.attachment_key_pending
message.encryptionState == "denied" -> R.string.attachment_key_denied
message.encryptionState == "decrypting" -> R.string.attachment_decrypting
else -> R.string.attachment_decrypt_failed
}
LaunchedEffect(message.messageId, message.encryptionState, message.downloadState) {
LogManager.addLog("DEBUG", "Attachment", "encrypted card id=${message.messageId} " +
"state=${message.encryptionState} download=${message.downloadState} action=$canDownload")
}
Column(Modifier.clickable(enabled = canDownload) { onDownload(message) }) {
Text("🔒 " + stringResource(R.string.attachment_encrypted_title), fontSize = 15.sp,
color = textColor, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(2.dp))
Text(listOf(formatFileSize(message.fileSize), stringResource(status)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 12.sp, color = ChatColors.Secondary)
}
}
@Composable
private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
ps: ChatViewModel.PlaybackState,

1
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt

@ -51,6 +51,7 @@ internal fun Modifier.messageLongPress(label: String, onLongPress: () -> Unit):
fun ReplyQuote(original: Message?, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) {
val preview = when {
original == null -> stringResource(R.string.reply_original_missing)
original.isLocked -> stringResource(R.string.attachment_encrypted_title)
original.contentType == "audio/opus" -> stringResource(R.string.reply_voice)
original.contentType.startsWith("image/") -> stringResource(R.string.photo)
original.contentType.startsWith("video/") -> stringResource(R.string.video_message)

4
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt

@ -175,8 +175,8 @@ fun ChannelListScreen(
Row(verticalAlignment = Alignment.CenterVertically) {
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
if (ch.lastMsg.isNotEmpty())
Text(ch.lastMsg, fontSize = 13.sp,
if (ch.lastMsgEncrypted || ch.lastMsg.isNotEmpty())
Text(if (ch.lastMsgEncrypted) stringResource(R.string.attachment_encrypted_title) else ch.lastMsg, fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}

10
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -534,6 +534,16 @@ class ChatViewModel : ViewModel() {
refreshChannels()
}
}
60 -> { /* ATTACHMENT_KEYS_CHANGED: [channel_len:1][channel][success?:1] */
val size = data?.firstOrNull()?.toInt()?.and(0xFF) ?: 0
if (size == 0 || data == null || data.size < 1 + size) {
LogManager.addLog("ERROR", "Attachment", "invalid encryption state event bytes=${data?.size}")
return
}
val channel = String(data, 1, size, Charsets.UTF_8)
LogManager.addLog("DEBUG", "Attachment", "encryption state changed ch=$channel")
if (_currentChannel.value?.id == channel) refreshMessages(channel)
}
2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */
if (repo == null || data == null || data.size < 20) { LogManager.addLog("WARN", "VM", "CONNECT_RESULT skipped repo=${repo != null} data=${data != null}"); return }
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)

8
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -498,4 +498,12 @@
<string name="stream_settings_invalid">Всего субблоков: 1–255. Избыточных: от 0 до общего числа минус один.</string>
<string name="stream_settings_save_failed">Не удалось сохранить настройки трансляции.</string>
<string name="stream_settings_saved">Сохранено. Настройки применятся при следующем запуске трансляции.</string>
<string name="attachment_encrypted_title">Зашифрованное вложение</string>
<string name="attachment_encrypted">Зашифровано</string>
<string name="attachment_encrypted_download">Нажмите, чтобы скачать и запросить доступ</string>
<string name="attachment_key_requesting">Отправка запроса ключа…</string>
<string name="attachment_key_pending">Ожидание разрешения автора</string>
<string name="attachment_key_denied">Автор отказал в доступе</string>
<string name="attachment_decrypting">Расшифровка…</string>
<string name="attachment_decrypt_failed">Ошибка расшифровки. Нажмите для повтора</string>
</resources>

8
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -497,4 +497,12 @@
<string name="stream_settings_invalid">Total subblocks: 1–255. Redundant subblocks: 0 to total minus one.</string>
<string name="stream_settings_save_failed">Failed to save broadcast settings.</string>
<string name="stream_settings_saved">Saved. Settings apply to the next broadcast.</string>
<string name="attachment_encrypted_title">Encrypted attachment</string>
<string name="attachment_encrypted">Encrypted</string>
<string name="attachment_encrypted_download">Tap to download and request access</string>
<string name="attachment_key_requesting">Sending key request…</string>
<string name="attachment_key_pending">Awaiting author’s approval</string>
<string name="attachment_key_denied">Access denied by author</string>
<string name="attachment_decrypting">Decrypting…</string>
<string name="attachment_decrypt_failed">Decryption failed. Tap to retry</string>
</resources>

76
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -27,6 +27,7 @@
#include "../../../lib/mem.h"
#include "../../../lib/sqlite3.h"
#include "../../../src/chat/chat_core.h"
#include "../../../src/media_async/encrypted_file.h"
#include "../../../src/chat/chat_event.h"
#include "../../../src/chat/chat_poll.h"
#include "../../../lib/strbuf.h"
@ -48,6 +49,7 @@
#include <stdarg.h>
#include <limits.h>
#include <unistd.h>
#include <sys/stat.h>
#ifdef __ANDROID__
#include <jni.h>
@ -372,6 +374,7 @@ static void bridge_get_node_name(sqlite3* db, uint64_t node_id, const char* chan
/* Формирует человекочитаемое тело превью последнего сообщения по content_type и "d"-полю. */
static void bridge_format_preview_body(const char* ct, const char* d, char* out, size_t out_sz) {
if (ct && !strcmp(ct, ENCRYPTED_ATTACHMENT_TYPE)) { out[0] = 0; return; }
int is_text = (!ct || !ct[0] || strcmp(ct, "text/plain") == 0 || strcmp(ct, "voice_transcription") == 0);
if (is_text) {
size_t tl = strlen(d);
@ -442,6 +445,7 @@ char* utun_bridge_get_channels_json(void) {
if (!ch_id) continue;
char tbl_msg[80], tbl_peers[80], last_msg[160];
int last_encrypted = 0;
msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg));
peers_table_name(ch_id, tbl_peers, sizeof(tbl_peers));
last_msg[0] = '\0';
@ -460,6 +464,7 @@ char* utun_bridge_get_channels_json(void) {
char ct[32] = "text/plain";
const char* cts = strstr(dp, "\"ct\":\"");
if (cts) { cts += 6; char* cte = strchr((char*)cts, '"'); if (cte) { size_t tlc = (size_t)(cte - cts); if (tlc < sizeof(ct)) { memcpy(ct, cts, tlc); ct[tlc] = '\0'; } } }
last_encrypted = !strcmp(ct, ENCRYPTED_ATTACHMENT_TYPE);
const char* ds = strstr(dp, "\"d\":\"");
if (ds) { ds += 5; char* de = strchr((char*)ds, '"'); if (de) { size_t tl = (size_t)(de - ds); if (tl > 511) tl = 511; char d[512]; memcpy(d, ds, tl); d[tl] = '\0'; bridge_format_preview_body(ct, d, body, sizeof(body)); } }
}
@ -491,12 +496,14 @@ char* utun_bridge_get_channels_json(void) {
const char* sep = first ? "" : ",";
first = 0;
size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}",
sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online);
size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\","
"\"peersOnline\":%d,\"lastMsgEncrypted\":%s}", sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online,
last_encrypted ? "true" : "false");
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}",
sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online);
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\","
"\"peersOnline\":%d,\"lastMsgEncrypted\":%s}", sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online,
last_encrypted ? "true" : "false");
u_free(esc_msg);
u_free(esc_name);
}
@ -518,17 +525,21 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin
"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);
snprintf(sql, sizeof(sql),
"SELECT id,node_id,timestamp,local_attrs,message_id,"
"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"
" FROM (%s) ORDER BY timestamp,author_signature", selection);
"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"
" 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);
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("[]");
}
sqlite3_bind_int(st, 1, limit > 0 ? limit : 50);
if (anchor) sqlite3_bind_blob(st, 2, anchor, 32, SQLITE_STATIC);
sqlite3_bind_text(st, 3, channel_id, -1, SQLITE_STATIC);
struct strbuf out = strbuf_new(); int step, result = strbuf_addf(&out, "[");
unsigned count = 0;
while (result >= 0 && (step = sqlite3_step(st)) == SQLITE_ROW) {
@ -542,8 +553,9 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin
char message_id[65], text[DB_SYNC_MESSAGE_MAX + 1];
for (unsigned i = 0; i < 32; i++) snprintf(message_id + 2 * i, 3, "%02x", id[i]);
snprintf(text, sizeof(text), "%s", body ? body : ""); if (!type) type = "text/plain";
int encrypted = !strcmp(type, ENCRYPTED_ATTACHMENT_TYPE);
/* У вложений '*' кодирует отображаемое имя; poll использует отдельный b64: формат. */
if (strcmp(type, "text/plain") && text[0] == '*') {
if (!encrypted && strcmp(type, "text/plain") && text[0] == '*') {
char* pipe = strchr(text, '|');
if (pipe) {
uint8_t decoded[1024]; size_t bytes = b64_decode(text + 1, pipe - text - 1, decoded, sizeof(decoded));
@ -554,7 +566,7 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin
char author[128], file_path[1024] = "";
bridge_get_node_name(db, (uint64_t)node_id, channel_id, author, sizeof(author));
if (!author[0]) snprintf(author, sizeof(author), "0x%016llx", (unsigned long long)node_id);
if (attrs) {
if (!encrypted && attrs) {
sqlite3_stmt* file = NULL;
if (sqlite3_prepare_v2(db, "SELECT CASE WHEN json_valid(?1) THEN json_extract(?1,'$.fp') END", -1, &file, NULL) == SQLITE_OK) {
sqlite3_bind_text(file, 1, attrs, -1, SQLITE_STATIC);
@ -568,6 +580,46 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin
sqlite3_finalize(file);
} else DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: local attributes query: %s", sqlite3_errmsg(db));
}
const char* encryption_state = "";
struct attachment_info info = {0};
uint64_t file_size = 0;
if (encrypted) {
/* Шифротекст никогда не передаём UI как готовый файл; метаданные доступны только после decrypt. */
const char* pipe = body ? strchr(body, '|') : NULL;
if (pipe) file_size = strtoull(pipe + 1, NULL, 10);
text[0] = 0;
const char* path = (const char*)sqlite3_column_text(st, 12);
const char* plain_type = (const char*)sqlite3_column_text(st, 14);
const int ready = sqlite3_column_int(st, 8), downloaded = sqlite3_column_int(st, 9);
const int decrypting = sqlite3_column_int(st, 10), error = sqlite3_column_int(st, 11);
const int state = sqlite3_column_int(st, 15), key = sqlite3_column_int(st, 16);
struct stat file;
int available = ready && path && !stat(path, &file) && S_ISREG(file.st_mode);
if (available && (!plain_type || !plain_type[0] || attachment_decode(sqlite3_column_blob(st, 13),
sqlite3_column_bytes(st, 13), &info))) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "bridge: encrypted metadata invalid ch=%s id=%lld", channel_id, (long long)local_id);
available = 0; encryption_state = "error";
}
if (available) {
int n = snprintf(file_path, sizeof(file_path), "%s", path);
if (n >= (int)sizeof(file_path)) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "bridge: decrypted path too long ch=%s id=%lld", channel_id, (long long)local_id);
file_path[0] = 0; encryption_state = "error";
} else {
encryption_state = "ready"; type = plain_type; file_size = (uint64_t)file.st_size;
snprintf(text, sizeof(text), "%s", info.name);
}
} else if (!encryption_state[0]) {
if (error) encryption_state = "error";
else if (state == 3) encryption_state = "denied";
else if (decrypting) encryption_state = "decrypting";
else if (ready) encryption_state = "download"; /* Открытая копия могла быть удалена кешем. */
else if (downloaded && key) encryption_state = "decrypting";
else if (state == 1) encryption_state = "pending";
else if (state == 0 || downloaded) encryption_state = "requesting";
else encryption_state = "download";
}
}
result = strbuf_addf(&out, "%s{\"id\":%lld,\"messageId\":\"%s\",\"authorNodeId\":%lld,\"author\":",
count++ ? "," : "", (long long)local_id, message_id, (long long)node_id);
if (result >= 0) result = strbuf_add_json_string(&out, author);
@ -582,6 +634,12 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin
if (result >= 0) result = strbuf_add_json_string(&out, attrs);
if (result >= 0) result = strbuf_addf(&out, ",\"replyTo\":");
if (result >= 0) result = strbuf_add_json_string(&out, (const char*)sqlite3_column_text(st, 7));
if (result >= 0 && encrypted) {
result = strbuf_addf(&out, ",\"encryptionState\":\"%s\",\"fileSize\":%llu,\"durationMs\":%u,"
"\"width\":%u,\"height\":%u,\"waveform\":", encryption_state, (unsigned long long)file_size,
info.duration_ms, info.width, info.height);
if (result >= 0) result = strbuf_add_json_string(&out, info.waveform);
}
if (result >= 0) result = strbuf_addf(&out, "}");
}
sqlite3_finalize(st);

Loading…
Cancel
Save