dm: маршрутизация через общую группу (group_id), JSON-эскейпинг; звонки: иконки/UI в desktop и android

This commit is contained in:
evgeny committed 2026-09-16 15:05:48 +03:00
1 parent b483335c06
commit e4557b0473
14 files changed
+925 -139

No files matched your search

@@ -12,8 +12,12 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
@@ -29,12 +33,13 @@ import com.utun.chat.data.LogManager
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.components.ActiveCallBanner
import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord, Call }
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord, Call, Dm }
private var overLock = false
@@ -74,6 +79,7 @@ class MainActivity : ComponentActivity() {
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) }
var screenBeforeCall by remember { mutableStateOf<Screen?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
@@ -94,17 +100,31 @@ class MainActivity : ComponentActivity() {
}
}
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE/ENDED → экран Call,
INCOMING → диалог поверх текущего экрана. */
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE → экран Call (запоминаем
откуда пришли, чтобы свернуться обратно). ENDED не навигирует — CallScreen сам
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE, ChatViewModel.CallPhase.ENDED -> screen = Screen.Call
ChatViewModel.CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen
screen = Screen.Call
}
null -> if (screen == Screen.Call) screen = Screen.ChannelList
else -> {}
}
}
/* Автооткрытие DM-беседы после нажатия «Message» в списке участников. */
LaunchedEffect(Unit) {
vm.dmOpen.collect { convId ->
if (convId != null) {
vm.consumeDmOpen()
screen = Screen.Dm
}
}
}
val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
@@ -120,7 +140,8 @@ class MainActivity : ComponentActivity() {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> screen = Screen.Chat
Screen.Call -> { /* блокируем back во время звонка — только явная кнопка */ }
Screen.Dm -> { vm.closeDm(); screen = Screen.ChannelList }
Screen.Call -> screen = screenBeforeCall ?: Screen.ChannelList
else -> screen = Screen.ChannelList
}
}
@@ -141,115 +162,148 @@ class MainActivity : ComponentActivity() {
onDispose { NativeLib.subscribeNativeLogs(false) }
}
Box(modifier = Modifier.fillMaxSize()) {
when (screen) {
Screen.ChannelList -> {
val channels by vm.channels.collectAsState()
val stuck by vm.isStuck.collectAsState()
ChannelListScreen(
channels = channels,
connected = connected,
isStuck = stuck,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() }
)
}
Screen.Chat -> {
val messages by vm.messages.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
ChatScreen(
channel = channel,
messages = messages,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { screen = Screen.VideoRecord },
onCapturePhoto = { screen = Screen.PhotoRecord }
)
}
}
Screen.MemberList -> {
val members by vm.members.collectAsState()
val selectedMember by vm.selectedMember.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
MemberListScreen(
channel = channel,
members = members,
selectedMember = selectedMember,
viewModel = vm,
onBack = { screen = Screen.Chat }
)
}
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.Call -> CallScreen(
Column(modifier = Modifier.fillMaxSize()) {
val callPhase = callState?.phase
val showCallBanner = screen != Screen.Call &&
(callPhase == ChatViewModel.CallPhase.OUTGOING ||
callPhase == ChatViewModel.CallPhase.RINGING ||
callPhase == ChatViewModel.CallPhase.ACTIVE)
if (showCallBanner) {
ActiveCallBanner(
viewModel = vm,
onClose = { vm.dismissCall(); screen = Screen.ChannelList }
onOpen = { screen = Screen.Call },
onHangup = { vm.hangupCall() }
)
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
qrResult = link
screen = Screen.ChannelList
},
onBack = { screen = Screen.ChannelList }
}
Box(
modifier = Modifier.weight(1f)
.then(if (showCallBanner) Modifier.consumeWindowInsets(WindowInsets.statusBars) else Modifier)
) {
when (screen) {
Screen.ChannelList -> {
val channels by vm.channels.collectAsState()
val dmConversations by vm.dmConversations.collectAsState()
val stuck by vm.isStuck.collectAsState()
ChannelListScreen(
channels = channels,
dmConversations = dmConversations,
connected = connected,
isStuck = stuck,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() }
)
}
Screen.Chat -> {
val messages by vm.messages.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
ChatScreen(
channel = channel,
messages = messages,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { screen = Screen.VideoRecord },
onCapturePhoto = { screen = Screen.PhotoRecord }
)
}
}
Screen.MemberList -> {
val members by vm.members.collectAsState()
val selectedMember by vm.selectedMember.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
MemberListScreen(
channel = channel,
members = members,
selectedMember = selectedMember,
viewModel = vm,
onBack = { screen = Screen.Chat }
)
}
}
Screen.Dm -> {
val dmMessages by vm.dmMessages.collectAsState()
val dm = vm.currentDm.collectAsState().value
if (dm != null) {
DmChatScreen(
conv = dm,
messages = dmMessages,
onSendMessage = { vm.sendDmMessage(it) },
onBack = { vm.closeDm(); screen = Screen.ChannelList }
)
}
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.Call -> CallScreen(
viewModel = vm,
onClose = { vm.dismissCall(); screen = Screen.ChannelList },
onMinimize = { screen = screenBeforeCall ?: Screen.ChannelList }
)
}
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
VideoRecordScreen(
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
qrResult = link
screen = Screen.ChannelList
},
onClose = { screen = Screen.Chat }
onBack = { screen = Screen.ChannelList }
)
}
}
Screen.PhotoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
PhotoRecordScreen(
onSend = { path, width, height ->
vm.sendPhotoMessage(path, width, height)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
VideoRecordScreen(
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
}
}
Screen.PhotoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
PhotoRecordScreen(
onSend = { path, width, height ->
vm.sendPhotoMessage(path, width, height)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
}
}
}
}
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
}
}
}
photoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
photoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
}
}
}
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
}
}
}
@@ -54,6 +54,11 @@ 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)
data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "",
val lastTs: Long = 0) {
val displayName: String get() = peerName.ifBlank { "0x%016x".format(peerNodeId) }
}
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(),
@@ -524,4 +529,50 @@ class ChatRepository {
}
fun close() {}
/* ── DM (личные сообщения) ── */
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) =
NativeLib.dmStart(nodeId, sourceChId, peerName)
fun dmSend(convId: String, text: String) = NativeLib.dmSend(convId, text)
fun getDmConversations(): List<DmConversation> {
val list = mutableListOf<DmConversation>()
try {
val arr = JSONArray(NativeLib.dmListConversations())
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
list.add(DmConversation(
convId = obj.getString("conv_id"),
peerNodeId = obj.optLong("peer_node_id", 0),
peerName = obj.optString("peer_name", ""),
lastTs = obj.optLong("last_ts", 0)
))
}
} catch (e: Exception) { Log.w("utun-gui", "getDmConversations error", e) }
return list
}
fun getDmMessages(convId: String, peerName: String = "", limit: Int = 50): List<Message> {
val list = mutableListOf<Message>()
try {
val arr = JSONArray(NativeLib.dmListMessages(convId, limit, 0))
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
val dir = obj.optInt("dir", 0)
val outgoing = dir == 1
val author = if (outgoing) "Вы" else peerName.ifBlank { "0x%016x".format(obj.optLong("author", 0)) }
list.add(Message(
id = obj.optLong("seq", 0),
author = author,
text = obj.optString("data", ""),
ts = obj.optLong("ts", 0),
isOutgoing = outgoing,
contentType = obj.optString("ct", "text/plain")
))
}
} catch (e: Exception) { Log.w("utun-gui", "getDmMessages error", e) }
return list
}
}
@@ -162,6 +162,13 @@ object NativeLib {
fun callAudioFeed(callId: Long, samples: ShortArray): Boolean = nativeCallAudioFeed(callId, samples)
fun callAudioStop() { nativeCallAudioStop() }
/* ── DM (личные сообщения) ── */
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) }
fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) }
fun dmListConversations(): String = nativeDmListConversations() ?: "[]"
fun dmListMessages(convId: String, count: Int = 50, offset: Int = 0): String =
nativeDmListMessages(convId, count, offset) ?: "[]"
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
private external fun nativeStop()
@@ -249,4 +256,10 @@ object NativeLib {
private external fun nativeCallAudioStart(callId: Long): Boolean
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean
private external fun nativeCallAudioStop()
/* ── DM JNI ── */
private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String)
private external fun nativeDmSend(convId: String, text: String)
private external fun nativeDmListConversations(): String?
private external fun nativeDmListMessages(convId: String, count: Int, offset: Int): String?
}
@@ -0,0 +1,94 @@
package com.utun.chat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
private fun fmtDuration(sec: Int): String {
val m = sec / 60
val s = sec % 60
return "%02d:%02d".format(m, s)
}
/* Постоянная полоса активного звонка: показывается поверх интерфейса, когда
* звонок свёрнут. Клик по полосе открывает CallScreen, красная кнопка — завершить. */
@Composable
fun ActiveCallBanner(
viewModel: ChatViewModel,
onOpen: () -> Unit,
onHangup: () -> Unit
) {
val st = viewModel.callState.collectAsState().value ?: return
val phase = st.phase
if (phase != CallPhase.OUTGOING && phase != CallPhase.RINGING && phase != CallPhase.ACTIVE) return
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
val status = when (phase) {
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationMs)
CallPhase.RINGING -> "Гудки…"
else -> "Звоним…"
}
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.background(Color(0xFF1F6FEB))
.clickable(onClick = onOpen)
.padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.PhoneInTalk,
contentDescription = "Звонок",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
name,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
status,
color = Color(0xFFD5E4FF),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier.size(40.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = onHangup) {
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(22.dp))
}
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Headset
@@ -73,7 +74,8 @@ private fun CallControlButton(
@Composable
fun CallScreen(
viewModel: ChatViewModel,
onClose: () -> Unit
onClose: () -> Unit,
onMinimize: () -> Unit
) {
val st = viewModel.callState.collectAsState().value
val muted by viewModel.muted.collectAsState()
@@ -100,6 +102,15 @@ fun CallScreen(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onMinimize,
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(8.dp)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Свернуть звонок", tint = Color.White, modifier = Modifier.size(24.dp))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.DmConversation
import com.utun.chat.data.NotificationSound
import com.utun.chat.ChatApplication
import kotlinx.coroutines.launch
@@ -31,9 +32,11 @@ import kotlinx.coroutines.launch
@Composable
fun ChannelListScreen(
channels: List<Channel>,
dmConversations: List<DmConversation> = emptyList(),
connected: Boolean,
isStuck: Boolean = false,
onSelectChannel: (Channel) -> Unit,
onSelectDm: (DmConversation) -> Unit = {},
onCreateChannel: (String) -> Unit,
onJoinChannel: () -> Unit,
onInviteClick: (Channel) -> Unit,
@@ -72,7 +75,7 @@ fun ChannelListScreen(
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
if (channels.isEmpty()) {
if (channels.isEmpty() && dmConversations.isEmpty()) {
Box(Modifier.fillMaxSize().weight(1f)) {
Text(
"No channels yet.\nTap + to create one.",
@@ -147,6 +150,35 @@ fun ChannelListScreen(
}
HorizontalDivider()
}
if (dmConversations.isNotEmpty()) {
item {
Text(
"Личные",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
items(dmConversations) { dm ->
Surface(modifier = Modifier.clickable { onSelectDm(dm) }) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFF8E44AD)))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
Text("@", fontSize = 22.sp, color = Color(0xFF8E44AD))
}
Spacer(Modifier.width(10.dp))
Column {
Text(dm.displayName, fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}
}
HorizontalDivider()
}
}
}
}
}
@@ -0,0 +1,97 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
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.setValue
import androidx.compose.ui.Modifier
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DmChatScreen(
conv: DmConversation,
messages: List<Message>,
onSendMessage: (String) -> Unit,
onBack: () -> Unit
) {
var text by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.lastOrNull()?.isOutgoing == true) {
listState.scrollToItem(0)
}
}
Column(modifier = Modifier.fillMaxSize().imePadding()) {
TopAppBar(
title = { Text(conv.displayName) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
}
)
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
state = listState,
reverseLayout = true
) {
item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed()) { msg ->
MessageBubble(msg)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.navigationBarsPadding(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
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)
}
}
}
}
@@ -85,6 +85,7 @@ fun MemberListScreen(
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) },
onEdit = {
editMember = m
showEditDialog = true
@@ -137,6 +138,7 @@ private fun MemberListItem(
onClick: () -> Unit,
onConnect: () -> Unit,
onCall: () -> Unit,
onMessage: () -> Unit,
onEdit: () -> Unit
) {
val onlineColor = when {
@@ -225,6 +227,7 @@ private fun MemberListItem(
onClick = onCall,
enabled = member.online
) { Text("Call") }
TextButton(onClick = onMessage) { Text("Message") }
}
TextButton(onClick = onEdit) { Text("Edit") }
}
@@ -22,6 +22,8 @@ import java.io.FileOutputStream
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private const val CALL_INCOMING_TIMEOUT_MS = 50_000L
class ChatViewModel : ViewModel() {
data class PlaybackState(
@@ -54,6 +56,7 @@ class ChatViewModel : ViewModel() {
val callAudio = CallAudioEngine()
private var callStartedMs = 0L
private var callIncomingAtMs = 0L
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted
@@ -118,6 +121,22 @@ class ChatViewModel : ViewModel() {
private val _inviteLinkPending = MutableStateFlow(false)
val inviteLinkPending: StateFlow<Boolean> = _inviteLinkPending
private val _dmConversations = MutableStateFlow<List<DmConversation>>(emptyList())
val dmConversations: StateFlow<List<DmConversation>> = _dmConversations
private val _dmMessages = MutableStateFlow<List<Message>>(emptyList())
val dmMessages: StateFlow<List<Message>> = _dmMessages
private val _currentDm = MutableStateFlow<DmConversation?>(null)
val currentDm: StateFlow<DmConversation?> = _currentDm
/* Одноразовый сигнал навигации: MainActivity открывает экран DM и сбрасывает. */
private val _dmOpen = MutableStateFlow<String?>(null)
val dmOpen: StateFlow<String?> = _dmOpen
/* peer-данные начатой беседы — для автооткрытия по событию DM_CONV_UPDATED. */
private var pendingStartPeer: Pair<Long, String>? = null
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
@@ -175,6 +194,7 @@ class ChatViewModel : ViewModel() {
val cur = _callState.value
if (cur != null && cur.callId == p.callId) return@launch
LogManager.addLog("INFO", "VM", "restore pending incoming call 0x%016x ch=%s".format(p.callId, p.channelId))
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = p.callId, channelId = p.channelId, peerNodeId = p.peerNodeId,
peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING)
resolveCallerName(p.channelId, p.peerNodeId)
@@ -231,6 +251,20 @@ class ChatViewModel : ViewModel() {
delay(500)
}
}
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.INCOMING &&
callIncomingAtMs > 0 &&
System.currentTimeMillis() - callIncomingAtMs > CALL_INCOMING_TIMEOUT_MS) {
LogManager.addLog("WARN", "VM", "INCOMING watchdog: no resolution, clearing call 0x%016x".format(st.callId))
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_callState.value = null
}
delay(1000)
}
}
}
private fun handleEvent(type: Int, data: ByteArray?) {
@@ -468,6 +502,36 @@ class ChatViewModel : ViewModel() {
}
refreshChannels()
}
33 -> { /* DM_CONV_UPDATED: [conv_id_len:1][conv_id:var] */
if (data == null || data.size < 2) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl) return
val convId = String(data, 1, cl)
LogManager.addLog("INFO", "VM", "DM_CONV_UPDATED conv=$convId")
refreshDmConversations()
_currentDm.value?.let { if (it.convId == convId) refreshDmMessages() }
val pend = pendingStartPeer
if (pend != null) {
pendingStartPeer = null
_currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second)
refreshDmMessages()
_dmOpen.value = convId
LogManager.addLog("INFO", "VM", "DM auto-open conv=$convId")
}
}
32 -> { /* DM_MSG_RECEIVED: [conv_id_len:1][conv_id:var][author_node_id:8] */
if (data == null || data.size < 1 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 8) return
val convId = String(data, 1, cl)
val author = java.nio.ByteBuffer.wrap(data, 1 + cl, 8)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
LogManager.addLog("INFO", "VM", "DM_MSG_RECEIVED conv=$convId author=0x%016x".format(author))
if (author != NativeLib.getMyNodeId()) NotificationSound.play("dm:$convId")
val cur = _currentDm.value
if (cur != null && cur.convId == convId) refreshDmMessages()
refreshDmConversations()
}
34 -> { /* CALL_INCOMING: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
@@ -484,6 +548,7 @@ class ChatViewModel : ViewModel() {
NativeLib.callDecline(callId)
return
}
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
resolveCallerName(chId, caller)
@@ -564,6 +629,7 @@ class ChatViewModel : ViewModel() {
if (st == null || st.callId != callId) return
callAudio.stop()
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_muted.value = false
_audioRoute.value = AudioRoute.SPEAKER
_headsetAvailable.value = false
@@ -592,7 +658,11 @@ class ChatViewModel : ViewModel() {
fun acceptCall() {
val st = _callState.value ?: return
if (st.phase != CallPhase.INCOMING) return
if (st.phase != CallPhase.INCOMING) {
LogManager.addLog("WARN", "VM", "acceptCall ignored: phase=${st.phase} id=0x%016x".format(st.callId))
return
}
callIncomingAtMs = 0L
IncomingCallManager.stopRinging()
NativeLib.callAccept(st.callId)
LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId))
@@ -619,6 +689,7 @@ class ChatViewModel : ViewModel() {
fun dismissCall() {
callAudio.stop()
callIncomingAtMs = 0L
_callState.value = null
}
@@ -676,6 +747,7 @@ class ChatViewModel : ViewModel() {
val r = repo ?: return
viewModelScope.launch {
_channels.value = withContext(Dispatchers.IO) { r.getChannels() }
_dmConversations.value = withContext(Dispatchers.IO) { r.getDmConversations() }
}
}
@@ -693,6 +765,58 @@ class ChatViewModel : ViewModel() {
refreshMessages(channel.id)
}
/* ── DM (личные сообщения) ── */
fun refreshDmConversations() {
val r = repo ?: return
viewModelScope.launch {
_dmConversations.value = withContext(Dispatchers.IO) { r.getDmConversations() }
}
}
fun refreshDmMessages() {
val r = repo ?: return
val dm = _currentDm.value ?: return
viewModelScope.launch {
_dmMessages.value = withContext(Dispatchers.IO) { r.getDmMessages(dm.convId, dm.peerName) }
}
}
fun startDm(channelId: String, nodeId: Long, name: String) {
val r = repo ?: return
if (nodeId == 0L || nodeId == NativeLib.getMyNodeId()) return
LogManager.addLog("INFO", "VM", "startDm ch=$channelId peer=0x%016x name=$name".format(nodeId))
pendingStartPeer = nodeId to name
r.dmStart(nodeId, channelId, name)
}
fun consumeDmOpen() { _dmOpen.value = null }
fun openDm(convId: String, peerNodeId: Long, peerName: String) {
val r = repo ?: return
val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName)
_currentDm.value = dm
viewModelScope.launch {
_dmMessages.value = withContext(Dispatchers.IO) { r.getDmMessages(convId, peerName) }
}
}
fun closeDm() {
_currentDm.value = null
_dmMessages.value = emptyList()
}
fun sendDmMessage(text: String) {
val r = repo ?: return
val dm = _currentDm.value ?: return
if (text.isBlank()) return
r.dmSend(dm.convId, text)
viewModelScope.launch {
_dmMessages.value = withContext(Dispatchers.IO) { r.getDmMessages(dm.convId, dm.peerName) }
refreshDmConversations()
}
}
fun refreshMembers(chId: String) {
val r = repo ?: return
val statuses = _nodeStatuses.value