dm: маршрутизация через общую группу (group_id), JSON-эскейпинг; звонки: иконки/UI в desktop и android
This commit is contained in:
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?
|
||||
}
|
||||
+94
@@ -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),
|
||||
|
||||
+33
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user