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:
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
|
||||
|
||||
+29
-6
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user