feature: media auto-download + manual download + UI download state indication (Android + desktop)

C-layer:
- chat_event.h: CHAT_EVT_ATTACHMENT_DOWNLOADED (14)
- chat_msg.c: fixed md_auto_download (non-NULL done_cb, correct media_base from db_path, use chat_setting for autoload checks)
- chat_msg.c: added chat_core_attachment_download + trampoline for manual download
- chat_core.h: exported attachment_dl_req + trampoline

Android (Kotlin + JNI):
- Message: downloadState field (parse from localAttrs)
- ChatRepository: parse localAttrs for download state
- NativeLib: attachmentDownload() JNI
- MessageBubble: FileBubble with download status icon + tap-to-download
- ChatViewModel: downloadAttachment() + CHAT_EVT_ATTACHMENT_DOWNLOADED handler
- ChatScreen: wire onDownloadFile handler
- android_jni_bridge: utun_bridge_attachment_download + JNI + localAttrs in JSON

Desktop (Qt/C++):
- messagedelegate.h: MsgFileDownloadStateRole, MsgChannelIdRole
- messagelist.cpp: parse localAttrs for file download state
- messagedelegate.cpp: FileBubble with download icon/text + click handler via chat_core_attachment_download_trampoline
- gui_bridge.h: GUI_EVT_ATTACHMENT_DOWNLOADED (14) + callback typedef
- gui_bridge_impl.cpp: event dispatch + setter
- mainwindow.cpp: callback registration -> refresh message list
This commit is contained in:
evgeny committed 2026-07-30 00:01:38 +03:00
1 parent b8920cf483
commit 70797f921e
23 files changed
+558 -174

No files matched your search

@@ -9,7 +9,8 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
val isOutgoing: Boolean = false, val contentType: String = "text/plain",
val filePath: String = "", val waveform: List<Float> = emptyList(),
val voiceDurationMs: Int = 0, val fileSize: Long = 0)
val voiceDurationMs: Int = 0, val fileSize: Long = 0,
val downloadState: String = "" /* "", "downloading", "downloaded", "error" */)
class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId()
@@ -56,6 +57,12 @@ class ChatRepository {
var displayText = text
var fileSize = 0L
/* Parse download state from localAttrs */
var downloadState = ""
val localAttrs = obj.optString("localAttrs", "")
if (filePath.isNotEmpty()) downloadState = "downloaded"
else if (localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded"
/* Parse base64 waveform from voice message text (format: "wf=<100 chars A-Za-z0-9+/>;dur=1234;") */
var waveform = emptyList<Float>()
var voiceDurationMs = 0
@@ -114,7 +121,8 @@ class ChatRepository {
filePath = filePath,
waveform = waveform,
voiceDurationMs = voiceDurationMs,
fileSize = fileSize
fileSize = fileSize,
downloadState = downloadState
))
}
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
@@ -99,6 +99,7 @@ object NativeLib {
/* ── Attachment ── */
fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
fun attachmentDownload(channelId: String, msgId: Long): Boolean = nativeAttachmentDownload(channelId, msgId)
/* ── Voice playback ── */
fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo)
@@ -147,6 +148,7 @@ object NativeLib {
/* ── Attachment JNI ── */
private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean
private external fun nativeAttachmentDownload(channelId: String, msgId: Long): Boolean
/* ── Voice playback JNI ── */
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
@@ -35,7 +35,8 @@ import kotlinx.coroutines.delay
@Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
onPlayVoice: (String, Int) -> Unit = { _, _ -> },
onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> }) {
onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> },
onDownloadFile: (Message) -> Unit = {}) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@@ -62,7 +63,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
when {
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice)
isMedia -> FileBubble(message, textColor)
isMedia -> FileBubble(message, textColor, onDownloadFile)
else -> {
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
Spacer(Modifier.height(2.dp))
@@ -168,11 +169,33 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
}
@Composable
private fun FileBubble(message: Message, textColor: Color) {
Column {
Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold)
private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit = {}) {
val state = message.downloadState
val stateIcon = when {
state == "downloaded" -> "\u2705"
state == "downloading" -> "\u23F3"
else -> "\u2B07\uFE0F"
}
val stateText = when {
state == "downloaded" -> "Downloaded"
state == "downloading" -> "Downloading..."
else -> "Tap to download"
}
Column(
modifier = Modifier.clickable { if (state.isEmpty()) onDownload(message) }
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stateIcon, fontSize = 18.sp)
Spacer(Modifier.width(6.dp))
Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(2.dp))
Text(formatFileSize(message.fileSize), fontSize = 12.sp, color = textColor.copy(alpha = 0.5f))
Text(
"${formatFileSize(message.fileSize)} · $stateText",
fontSize = 12.sp,
color = textColor.copy(alpha = 0.5f)
)
}
}
@@ -66,12 +66,9 @@ fun ChatScreen(
) {
items(messages.size) { idx ->
MessageBubble(messages[idx], playbackState = playbackState,
onPlayVoice = { path, durMs ->
viewModel.playVoiceMessage(path, durMs)
},
onSeekVoice = { path, seekMs, durMs ->
viewModel.seekVoice(path, seekMs, durMs)
})
onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) },
onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) },
onDownloadFile = { msg -> viewModel.downloadAttachment(msg) })
}
item { Spacer(Modifier.height(8.dp)) }
}
@@ -123,6 +123,16 @@ class ChatViewModel : ViewModel() {
}
}
8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */
14 -> { /* ATTACHMENT_DOWNLOADED: [ch_id_len:1][ch_id:var][msg_id:8] */
if (repo == null || data == null || data.size < 2) return
val chLen = data[0].toInt() and 0xFF
if (data.size >= 1 + chLen + 8) {
val chId = String(data, 1, chLen)
val cur = _currentChannel.value
if (cur != null && cur.id == chId) refreshMessages(chId)
refreshChannels()
}
}
2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */
if (repo == null || data == null || data.size < 20) return
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
@@ -279,6 +289,15 @@ class ChatViewModel : ViewModel() {
}
}
fun downloadAttachment(msg: Message) {
val ch = _currentChannel.value ?: return
NativeLib.attachmentDownload(ch.id, msg.id)
viewModelScope.launch {
delay(500)
refreshMessages(ch.id)
}
}
fun getVoicePreset(): Int = NativeLib.voiceGetPreset()
fun setVoicePreset(preset: Int) { NativeLib.voiceSetPreset(preset) }
fun getVoiceCompressor(): Boolean = NativeLib.voiceGetCompressor()