Browse Source

Use the same Android media interface for channels and PM

master
evgeny 2 days ago
parent
commit
fef800ef7a
  1. 151
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  2. 171
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt

151
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -56,7 +56,9 @@ import com.utun.chat.data.NativeLib
import com.utun.chat.data.RadioAudioEngine import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.data.RadioOverlayService import com.utun.chat.data.RadioOverlayService
import com.utun.chat.ui.components.InputBar import com.utun.chat.ui.components.InputBar
import com.utun.chat.ui.components.MessageBubble import com.utun.chat.ui.components.ConversationMessages
import com.utun.chat.ui.components.ConversationComposer
import com.utun.chat.data.ConversationTarget
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -79,13 +81,7 @@ fun ChatScreen(
onRecordVideo: () -> Unit = {}, onRecordVideo: () -> Unit = {},
onCapturePhoto: () -> Unit = {} onCapturePhoto: () -> Unit = {}
) { ) {
var text by remember { mutableStateOf("") }
val isVideoMode by viewModel.isVideoMode.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isRecording by viewModel.isRecording.collectAsState()
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
var recordingLevel by remember { mutableStateOf(0f) }
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() } val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() }
@ -100,34 +96,6 @@ fun ChatScreen(
/* звук сообщений канала */ /* звук сообщений канала */
val soundOn by viewModel.soundOn.collectAsState() val soundOn by viewModel.soundOn.collectAsState()
/* attach menu */
var showAttachMenu by remember { mutableStateOf(false) }
/* pending attachment state */
var pendingAttachUri by remember { mutableStateOf<Uri?>(null) }
var pendingAttachName by remember { mutableStateOf("") }
var pendingAttachSize by remember { mutableStateOf(0L) }
/* file picker */
val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) {
val cr: ContentResolver = context.contentResolver
var name = "file"
var size = 0L
cr.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
if (nameIdx >= 0) name = cursor.getString(nameIdx) ?: name
if (sizeIdx >= 0) size = cursor.getLong(sizeIdx)
}
}
pendingAttachUri = uri
pendingAttachName = name
pendingAttachSize = size
}
}
/* save dialog for downloaded files */ /* save dialog for downloaded files */
var pendingSaveFilePath by remember { mutableStateOf<String?>(null) } var pendingSaveFilePath by remember { mutableStateOf<String?>(null) }
val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri -> val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri ->
@ -142,16 +110,6 @@ fun ChatScreen(
} }
} }
/* Poll peak level during recording */
LaunchedEffect(isRecording) {
if (isRecording) {
while (true) {
recordingLevel = NativeLib.voiceGetPeakLevel()
delay(80)
}
}
}
val playbackState by viewModel.playbackState.collectAsState() val playbackState by viewModel.playbackState.collectAsState()
/* своё сообщение — подтягиваемся вниз (в reverseLayout индекс 0 — это низ) */ /* своё сообщение — подтягиваемся вниз (в reverseLayout индекс 0 — это низ) */
@ -206,27 +164,15 @@ fun ChatScreen(
(windowPosition.x + coordinates.size.width / 2f + screenOrigin[0] - windowOrigin[0]).toInt(), (windowPosition.x + coordinates.size.width / 2f + screenOrigin[0] - windowOrigin[0]).toInt(),
(windowPosition.y + pttCenterOffset + screenOrigin[1] - windowOrigin[1]).toInt()) (windowPosition.y + pttCenterOffset + screenOrigin[1] - windowOrigin[1]).toInt())
}) { }) {
LazyColumn( ConversationMessages(
modifier = Modifier.fillMaxSize(), target = ConversationTarget.Channel(channel.id), messages = messages, state = listState,
state = listState, playbackState = playbackState, modifier = Modifier.fillMaxSize(),
reverseLayout = true onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) },
) { onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) },
item { Spacer(Modifier.height(8.dp)) } onDownloadFile = { viewModel.downloadAttachment(it) },
items(messages.reversed()) { msg -> onOpenFile = { if (it.filePath.isNotEmpty()) { pendingSaveFilePath = it.filePath; saveFileLauncher.launch(it.text) } },
MessageBubble(msg, playbackState = playbackState, onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo
onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) }, )
onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) },
onDownloadFile = { msg -> viewModel.downloadAttachment(msg) },
onOpenFile = { msg ->
if (msg.filePath.isNotEmpty()) {
pendingSaveFilePath = msg.filePath
saveFileLauncher.launch(msg.text) /* suggest original filename */
}
},
onOpenImage = { msg -> onViewPhoto(msg) },
onPlayVideo = { msg -> onPlayVideo(msg) })
}
}
if (radioActive && !overlayShown) { if (radioActive && !overlayShown) {
RadioPttButton( RadioPttButton(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
@ -236,78 +182,7 @@ fun ChatScreen(
) )
} }
} }
InputBar( ConversationComposer(ConversationTarget.Channel(channel.id), viewModel, onSendMessage, onRecordVideo, onCapturePhoto)
text = text,
onTextChange = { text = it },
onSend = {
val attachUri = pendingAttachUri
val attachName = pendingAttachName
pendingAttachUri = null
pendingAttachName = ""
pendingAttachSize = 0L
if (attachUri != null) {
scope.launch(Dispatchers.IO) {
val cachePath = viewModel.copyUriToCache(context, attachUri, attachName)
if (cachePath != null) {
viewModel.sendAttachment(cachePath)
}
}
}
if (text.isNotBlank()) {
onSendMessage(text)
text = ""
}
},
enabled = true,
isRecording = isRecording,
isRecordingLocked = isRecordingLocked,
recordingDurationMs = recordingDurationMs,
recordingLevel = recordingLevel,
onPttPressed = { viewModel.startVoiceRecording() },
onPttReleased = {
if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage()
},
onPttLock = { viewModel.lockVoiceRecording() },
onVoiceSend = { viewModel.sendVoiceMessage() },
onVoiceCancel = {
viewModel.cancelVoiceRecording()
recordingLevel = 0f
},
onAttachClicked = { showAttachMenu = true },
pendingAttachmentName = pendingAttachName,
pendingAttachmentSize = pendingAttachSize,
onClearAttachment = {
pendingAttachUri = null
pendingAttachName = ""
pendingAttachSize = 0L
},
isVideoMode = isVideoMode,
onToggleMode = { viewModel.toggleVideoMode() },
onVideoRecordOpen = onRecordVideo,
modifier = Modifier.navigationBarsPadding()
)
if (showAttachMenu) {
ModalBottomSheet(onDismissRequest = { showAttachMenu = false }) {
ListItem(
headlineContent = { Text("Выбрать файл") },
leadingContent = { Text("\uD83D\uDCCE", fontSize = 20.sp) },
modifier = Modifier.clickable {
showAttachMenu = false
filePickerLauncher.launch("*/*")
}
)
ListItem(
headlineContent = { Text("Сфотографировать") },
leadingContent = { Text("\uD83D\uDCF7", fontSize = 20.sp) },
modifier = Modifier.clickable {
showAttachMenu = false
onCapturePhoto()
}
)
Spacer(Modifier.height(16.dp))
}
}
} }
} }

