Render encrypted group attachments correctly on Android
This commit is contained in:
1 parent
b3495349e1
commit
aad295c9d0
11 files changed
+223
-28
No files matched your search
@@ -6,6 +6,12 @@
|
|||||||
флажок. Выбор фиксируется вместе с адресатом при отправке, до подготовки.
|
флажок. Выбор фиксируется вместе с адресатом при отправке, до подготовки.
|
||||||
В DM флажок скрыт: используется существующее автоматическое E2E-шифрование лички.
|
В DM флажок скрыт: используется существующее автоматическое E2E-шифрование лички.
|
||||||
|
|
||||||
|
Android показывает зашифрованное вложение отдельной карточкой со статусом запроса
|
||||||
|
ключа. После одобрения карточка обновляется на исходный файл, изображение, видео
|
||||||
|
или голосовое. В интерфейс передаётся только расшифрованный путь; контейнер `.uenc`
|
||||||
|
нельзя открыть или сохранить как готовое вложение. Цитаты и список чатов показывают
|
||||||
|
название «Зашифрованное вложение» вместо manifest.
|
||||||
|
|
||||||
Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую
|
Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую
|
||||||
подготовку. Ключ записывается в локальную SQLite до шифрования. Готовый контейнер
|
подготовку. Ключ записывается в локальную SQLite до шифрования. Готовый контейнер
|
||||||
передаётся через обычные media_index/media_delivery. UUID транспортного медиа
|
передаётся через обычные media_index/media_delivery. UUID транспортного медиа
|
||||||
|
|||||||
@@ -934,6 +934,13 @@
|
|||||||
Двойной free по TIME_WAIT уже закрыт (detach по локальному закрытию + guard abort).
|
Двойной 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`:
|
[ ] **test_etcp_reconnect** — флаки под параллельной нагрузкой `make check -j4`:
|
||||||
phase 4 (reconnect после server restart) таймаутит (`sent=480 recv=0`), при одиночном
|
phase 4 (reconnect после server restart) таймаутит (`sent=480 recv=0`), при одиночном
|
||||||
запуске стабильно `TEST PASSED`. Наш код (chat_join/chat_sync) его не трогает.
|
запуске стабильно `TEST PASSED`. Наш код (chat_join/chat_sync) его не трогает.
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
#include "../src/utun_instance.h"
|
#include "../src/utun_instance.h"
|
||||||
#include "../src/chat/chat_core.h"
|
#include "../src/chat/chat_core.h"
|
||||||
#include "../src/chat/chat_event.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/routing_layer/topo_node_sqlite.h"
|
||||||
#include "../src/transport_layer/etcp_connections.h"
|
#include "../src/transport_layer/etcp_connections.h"
|
||||||
#include "../src/config_parser.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;
|
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 и тип профиля. */
|
/* JSON адресов для Android сохраняет IPv4/IPv6, протокол, порт, socket_id и тип профиля. */
|
||||||
static int check_addresses(const char *channel) {
|
static int check_addresses(const char *channel) {
|
||||||
struct TOPO_SOCKMETA4 meta4 = {.id = 5, .config_type = CFG_SERVER_TYPE_PUBLIC};
|
struct TOPO_SOCKMETA4 meta4 = {.id = 5, .config_type = CFG_SERVER_TYPE_PUBLIC};
|
||||||
@@ -207,6 +265,7 @@ int main(void) {
|
|||||||
/* Ссылка остаётся в JSON после перезапуска сервиса. */
|
/* Ссылка остаётся в JSON после перезапуска сервиса. */
|
||||||
chat_service_stop(instance); REQUIRE(!chat_service_start(instance));
|
chat_service_stop(instance); REQUIRE(!chat_service_start(instance));
|
||||||
REQUIRE(check_reply(channel, hex, "audio/opus"));
|
REQUIRE(check_reply(channel, hex, "audio/opus"));
|
||||||
|
REQUIRE(!check_encrypted_attachment(channel, directory));
|
||||||
utun_bridge_destroy();
|
utun_bridge_destroy();
|
||||||
utun_instance_destroy(instance); uasync_destroy(async, 0);
|
utun_instance_destroy(instance); uasync_destroy(async, 0);
|
||||||
printf("test_android_replies: text, media, voice, results, history and restart PASS\n");
|
printf("test_android_replies: text, media, voice, results, history and restart PASS\n");
|
||||||
|
|||||||
@@ -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 = "",
|
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 = "",
|
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) {
|
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 videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0,
|
||||||
val downloadState: String = "", /* "", "downloading", "downloaded", "error" */
|
val downloadState: String = "", /* "", "downloading", "downloaded", "error" */
|
||||||
val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0,
|
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,
|
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
|
||||||
val isAdmin: Boolean = false, val isSuper: Boolean = false,
|
val isAdmin: Boolean = false, val isSuper: Boolean = false,
|
||||||
@@ -286,7 +290,8 @@ class ChatRepository {
|
|||||||
name = obj.optString("name", obj.getString("id")),
|
name = obj.optString("name", obj.getString("id")),
|
||||||
lastMsgAt = obj.optLong("lastMsgAt", 0),
|
lastMsgAt = obj.optLong("lastMsgAt", 0),
|
||||||
lastMsg = obj.optString("lastMsg", ""),
|
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) }
|
} catch (e: Exception) { Log.w("utun-gui", "getChannels error", e) }
|
||||||
@@ -313,19 +318,23 @@ class ChatRepository {
|
|||||||
val text = obj.optString("text", "")
|
val text = obj.optString("text", "")
|
||||||
val contentType = obj.optString("contentType", "text/plain")
|
val contentType = obj.optString("contentType", "text/plain")
|
||||||
val filePath = obj.optString("filePath", "")
|
val filePath = obj.optString("filePath", "")
|
||||||
|
val encryptionState = obj.optString("encryptionState")
|
||||||
|
val encrypted = encryptionState.isNotEmpty()
|
||||||
|
|
||||||
var displayText = text
|
var displayText = text
|
||||||
var fileSize = 0L
|
var fileSize = obj.optLong("fileSize")
|
||||||
|
|
||||||
var downloadState = ""
|
var downloadState = ""
|
||||||
val localAttrs = obj.optString("localAttrs", "")
|
val localAttrs = obj.optString("localAttrs", "")
|
||||||
if (filePath.isNotEmpty()) downloadState = "downloaded"
|
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"
|
else if (localAttrs.contains("\"st\":\"er\"")) downloadState = "error"
|
||||||
|
|
||||||
var waveform = emptyList<Float>()
|
var waveform = obj.optString("waveform").map {
|
||||||
var voiceDurationMs = 0
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf(it).coerceAtLeast(0) / 63f
|
||||||
if (contentType == "audio/opus" && text.startsWith("wf=")) {
|
}
|
||||||
|
var voiceDurationMs = obj.optInt("durationMs")
|
||||||
|
if (!encrypted && contentType == "audio/opus" && text.startsWith("wf=")) {
|
||||||
try {
|
try {
|
||||||
val wfEnd = text.indexOf(";dur=")
|
val wfEnd = text.indexOf(";dur=")
|
||||||
if (wfEnd > 0) {
|
if (wfEnd > 0) {
|
||||||
@@ -356,7 +365,7 @@ class ChatRepository {
|
|||||||
} catch (_: Exception) {}
|
} 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 {
|
try {
|
||||||
val pipeIdx = text.indexOf('|')
|
val pipeIdx = text.indexOf('|')
|
||||||
if (pipeIdx > 0) {
|
if (pipeIdx > 0) {
|
||||||
@@ -368,9 +377,9 @@ class ChatRepository {
|
|||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageWidth = 0
|
var imageWidth = obj.optInt("width")
|
||||||
var imageHeight = 0
|
var imageHeight = obj.optInt("height")
|
||||||
if (contentType.startsWith("image/")) {
|
if (!encrypted && contentType.startsWith("image/")) {
|
||||||
try {
|
try {
|
||||||
for (part in text.substringAfter('|').split("|")) {
|
for (part in text.substringAfter('|').split("|")) {
|
||||||
when {
|
when {
|
||||||
@@ -381,10 +390,10 @@ class ChatRepository {
|
|||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
var videoWidth = 0
|
var videoWidth = obj.optInt("width")
|
||||||
var videoHeight = 0
|
var videoHeight = obj.optInt("height")
|
||||||
var videoDurationMs = 0
|
var videoDurationMs = obj.optInt("durationMs")
|
||||||
if (contentType.startsWith("video/")) {
|
if (!encrypted && contentType.startsWith("video/")) {
|
||||||
try {
|
try {
|
||||||
for (part in text.substringAfter('|').split("|")) {
|
for (part in text.substringAfter('|').split("|")) {
|
||||||
when {
|
when {
|
||||||
@@ -415,7 +424,9 @@ class ChatRepository {
|
|||||||
videoWidth = videoWidth,
|
videoWidth = videoWidth,
|
||||||
videoHeight = videoHeight,
|
videoHeight = videoHeight,
|
||||||
videoDurationMs = videoDurationMs,
|
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) }
|
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
|||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
|
|
||||||
when {
|
when {
|
||||||
|
message.isLocked -> EncryptedAttachmentBubble(message, textColor, onDownloadFile)
|
||||||
message.inviteGroupName != null -> {
|
message.inviteGroupName != null -> {
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
LaunchedEffect(message.id, message.inviteGroupName) {
|
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
|
@Composable
|
||||||
private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
|
private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
|
||||||
ps: ChatViewModel.PlaybackState,
|
ps: ChatViewModel.PlaybackState,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ internal fun Modifier.messageLongPress(label: String, onLongPress: () -> Unit):
|
|||||||
fun ReplyQuote(original: Message?, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) {
|
fun ReplyQuote(original: Message?, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) {
|
||||||
val preview = when {
|
val preview = when {
|
||||||
original == null -> stringResource(R.string.reply_original_missing)
|
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 == "audio/opus" -> stringResource(R.string.reply_voice)
|
||||||
original.contentType.startsWith("image/") -> stringResource(R.string.photo)
|
original.contentType.startsWith("image/") -> stringResource(R.string.photo)
|
||||||
original.contentType.startsWith("video/") -> stringResource(R.string.video_message)
|
original.contentType.startsWith("video/") -> stringResource(R.string.video_message)
|
||||||
|
|||||||
+2
-2
@@ -175,8 +175,8 @@ fun ChannelListScreen(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
}
|
}
|
||||||
if (ch.lastMsg.isNotEmpty())
|
if (ch.lastMsgEncrypted || ch.lastMsg.isNotEmpty())
|
||||||
Text(ch.lastMsg, fontSize = 13.sp,
|
Text(if (ch.lastMsgEncrypted) stringResource(R.string.attachment_encrypted_title) else ch.lastMsg, fontSize = 13.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -534,6 +534,16 @@ class ChatViewModel : ViewModel() {
|
|||||||
refreshChannels()
|
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] */
|
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 }
|
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)
|
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|||||||
@@ -498,4 +498,12 @@
|
|||||||
<string name="stream_settings_invalid">Всего субблоков: 1–255. Избыточных: от 0 до общего числа минус один.</string>
|
<string name="stream_settings_invalid">Всего субблоков: 1–255. Избыточных: от 0 до общего числа минус один.</string>
|
||||||
<string name="stream_settings_save_failed">Не удалось сохранить настройки трансляции.</string>
|
<string name="stream_settings_save_failed">Не удалось сохранить настройки трансляции.</string>
|
||||||
<string name="stream_settings_saved">Сохранено. Настройки применятся при следующем запуске трансляции.</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>
|
</resources>
|
||||||
@@ -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_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_save_failed">Failed to save broadcast settings.</string>
|
||||||
<string name="stream_settings_saved">Saved. Settings apply to the next broadcast.</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>
|
</resources>
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
#include "../../../lib/mem.h"
|
#include "../../../lib/mem.h"
|
||||||
#include "../../../lib/sqlite3.h"
|
#include "../../../lib/sqlite3.h"
|
||||||
#include "../../../src/chat/chat_core.h"
|
#include "../../../src/chat/chat_core.h"
|
||||||
|
#include "../../../src/media_async/encrypted_file.h"
|
||||||
#include "../../../src/chat/chat_event.h"
|
#include "../../../src/chat/chat_event.h"
|
||||||
#include "../../../src/chat/chat_poll.h"
|
#include "../../../src/chat/chat_poll.h"
|
||||||
#include "../../../lib/strbuf.h"
|
#include "../../../lib/strbuf.h"
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
#ifdef __ANDROID__
|
#ifdef __ANDROID__
|
||||||
#include <jni.h>
|
#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"-полю. */
|
/* Формирует человекочитаемое тело превью последнего сообщения по content_type и "d"-полю. */
|
||||||
static void bridge_format_preview_body(const char* ct, const char* d, char* out, size_t out_sz) {
|
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);
|
int is_text = (!ct || !ct[0] || strcmp(ct, "text/plain") == 0 || strcmp(ct, "voice_transcription") == 0);
|
||||||
if (is_text) {
|
if (is_text) {
|
||||||
size_t tl = strlen(d);
|
size_t tl = strlen(d);
|
||||||
@@ -442,6 +445,7 @@ char* utun_bridge_get_channels_json(void) {
|
|||||||
if (!ch_id) continue;
|
if (!ch_id) continue;
|
||||||
|
|
||||||
char tbl_msg[80], tbl_peers[80], last_msg[160];
|
char tbl_msg[80], tbl_peers[80], last_msg[160];
|
||||||
|
int last_encrypted = 0;
|
||||||
msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg));
|
msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg));
|
||||||
peers_table_name(ch_id, tbl_peers, sizeof(tbl_peers));
|
peers_table_name(ch_id, tbl_peers, sizeof(tbl_peers));
|
||||||
last_msg[0] = '\0';
|
last_msg[0] = '\0';
|
||||||
@@ -460,6 +464,7 @@ char* utun_bridge_get_channels_json(void) {
|
|||||||
char ct[32] = "text/plain";
|
char ct[32] = "text/plain";
|
||||||
const char* cts = strstr(dp, "\"ct\":\"");
|
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'; } } }
|
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\":\"");
|
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)); } }
|
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 ? "" : ",";
|
const char* sep = first ? "" : ",";
|
||||||
first = 0;
|
first = 0;
|
||||||
|
|
||||||
size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}",
|
size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\","
|
||||||
sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online);
|
"\"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; }
|
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
|
||||||
if (pos + needed + 2 <= cap)
|
if (pos + needed + 2 <= cap)
|
||||||
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}",
|
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\","
|
||||||
sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online);
|
"\"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_msg);
|
||||||
u_free(esc_name);
|
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);
|
"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,author_signature DESC LIMIT ?1", table);
|
||||||
snprintf(sql, sizeof(sql),
|
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),'$.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),'$.d') END,"
|
||||||
"CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.reply_to') 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);
|
"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;
|
sqlite3_stmt* st = NULL;
|
||||||
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
|
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("[]");
|
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);
|
sqlite3_bind_int(st, 1, limit > 0 ? limit : 50);
|
||||||
if (anchor) sqlite3_bind_blob(st, 2, anchor, 32, SQLITE_STATIC);
|
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, "[");
|
struct strbuf out = strbuf_new(); int step, result = strbuf_addf(&out, "[");
|
||||||
unsigned count = 0;
|
unsigned count = 0;
|
||||||
while (result >= 0 && (step = sqlite3_step(st)) == SQLITE_ROW) {
|
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];
|
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]);
|
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";
|
snprintf(text, sizeof(text), "%s", body ? body : ""); if (!type) type = "text/plain";
|
||||||
|
int encrypted = !strcmp(type, ENCRYPTED_ATTACHMENT_TYPE);
|
||||||
/* У вложений '*' кодирует отображаемое имя; poll использует отдельный b64: формат. */
|
/* У вложений '*' кодирует отображаемое имя; poll использует отдельный b64: формат. */
|
||||||
if (strcmp(type, "text/plain") && text[0] == '*') {
|
if (!encrypted && strcmp(type, "text/plain") && text[0] == '*') {
|
||||||
char* pipe = strchr(text, '|');
|
char* pipe = strchr(text, '|');
|
||||||
if (pipe) {
|
if (pipe) {
|
||||||
uint8_t decoded[1024]; size_t bytes = b64_decode(text + 1, pipe - text - 1, decoded, sizeof(decoded));
|
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] = "";
|
char author[128], file_path[1024] = "";
|
||||||
bridge_get_node_name(db, (uint64_t)node_id, channel_id, author, sizeof(author));
|
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 (!author[0]) snprintf(author, sizeof(author), "0x%016llx", (unsigned long long)node_id);
|
||||||
if (attrs) {
|
if (!encrypted && attrs) {
|
||||||
sqlite3_stmt* file = NULL;
|
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) {
|
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);
|
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);
|
sqlite3_finalize(file);
|
||||||
} else DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: local attributes query: %s", sqlite3_errmsg(db));
|
} 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\":",
|
result = strbuf_addf(&out, "%s{\"id\":%lld,\"messageId\":\"%s\",\"authorNodeId\":%lld,\"author\":",
|
||||||
count++ ? "," : "", (long long)local_id, message_id, (long long)node_id);
|
count++ ? "," : "", (long long)local_id, message_id, (long long)node_id);
|
||||||
if (result >= 0) result = strbuf_add_json_string(&out, author);
|
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_add_json_string(&out, attrs);
|
||||||
if (result >= 0) result = strbuf_addf(&out, ",\"replyTo\":");
|
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) 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, "}");
|
if (result >= 0) result = strbuf_addf(&out, "}");
|
||||||
}
|
}
|
||||||
sqlite3_finalize(st);
|
sqlite3_finalize(st);
|
||||||
|
|||||||
Reference in new issue
Block a user