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
@@ -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)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user