171
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt

@ -1,165 +1,66 @@
package com.utun.chat.ui.screens package com.utun.chat.ui.screens
import android.provider.OpenableColumns
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.*
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.runtime.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.utun.chat.data.DmConversation import com.utun.chat.data.*
import com.utun.chat.data.Message import com.utun.chat.ui.components.ConversationComposer
import com.utun.chat.ui.components.MessageBubble import com.utun.chat.ui.components.ConversationMessages
import com.utun.chat.viewmodel.ChatViewModel
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** PM отличается заголовком и доставкой; ввод, вложения и сообщения общие с каналом. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DmChatScreen( fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatViewModel,
conv: DmConversation, onSendMessage: (String) -> Unit, onBack: () -> Unit,
messages: List<Message>, onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) {
onSendMessage: (String) -> Unit, val target = ConversationTarget.Pm(conv.convId)
onSendFile: (String, String, String) -> Unit, val playback by viewModel.playbackState.collectAsState()
onBack: () -> Unit val state = rememberLazyListState()
) {
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 context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var savePath by remember { mutableStateOf<String?>(null) } var pathToSave by remember { mutableStateOf<String?>(null) }
val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> var error by remember(conv.convId) { mutableStateOf("") }
val path = savePath val save = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri ->
savePath = null val path = pathToSave
pathToSave = null
if (uri != null && path != null) scope.launch { if (uri != null && path != null) scope.launch {
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.use { output -> File(path).inputStream().use { it.copyTo(output) } } context.contentResolver.openOutputStream(uri)?.use { output ->
?: error("Не удалось открыть файл для записи") File(path).inputStream().use { it.copyTo(output) }
} ?: throw IllegalStateException("Не удалось открыть файл")
} }
} catch (e: Exception) { Log.e("utun-gui", "PM file save failed", e); fileError = "Не удалось сохранить файл" } } catch (e: Exception) {
} LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = "Не удалось сохранить файл"
}
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) { LaunchedEffect(messages.size) {
if (messages.lastOrNull()?.isOutgoing == true) { if (messages.lastOrNull()?.isOutgoing == true) state.scrollToItem(0)
listState.scrollToItem(0)
}
} }
Column(Modifier.fillMaxSize().imePadding()) {
Column(modifier = Modifier.fillMaxSize().imePadding()) { TopAppBar(title = { Text(conv.displayName) }, navigationIcon = {
TopAppBar( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Назад") }
title = { Text(conv.displayName) }, })
navigationIcon = { ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(),
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) },
} onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) },
) onOpenFile = { if (it.filePath.isNotEmpty()) { pathToSave = it.filePath; save.launch(it.text) } },
LazyColumn( onPlayVideo = onPlayVideo)
modifier = Modifier.weight(1f).fillMaxWidth(), if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
state = listState, ConversationComposer(target, viewModel, onSendMessage, onRecordVideo)
reverseLayout = true
) {
item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed()) { 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()
.padding(8.dp)
.navigationBarsPadding(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
IconButton(onClick = { attachFile.launch(arrayOf("*/*")) }, enabled = !preparing) {
Icon(Icons.Default.AttachFile, "Прикрепить файл")
}
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Сообщение") },
maxLines = 4
)
Spacer(Modifier.width(8.dp))
IconButton(onClick = {
if (text.isNotBlank()) {
onSendMessage(text)
text = ""
}
}) {
Icon(Icons.AutoMirrored.Filled.Send, "Send", tint = MaterialTheme.colorScheme.primary)
}
}
} }
} }

Loading…
Cancel
Save