From dc1110af4de6e5b45d72dde00420395c750132ca Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 1 Oct 2026 16:29:12 +0300 Subject: [PATCH] Add Android PM attachment selection, saving and delivery labels --- .../main/java/com/utun/chat/MainActivity.kt | 1 + .../utun/chat/ui/components/MessageBubble.kt | 2 +- .../com/utun/chat/ui/screens/DmChatScreen.kt | 72 ++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 9800effe..7b53e38b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -325,6 +325,7 @@ class MainActivity : ComponentActivity() { conv = dm, messages = dmMessages, onSendMessage = { vm.sendDmMessage(it) }, + onSendFile = { conv, path, name -> vm.sendDmFile(conv, path, name) }, onBack = { vm.closeDm(); screen = Screen.ChannelList } ) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 7ebc3d97..4de1b355 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -137,7 +137,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = if (!isVoice) { Spacer(Modifier.height(2.dp)) Text( - formatTime(message.ts), + listOf(formatTime(message.ts), message.deliveryStatus).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 11.sp, color = ChatColors.Secondary, modifier = Modifier.align(Alignment.End) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt index 4071e7ee..60427c9c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt @@ -1,5 +1,9 @@ package com.utun.chat.ui.screens +import android.provider.OpenableColumns +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,6 +20,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -28,12 +33,18 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.utun.chat.data.DmConversation import com.utun.chat.data.Message import com.utun.chat.ui.components.MessageBubble +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -41,9 +52,61 @@ fun DmChatScreen( conv: DmConversation, messages: List, onSendMessage: (String) -> Unit, + onSendFile: (String, String, String) -> Unit, onBack: () -> Unit ) { - var text by remember { mutableStateOf("") } + var text by remember(conv.convId) { mutableStateOf("") } + var fileError by remember(conv.convId) { mutableStateOf("") } + var preparing by remember(conv.convId) { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + var savePath by remember { mutableStateOf(null) } + val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + val path = savePath + savePath = null + if (uri != null && path != null) scope.launch { + try { + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(uri)?.use { output -> File(path).inputStream().use { it.copyTo(output) } } + ?: error("Не удалось открыть файл для записи") + } + } catch (e: Exception) { Log.e("utun-gui", "PM file save failed", e); fileError = "Не удалось сохранить файл" } + } + } + val attachFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + val targetConv = conv.convId + scope.launch { + preparing = true; fileError = "" + try { + val attachment = withContext(Dispatchers.IO) { + val name = context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } ?: "attachment.bin" + val temp = File.createTempFile("pm-upload-", ".file", context.cacheDir) + try { + context.contentResolver.openInputStream(uri)?.use { input -> + temp.outputStream().use { output -> + val buffer = ByteArray(65536) + var size = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + size += count + check(size <= 1024L * 1024 * 1024) { "Файл больше 1 ГиБ" } + output.write(buffer, 0, count) + } + } + } ?: error("Не удалось открыть вложение") + temp.absolutePath to name + } catch (e: Exception) { temp.delete(); throw e } + } + onSendFile(targetConv, attachment.first, attachment.second) + } catch (e: Exception) { Log.e("utun-gui", "PM attachment failed", e); fileError = e.message ?: "Ошибка вложения" } + finally { preparing = false } + } + } + } val listState = rememberLazyListState() LaunchedEffect(messages.size) { @@ -66,9 +129,11 @@ fun DmChatScreen( ) { item { Spacer(Modifier.height(8.dp)) } items(messages.reversed()) { msg -> - MessageBubble(msg) + MessageBubble(msg, onOpenFile = { savePath = it.filePath; saveFile.launch(it.text) }) } } + if (fileError.isNotEmpty()) Text(fileError, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) + if (preparing) Text("Подготовка вложения…", modifier = Modifier.padding(8.dp)) Row( modifier = Modifier .fillMaxWidth() @@ -76,6 +141,9 @@ fun DmChatScreen( .navigationBarsPadding(), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically ) { + IconButton(onClick = { attachFile.launch(arrayOf("*/*")) }, enabled = !preparing) { + Icon(Icons.Default.AttachFile, "Прикрепить файл") + } OutlinedTextField( value = text, onValueChange = { text = it },