Render encrypted group attachments correctly on Android

This commit is contained in:
evgeny committed 2026-10-18 13:14:56 +02:00
1 parent b3495349e1
commit aad295c9d0
11 files changed
+223 -28

No files matched your search

@@ -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) }
@@ -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,
@@ -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)
@@ -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)
}
}
@@ -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)