Browse Source

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

router-recv-conn-uaf
evgeny 2 weeks ago
parent
commit
e4557b0473
  1. 89
      src/dm/dm_core.c
  2. 252
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  3. 51
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  4. 13
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  5. 94
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt
  6. 13
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  7. 34
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  8. 97
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt
  9. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  10. 126
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  11. 98
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  12. 8
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  13. 175
      tools/chatgui/src/callwindow.cpp
  14. 7
      tools/chatgui/src/callwindow.h

89
src/dm/dm_core.c

@ -109,8 +109,9 @@ static int dm_node_pubkeys(struct dm_state* dm, uint64_t node_id, uint8_t x25519
return rc; return rc;
} }
/* Есть ли общая группа с пиром (anti-spam: принимаем DM только от знакомых из общих каналов). */ /* Общий CHAT-канал с пиром. Возвращает 1 и кладёт group_id (== channel_id) в *out_group_id. */
static int dm_share_group(struct dm_state* dm, uint64_t peer_id) { static int dm_shared_group_id(struct dm_state* dm, uint64_t peer_id, uint64_t* out_group_id) {
if (!dm || !out_group_id) return 0;
uint64_t* ch_ids = NULL; uint64_t* ch_ids = NULL;
int ch_count = 0; int ch_count = 0;
if (topo_node_sqlite_get_member_channels(dm->db, dm->inst->node_id, &ch_ids, &ch_count) != 0) if (topo_node_sqlite_get_member_channels(dm->db, dm->inst->node_id, &ch_ids, &ch_count) != 0)
@ -118,12 +119,22 @@ static int dm_share_group(struct dm_state* dm, uint64_t peer_id) {
int shared = 0; int shared = 0;
for (int i = 0; i < ch_count; i++) { for (int i = 0; i < ch_count; i++) {
char ch[64]; snprintf(ch, sizeof(ch), "%llu", (unsigned long long)ch_ids[i]); char ch[64]; snprintf(ch, sizeof(ch), "%llu", (unsigned long long)ch_ids[i]);
if (topo_node_sqlite_member_in_channel(dm->db, ch, peer_id)) { shared = 1; break; } if (topo_node_sqlite_member_in_channel(dm->db, ch, peer_id)) {
*out_group_id = ch_ids[i];
shared = 1;
break;
}
} }
if (ch_ids) u_free(ch_ids); if (ch_ids) u_free(ch_ids);
return shared; return shared;
} }
/* Есть ли общая группа с пиром (anti-spam: принимаем DM только от знакомых из общих каналов). */
static int dm_share_group(struct dm_state* dm, uint64_t peer_id) {
uint64_t gid = 0;
return dm_shared_group_id(dm, peer_id, &gid);
}
/* Собрать node_id всех мемберов общих групп (без self и дублей). До max штук. /* Собрать node_id всех мемберов общих групп (без self и дублей). До max штук.
* Используется для offline-pull: потенциальные отправители почты = мемберы общих групп. */ * Используется для offline-pull: потенциальные отправители почты = мемберы общих групп. */
static int dm_shared_peers(struct dm_state* dm, uint64_t* out, int max) { static int dm_shared_peers(struct dm_state* dm, uint64_t* out, int max) {
@ -218,6 +229,12 @@ static int dm_conv_load(struct dm_state* dm, const char* conv_id, struct dm_conv
c->group_id = (uint64_t)sqlite3_column_int64(st, 4); c->group_id = (uint64_t)sqlite3_column_int64(st, 4);
c->last_out_seq = (uint64_t)sqlite3_column_int64(st, 5); c->last_out_seq = (uint64_t)sqlite3_column_int64(st, 5);
c->last_in_seq = (uint64_t)sqlite3_column_int64(st, 6); c->last_in_seq = (uint64_t)sqlite3_column_int64(st, 6);
/* Старые беседы могли быть авто-созданы с group_id=0 (маршрутизация только напрямую) —
* подтягиваем общую группу, чтобы DM шёл через неё (indirect/BGP). */
if (c->group_id == 0) {
uint64_t gid = 0;
if (dm_shared_group_id(dm, c->peer_node_id, &gid)) c->group_id = gid;
}
dm_derive_content_key(dm->inst->my_keys.private_key, c->peer_x25519, c->content_key); dm_derive_content_key(dm->inst->my_keys.private_key, c->peer_x25519, c->content_key);
rc = 0; rc = 0;
} }
@ -326,22 +343,23 @@ static void dm_on_msg(struct dm_state* dm, const uint8_t* msg, size_t mlen) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: conv_id mismatch from 0x%016llx", DM_ID, (unsigned long long)author); DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: conv_id mismatch from 0x%016llx", DM_ID, (unsigned long long)author);
return; return;
} }
if (!dm_share_group(dm, author)) { uint64_t gid = 0;
if (!dm_shared_group_id(dm, author, &gid)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: no shared group with 0x%016llx — reject DM", DM_ID, (unsigned long long)author); DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: no shared group with 0x%016llx — reject DM", DM_ID, (unsigned long long)author);
return; return;
} }
memset(&c, 0, sizeof(c)); memset(&c, 0, sizeof(c));
snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id); snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id);
c.peer_node_id = author; c.peer_node_id = author;
c.group_id = gid;
if (dm_node_pubkeys(dm, author, c.peer_x25519, c.peer_ed25519) != 0) { if (dm_node_pubkeys(dm, author, c.peer_x25519, c.peer_ed25519) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: no pubkeys for 0x%016llx", DM_ID, (unsigned long long)author); DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: no pubkeys for 0x%016llx", DM_ID, (unsigned long long)author);
return; return;
} }
dm_derive_content_key(dm->inst->my_keys.private_key, c.peer_x25519, c.content_key); dm_derive_content_key(dm->inst->my_keys.private_key, c.peer_x25519, c.content_key);
c.group_id = 0;
dm_conv_save(dm, &c); dm_conv_save(dm, &c);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: auto-created conversation conv=%s peer=0x%016llx", DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: auto-created conversation conv=%s peer=0x%016llx group=%llu",
DM_ID, conv_id, (unsigned long long)author); DM_ID, conv_id, (unsigned long long)author, (unsigned long long)c.group_id);
/* событие GUI: появилась новая беседа (чтобы GUI добавил её в список) */ /* событие GUI: появилась новая беседа (чтобы GUI добавил её в список) */
{ {
@ -459,21 +477,23 @@ static void dm_on_hello(struct dm_state* dm, uint64_t conv_num, uint64_t from_pe
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello conv_id mismatch from 0x%016llx", DM_ID, (unsigned long long)from_peer); DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello conv_id mismatch from 0x%016llx", DM_ID, (unsigned long long)from_peer);
return; return;
} }
if (!dm_share_group(dm, from_peer)) { uint64_t gid = 0;
if (!dm_shared_group_id(dm, from_peer, &gid)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello no shared group with 0x%016llx — ignore", DM_ID, (unsigned long long)from_peer); DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello no shared group with 0x%016llx — ignore", DM_ID, (unsigned long long)from_peer);
return; return;
} }
memset(&c, 0, sizeof(c)); memset(&c, 0, sizeof(c));
snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id); snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id);
c.peer_node_id = from_peer; c.peer_node_id = from_peer;
c.group_id = gid;
if (dm_node_pubkeys(dm, from_peer, c.peer_x25519, c.peer_ed25519) != 0) { if (dm_node_pubkeys(dm, from_peer, c.peer_x25519, c.peer_ed25519) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: hello no pubkeys for 0x%016llx", DM_ID, (unsigned long long)from_peer); DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: hello no pubkeys for 0x%016llx", DM_ID, (unsigned long long)from_peer);
return; return;
} }
dm_derive_content_key(dm->inst->my_keys.private_key, c.peer_x25519, c.content_key); dm_derive_content_key(dm->inst->my_keys.private_key, c.peer_x25519, c.content_key);
dm_conv_save(dm, &c); dm_conv_save(dm, &c);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: hello auto-created conversation conv=%s peer=0x%016llx", DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: hello auto-created conversation conv=%s peer=0x%016llx group=%llu",
DM_ID, conv_id, (unsigned long long)from_peer); DM_ID, conv_id, (unsigned long long)from_peer, (unsigned long long)c.group_id);
{ {
size_t cl = strlen(conv_id); size_t cl = strlen(conv_id);
@ -660,7 +680,19 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id,
struct dm_conv c; struct dm_conv c;
if (dm_conv_load(dm, conv_id, &c) == 0) { if (dm_conv_load(dm, conv_id, &c) == 0) {
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: conversation already exists conv=%s", DM_ID, conv_id); /* Беседа уже есть, но могла быть авто-создана с group_id=0 — дописываем из source_ch_id,
* чтобы маршрутизация шла через группу (а не только прямое соединение). */
if (c.group_id == 0 && source_ch_id && source_ch_id[0]) {
uint64_t gid = strtoull(source_ch_id, NULL, 10);
if (gid != 0) {
c.group_id = gid;
dm_conv_save(dm, &c);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: conversation exists conv=%s — updated group=%llu",
DM_ID, conv_id, (unsigned long long)gid);
}
} else {
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: conversation already exists conv=%s", DM_ID, conv_id);
}
} else { } else {
memset(&c, 0, sizeof(c)); memset(&c, 0, sizeof(c));
snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id); snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id);
@ -812,6 +844,26 @@ void dm_core_destroy(struct UTUN_INSTANCE* inst) {
/* ── JSON для GUI/headless ── */ /* ── JSON для GUI/headless ── */
/* Экранирование строки для вставки в JSON-строку: " \ \n \r \t, прочие <0x20 — drop. */
static void json_escape(const char* src, char* dst, size_t dst_sz) {
size_t i = 0;
if (!src) src = "";
for (; *src && i + 2 < dst_sz; src++) {
unsigned char c = (unsigned char)*src;
switch (c) {
case '"': dst[i++] = '\\'; dst[i++] = '"'; break;
case '\\': dst[i++] = '\\'; dst[i++] = '\\'; break;
case '\n': dst[i++] = '\\'; dst[i++] = 'n'; break;
case '\r': dst[i++] = '\\'; dst[i++] = 'r'; break;
case '\t': dst[i++] = '\\'; dst[i++] = 't'; break;
default:
if (c < 0x20) break;
dst[i++] = (char)c;
}
}
dst[i] = '\0';
}
/* Список бесед в JSON. */ /* Список бесед в JSON. */
int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len) { int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len) {
struct dm_state* dm = dm_of(inst); struct dm_state* dm = dm_of(inst);
@ -831,11 +883,13 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf
uint64_t last_ts = (uint64_t)sqlite3_column_int64(st, 3); uint64_t last_ts = (uint64_t)sqlite3_column_int64(st, 3);
uint64_t out_seq = (uint64_t)sqlite3_column_int64(st, 4); uint64_t out_seq = (uint64_t)sqlite3_column_int64(st, 4);
uint64_t in_seq = (uint64_t)sqlite3_column_int64(st, 5); uint64_t in_seq = (uint64_t)sqlite3_column_int64(st, 5);
char esc_name[512];
json_escape(name ? name : "", esc_name, sizeof(esc_name));
off += snprintf(buf + off, buf_size - (size_t)off, off += snprintf(buf + off, buf_size - (size_t)off,
"%s{\"conv_id\":\"%s\",\"peer_node_id\":%llu,\"peer_name\":\"%s\"," "%s{\"conv_id\":\"%s\",\"peer_node_id\":%llu,\"peer_name\":\"%s\","
"\"last_ts\":%llu,\"out_seq\":%llu,\"in_seq\":%llu}", "\"last_ts\":%llu,\"out_seq\":%llu,\"in_seq\":%llu}",
first ? "" : ",", conv_id, (unsigned long long)peer, first ? "" : ",", conv_id, (unsigned long long)peer,
name ? name : "", (unsigned long long)last_ts, esc_name, (unsigned long long)last_ts,
(unsigned long long)out_seq, (unsigned long long)in_seq); (unsigned long long)out_seq, (unsigned long long)in_seq);
first = 0; first = 0;
} }
@ -858,11 +912,11 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c
char sql[256]; char sql[256];
if (count > 0) if (count > 0)
snprintf(sql, sizeof(sql), snprintf(sql, sizeof(sql),
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY seq LIMIT %d OFFSET %d", "SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY ts, seq LIMIT %d OFFSET %d",
count, offset); count, offset);
else else
snprintf(sql, sizeof(sql), snprintf(sql, sizeof(sql),
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY seq"); "SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY ts, seq");
int off = 0; int off = 0;
off += snprintf(buf + off, buf_size - (size_t)off, "["); off += snprintf(buf + off, buf_size - (size_t)off, "[");
@ -894,10 +948,13 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c
} }
u_free(plain); u_free(plain);
} }
char esc_ct[64], esc_data[DM_DATA_MAX * 2 + 8];
json_escape(ct ? ct : "", esc_ct, sizeof(esc_ct));
json_escape(text, esc_data, sizeof(esc_data));
off += snprintf(buf + off, buf_size - (size_t)off, off += snprintf(buf + off, buf_size - (size_t)off,
"%s{\"dir\":%d,\"seq\":%llu,\"ts\":%llu,\"author\":%llu,\"ct\":\"%s\",\"data\":\"%s\"}", "%s{\"dir\":%d,\"seq\":%llu,\"ts\":%llu,\"author\":%llu,\"ct\":\"%s\",\"data\":\"%s\"}",
first ? "" : ",", dir, (unsigned long long)seq, (unsigned long long)ts, first ? "" : ",", dir, (unsigned long long)seq, (unsigned long long)ts,
(unsigned long long)author, ct ? ct : "", text); (unsigned long long)author, esc_ct, esc_data);
first = 0; first = 0;
} }
} }
@ -974,7 +1031,7 @@ void dm_messages_trampoline(void* arg) {
size_t msgs_len = 0, msgs_cap = 0; size_t msgs_len = 0, msgs_cap = 0;
int count = 0; int count = 0;
if (sqlite3_prepare_v2(dm->db, if (sqlite3_prepare_v2(dm->db,
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY seq", "SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY ts, seq",
-1, &st, NULL) == SQLITE_OK) { -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, conv_id, -1, SQLITE_STATIC); sqlite3_bind_text(st, 1, conv_id, -1, SQLITE_STATIC);
while (sqlite3_step(st) == SQLITE_ROW) { while (sqlite3_step(st) == SQLITE_ROW) {

252
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -12,8 +12,12 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.* 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.Message
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.components.ActiveCallBanner
import com.utun.chat.ui.screens.* import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { 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 private var overLock = false
@ -74,6 +79,7 @@ class MainActivity : ComponentActivity() {
var videoMessage by remember { mutableStateOf<Message?>(null) } var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) } var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) } var hadCall by remember { mutableStateOf(false) }
var screenBeforeCall by remember { mutableStateOf<Screen?>(null) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -94,17 +100,31 @@ class MainActivity : ComponentActivity() {
} }
} }
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE/ENDED → экран Call, /* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE → экран Call (запоминаем
INCOMING → диалог поверх текущего экрана. */ откуда пришли, чтобы свернуться обратно). ENDED не навигирует — CallScreen сам
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
LaunchedEffect(callState?.phase) { LaunchedEffect(callState?.phase) {
when (callState?.phase) { when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING, 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 null -> if (screen == Screen.Call) screen = Screen.ChannelList
else -> {} else -> {}
} }
} }
/* Автооткрытие DM-беседы после нажатия «Message» в списке участников. */
LaunchedEffect(Unit) {
vm.dmOpen.collect { convId ->
if (convId != null) {
vm.consumeDmOpen()
screen = Screen.Dm
}
}
}
val showInvite: (Channel) -> Unit = { ch -> val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) { if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}") LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
@ -120,7 +140,8 @@ class MainActivity : ComponentActivity() {
Screen.MemberList -> screen = Screen.Chat Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> 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 else -> screen = Screen.ChannelList
} }
} }
@ -141,115 +162,148 @@ class MainActivity : ComponentActivity() {
onDispose { NativeLib.subscribeNativeLogs(false) } onDispose { NativeLib.subscribeNativeLogs(false) }
} }
Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
when (screen) { val callPhase = callState?.phase
Screen.ChannelList -> { val showCallBanner = screen != Screen.Call &&
val channels by vm.channels.collectAsState() (callPhase == ChatViewModel.CallPhase.OUTGOING ||
val stuck by vm.isStuck.collectAsState() callPhase == ChatViewModel.CallPhase.RINGING ||
ChannelListScreen( callPhase == ChatViewModel.CallPhase.ACTIVE)
channels = channels, if (showCallBanner) {
connected = connected, ActiveCallBanner(
isStuck = stuck, viewModel = vm,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, onOpen = { screen = Screen.Call },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") }, onHangup = { vm.hangupCall() }
onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, )
onInviteClick = showInvite, }
onDeleteChannel = { vm.deleteChannel(it.id) }, Box(
onSettings = { screen = Screen.Settings }, modifier = Modifier.weight(1f)
onLogs = { screen = Screen.Logs }, .then(if (showCallBanner) Modifier.consumeWindowInsets(WindowInsets.statusBars) else Modifier)
onRefresh = { vm.requestRefresh() } ) {
) when (screen) {
} Screen.ChannelList -> {
Screen.Chat -> { val channels by vm.channels.collectAsState()
val messages by vm.messages.collectAsState() val dmConversations by vm.dmConversations.collectAsState()
val channel = vm.currentChannel.collectAsState().value val stuck by vm.isStuck.collectAsState()
if (channel != null) { ChannelListScreen(
ChatScreen( channels = channels,
channel = channel, dmConversations = dmConversations,
messages = messages,
connected = connected, connected = connected,
viewModel = vm, isStuck = stuck,
onSendMessage = { vm.sendMessage(it) }, onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onMembersClick = { screen = Screen.MemberList }, onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onInvite = { showInvite(channel) }, onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onBack = { screen = Screen.ChannelList }, onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onPlayVideo = { msg -> videoMessage = msg }, onInviteClick = showInvite,
onViewPhoto = { msg -> photoMessage = msg }, onDeleteChannel = { vm.deleteChannel(it.id) },
onRecordVideo = { screen = Screen.VideoRecord }, onSettings = { screen = Screen.Settings },
onCapturePhoto = { screen = Screen.PhotoRecord } onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() }
) )
} }
} Screen.Chat -> {
Screen.MemberList -> { val messages by vm.messages.collectAsState()
val members by vm.members.collectAsState() val channel = vm.currentChannel.collectAsState().value
val selectedMember by vm.selectedMember.collectAsState() if (channel != null) {
val channel = vm.currentChannel.collectAsState().value ChatScreen(
if (channel != null) { channel = channel,
MemberListScreen( messages = messages,
channel = channel, connected = connected,
members = members, viewModel = vm,
selectedMember = selectedMember, onSendMessage = { vm.sendMessage(it) },
viewModel = vm, onMembersClick = { screen = Screen.MemberList },
onBack = { screen = Screen.Chat } 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 -> {
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() }) val members by vm.members.collectAsState()
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList }) val selectedMember by vm.selectedMember.collectAsState()
Screen.Call -> CallScreen( val channel = vm.currentChannel.collectAsState().value
viewModel = vm, if (channel != null) {
onClose = { vm.dismissCall(); screen = Screen.ChannelList } MemberListScreen(
) channel = channel,
Screen.QrScan -> { members = members,
QrScanScreen( selectedMember = selectedMember,
onResult = { link -> viewModel = vm,
qrResult = link onBack = { screen = Screen.Chat }
screen = Screen.ChannelList )
}, }
onBack = { screen = Screen.ChannelList } }
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.QrScan -> {
Screen.VideoRecord -> { QrScanScreen(
val channel = vm.currentChannel.collectAsState().value onResult = { link ->
if (channel != null) { qrResult = link
VideoRecordScreen( screen = Screen.ChannelList
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
}, },
onClose = { screen = Screen.Chat } onBack = { screen = Screen.ChannelList }
) )
} }
} Screen.VideoRecord -> {
Screen.PhotoRecord -> { val channel = vm.currentChannel.collectAsState().value
val channel = vm.currentChannel.collectAsState().value if (channel != null) {
if (channel != null) { VideoRecordScreen(
PhotoRecordScreen( onSend = { path ->
onSend = { path, width, height -> vm.sendVideoMessage(path)
vm.sendPhotoMessage(path, width, height) screen = Screen.Chat
screen = Screen.Chat },
}, onClose = { 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 -> videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) { if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null }) VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
}
} }
}
photoMessage?.let { msg -> photoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) { if (msg.filePath.isNotEmpty()) {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null }) PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
}
} }
}
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) { if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm) IncomingCallDialog(viewModel = vm)
}
} }
} }

51
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -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 = "", data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "",
val peersOnline: Int = 0) 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, 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 isOutgoing: Boolean = false, val contentType: String = "text/plain",
val filePath: String = "", val waveform: List<Float> = emptyList(), val filePath: String = "", val waveform: List<Float> = emptyList(),
@ -524,4 +529,50 @@ class ChatRepository {
} }
fun close() {} 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
}
} }

13
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -162,6 +162,13 @@ object NativeLib {
fun callAudioFeed(callId: Long, samples: ShortArray): Boolean = nativeCallAudioFeed(callId, samples) fun callAudioFeed(callId: Long, samples: ShortArray): Boolean = nativeCallAudioFeed(callId, samples)
fun callAudioStop() { nativeCallAudioStop() } 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 nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean private external fun nativeStart(configText: String): Boolean
private external fun nativeStop() private external fun nativeStop()
@ -249,4 +256,10 @@ object NativeLib {
private external fun nativeCallAudioStart(callId: Long): Boolean private external fun nativeCallAudioStart(callId: Long): Boolean
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean
private external fun nativeCallAudioStop() 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
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt

@ -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))
}
}
}
}

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

@ -9,6 +9,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
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.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Headset import androidx.compose.material.icons.filled.Headset
@ -73,7 +74,8 @@ private fun CallControlButton(
@Composable @Composable
fun CallScreen( fun CallScreen(
viewModel: ChatViewModel, viewModel: ChatViewModel,
onClose: () -> Unit onClose: () -> Unit,
onMinimize: () -> Unit
) { ) {
val st = viewModel.callState.collectAsState().value val st = viewModel.callState.collectAsState().value
val muted by viewModel.muted.collectAsState() val muted by viewModel.muted.collectAsState()
@ -100,6 +102,15 @@ fun CallScreen(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)), modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center 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) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box( Box(
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape), modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),

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

@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
import com.utun.chat.data.DmConversation
import com.utun.chat.data.NotificationSound import com.utun.chat.data.NotificationSound
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -31,9 +32,11 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun ChannelListScreen( fun ChannelListScreen(
channels: List<Channel>, channels: List<Channel>,
dmConversations: List<DmConversation> = emptyList(),
connected: Boolean, connected: Boolean,
isStuck: Boolean = false, isStuck: Boolean = false,
onSelectChannel: (Channel) -> Unit, onSelectChannel: (Channel) -> Unit,
onSelectDm: (DmConversation) -> Unit = {},
onCreateChannel: (String) -> Unit, onCreateChannel: (String) -> Unit,
onJoinChannel: () -> Unit, onJoinChannel: () -> Unit,
onInviteClick: (Channel) -> Unit, onInviteClick: (Channel) -> Unit,
@ -72,7 +75,7 @@ fun ChannelListScreen(
} }
) { padding -> ) { padding ->
Column(modifier = Modifier.padding(padding)) { Column(modifier = Modifier.padding(padding)) {
if (channels.isEmpty()) { if (channels.isEmpty() && dmConversations.isEmpty()) {
Box(Modifier.fillMaxSize().weight(1f)) { Box(Modifier.fillMaxSize().weight(1f)) {
Text( Text(
"No channels yet.\nTap + to create one.", "No channels yet.\nTap + to create one.",
@ -147,6 +150,35 @@ fun ChannelListScreen(
} }
HorizontalDivider() 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()
}
}
} }
} }
} }

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

@ -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)
}
}
}
}

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

@ -85,6 +85,7 @@ fun MemberListScreen(
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) }, onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) }, onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) },
onEdit = { onEdit = {
editMember = m editMember = m
showEditDialog = true showEditDialog = true
@ -137,6 +138,7 @@ private fun MemberListItem(
onClick: () -> Unit, onClick: () -> Unit,
onConnect: () -> Unit, onConnect: () -> Unit,
onCall: () -> Unit, onCall: () -> Unit,
onMessage: () -> Unit,
onEdit: () -> Unit onEdit: () -> Unit
) { ) {
val onlineColor = when { val onlineColor = when {
@ -225,6 +227,7 @@ private fun MemberListItem(
onClick = onCall, onClick = onCall,
enabled = member.online enabled = member.online
) { Text("Call") } ) { Text("Call") }
TextButton(onClick = onMessage) { Text("Message") }
} }
TextButton(onClick = onEdit) { Text("Edit") } TextButton(onClick = onEdit) { Text("Edit") }
} }

126
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -22,6 +22,8 @@ import java.io.FileOutputStream
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private const val CALL_INCOMING_TIMEOUT_MS = 50_000L
class ChatViewModel : ViewModel() { class ChatViewModel : ViewModel() {
data class PlaybackState( data class PlaybackState(
@ -54,6 +56,7 @@ class ChatViewModel : ViewModel() {
val callAudio = CallAudioEngine() val callAudio = CallAudioEngine()
private var callStartedMs = 0L private var callStartedMs = 0L
private var callIncomingAtMs = 0L
private val _muted = MutableStateFlow(false) private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted val muted: StateFlow<Boolean> = _muted
@ -118,6 +121,22 @@ class ChatViewModel : ViewModel() {
private val _inviteLinkPending = MutableStateFlow(false) private val _inviteLinkPending = MutableStateFlow(false)
val inviteLinkPending: StateFlow<Boolean> = _inviteLinkPending 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() val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false) private val _isRecording = MutableStateFlow(false)
@ -175,6 +194,7 @@ class ChatViewModel : ViewModel() {
val cur = _callState.value val cur = _callState.value
if (cur != null && cur.callId == p.callId) return@launch 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)) 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, _callState.value = CallState(callId = p.callId, channelId = p.channelId, peerNodeId = p.peerNodeId,
peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING) peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING)
resolveCallerName(p.channelId, p.peerNodeId) resolveCallerName(p.channelId, p.peerNodeId)
@ -231,6 +251,20 @@ class ChatViewModel : ViewModel() {
delay(500) 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?) { private fun handleEvent(type: Int, data: ByteArray?) {
@ -468,6 +502,36 @@ class ChatViewModel : ViewModel() {
} }
refreshChannels() 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] */ 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 if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF val cl = data[0].toInt() and 0xFF
@ -484,6 +548,7 @@ class ChatViewModel : ViewModel() {
NativeLib.callDecline(callId) NativeLib.callDecline(callId)
return return
} }
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller, _callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING) peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
resolveCallerName(chId, caller) resolveCallerName(chId, caller)
@ -564,6 +629,7 @@ class ChatViewModel : ViewModel() {
if (st == null || st.callId != callId) return if (st == null || st.callId != callId) return
callAudio.stop() callAudio.stop()
IncomingCallManager.stopRinging() IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_muted.value = false _muted.value = false
_audioRoute.value = AudioRoute.SPEAKER _audioRoute.value = AudioRoute.SPEAKER
_headsetAvailable.value = false _headsetAvailable.value = false
@ -592,7 +658,11 @@ class ChatViewModel : ViewModel() {
fun acceptCall() { fun acceptCall() {
val st = _callState.value ?: return 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() IncomingCallManager.stopRinging()
NativeLib.callAccept(st.callId) NativeLib.callAccept(st.callId)
LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId)) LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId))
@ -619,6 +689,7 @@ class ChatViewModel : ViewModel() {
fun dismissCall() { fun dismissCall() {
callAudio.stop() callAudio.stop()
callIncomingAtMs = 0L
_callState.value = null _callState.value = null
} }
@ -676,6 +747,7 @@ class ChatViewModel : ViewModel() {
val r = repo ?: return val r = repo ?: return
viewModelScope.launch { viewModelScope.launch {
_channels.value = withContext(Dispatchers.IO) { r.getChannels() } _channels.value = withContext(Dispatchers.IO) { r.getChannels() }
_dmConversations.value = withContext(Dispatchers.IO) { r.getDmConversations() }
} }
} }
@ -693,6 +765,58 @@ class ChatViewModel : ViewModel() {
refreshMessages(channel.id) 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) { fun refreshMembers(chId: String) {
val r = repo ?: return val r = repo ?: return
val statuses = _nodeStatuses.value val statuses = _nodeStatuses.value

98
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -18,6 +18,7 @@
#include "../libutun_lite/standby.h" #include "../libutun_lite/standby.h"
#include "../libutun_lite/call_audio.h" #include "../libutun_lite/call_audio.h"
#include "../../../src/call/call.h" #include "../../../src/call/call.h"
#include "../../../src/dm/dm_core.h"
#include "../../../lib/debug_config.h" #include "../../../lib/debug_config.h"
#include "../../../lib/u_async.h" #include "../../../lib/u_async.h"
#include "../../../lib/mem.h" #include "../../../lib/mem.h"
@ -602,6 +603,63 @@ uint64_t utun_bridge_get_my_node_id(void) {
return (inst && chat_core_is_initialized(inst)) ? inst->node_id : 0; return (inst && chat_core_is_initialized(inst)) ? inst->node_id : 0;
} }
/* ── DM (личные сообщения) ── */
void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char* peer_name) {
if (!node_id) { bridge_log(BLEV_ERROR, "dm_start: invalid node_id=0"); return; }
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "dm_start: uasync not running"); return; }
struct dm_start_req* req = u_calloc(1, sizeof(*req));
if (!req) { bridge_log(BLEV_ERROR, "dm_start: OOM"); return; }
req->inst = instance_lite_get_instance();
req->peer_node_id = node_id;
if (source_ch_id) strncpy(req->source_ch_id, source_ch_id, sizeof(req->source_ch_id) - 1);
if (peer_name) strncpy(req->peer_name, peer_name, sizeof(req->peer_name) - 1);
bridge_log(BLEV_INFO, "dm_start queued peer=0x%016llx ch=%s",
(unsigned long long)node_id, source_ch_id ? source_ch_id : "");
uasync_post(ua, dm_start_trampoline, req);
}
void utun_bridge_dm_send(const char* conv_id, const char* text) {
if (!conv_id || !conv_id[0] || !text) { bridge_log(BLEV_ERROR, "dm_send: invalid args"); return; }
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "dm_send: uasync not running"); return; }
size_t len = strlen(text);
struct dm_send_req* req = u_calloc(1, sizeof(struct dm_send_req) + len);
if (!req) { bridge_log(BLEV_ERROR, "dm_send: OOM"); return; }
req->inst = instance_lite_get_instance();
strncpy(req->conv_id, conv_id, sizeof(req->conv_id) - 1);
req->data_len = (uint32_t)len;
req->data = (uint8_t*)(req + 1);
memcpy(req->data, text, len);
bridge_log(BLEV_DEBUG, "dm_send queued conv=%s len=%zu", conv_id, len);
uasync_post(ua, dm_send_trampoline, req);
}
char* utun_bridge_dm_list_conversations_json(void) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
char buf[16384]; size_t len = 0;
if (!inst || dm_list_conversations_json(inst, buf, sizeof(buf), &len) < 0)
return u_strdup("[]");
return u_strdup(buf);
}
char* utun_bridge_dm_list_messages_json(const char* conv_id, int count, int offset) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst || !conv_id || !conv_id[0]) return u_strdup("[]");
size_t cap = 262144;
char* buf = u_malloc(cap);
if (!buf) { bridge_log(BLEV_ERROR, "dm_list_messages: OOM"); return u_strdup("[]"); }
size_t len = 0;
if (dm_list_messages_json(inst, conv_id, count, offset, buf, cap, &len) < 0) {
u_free(buf);
return u_strdup("[]");
}
char* out = u_strdup(buf);
u_free(buf);
return out;
}
void utun_bridge_set_debug_level(const char* category, const char* level) { void utun_bridge_set_debug_level(const char* category, const char* level) {
if (!level) return; if (!level) return;
if (!category || !category[0]) { if (!category || !category[0]) {
@ -1724,6 +1782,46 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages(
return result; return result;
} }
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmStart(
JNIEnv* env, jobject thiz, jlong nodeId, jstring sourceChId, jstring peerName) {
(void)thiz;
const char* ch = sourceChId ? (*env)->GetStringUTFChars(env, sourceChId, NULL) : NULL;
const char* nm = peerName ? (*env)->GetStringUTFChars(env, peerName, NULL) : NULL;
utun_bridge_dm_start((uint64_t)nodeId, ch, nm);
if (nm) (*env)->ReleaseStringUTFChars(env, peerName, nm);
if (ch) (*env)->ReleaseStringUTFChars(env, sourceChId, ch);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmSend(
JNIEnv* env, jobject thiz, jstring convId, jstring text) {
(void)thiz;
const char* c = (*env)->GetStringUTFChars(env, convId, NULL);
const char* t = (*env)->GetStringUTFChars(env, text, NULL);
utun_bridge_dm_send(c, t);
if (t) (*env)->ReleaseStringUTFChars(env, text, t);
if (c) (*env)->ReleaseStringUTFChars(env, convId, c);
}
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDmListConversations(
JNIEnv* env, jobject thiz) {
(void)thiz;
char* json = utun_bridge_dm_list_conversations_json();
jstring result = (*env)->NewStringUTF(env, json);
u_free(json);
return result;
}
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDmListMessages(
JNIEnv* env, jobject thiz, jstring convId, jint count, jint offset) {
(void)thiz;
const char* c = (*env)->GetStringUTFChars(env, convId, NULL);
char* json = utun_bridge_dm_list_messages_json(c, (int)count, (int)offset);
jstring result = (*env)->NewStringUTF(env, json);
u_free(json);
if (c) (*env)->ReleaseStringUTFChars(env, convId, c);
return result;
}
JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMyNodeId( JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMyNodeId(
JNIEnv* env, jobject thiz) { JNIEnv* env, jobject thiz) {
(void)env; (void)thiz; (void)env; (void)thiz;

8
tools/chatgui-android/jni_bridge/android_jni_bridge.h

@ -185,6 +185,14 @@ void utun_bridge_notify_network_active(void);
void utun_bridge_collect_conn_list(void); void utun_bridge_collect_conn_list(void);
void utun_bridge_collect_conn_metrics(uint64_t peer_node_id); void utun_bridge_collect_conn_metrics(uint64_t peer_node_id);
/* ── DM (личные сообщения) ──
* dm_start/dm_send выполняются асинхронно через uasync (трамплины dm_core).
* Результат dm_start — событие CHAT_EVT_DM_CONV_UPDATED. */
void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char* peer_name);
void utun_bridge_dm_send(const char* conv_id, const char* text);
char* utun_bridge_dm_list_conversations_json(void);
char* utun_bridge_dm_list_messages_json(const char* conv_id, int count, int offset);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif

175
tools/chatgui/src/callwindow.cpp

@ -11,6 +11,8 @@
#include <QTimer> #include <QTimer>
#include <QDateTime> #include <QDateTime>
#include <QCloseEvent> #include <QCloseEvent>
#include <QPainter>
#include <QPainterPath>
#include <cstring> #include <cstring>
extern "C" { extern "C" {
@ -19,29 +21,146 @@ extern "C" {
#include "../../lib/debug_config.h" #include "../../lib/debug_config.h"
} }
/* ── рисование иконок звонка (QPainter, без внешних ассетов) ── */
enum class Glyph { Phone, PhoneDown, Mic, MicOff };
static QColor colorForName(const QString& name) {
static const QColor palette[] = {
QColor("#5B9BD5"), QColor("#70AD47"), QColor("#ED7D31"),
QColor("#9B59B6"), QColor("#F4B400"), QColor("#E67E22"),
QColor("#16A085"), QColor("#D35400"),
};
quint32 h = 0;
for (const QChar c : name) h = h * 31u + c.unicode();
return palette[h % 8];
}
static QPixmap makeAvatar(const QString& name, int size) {
QPixmap pm(size, size);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setBrush(colorForName(name));
p.setPen(Qt::NoPen);
p.drawEllipse(0, 0, size, size);
QString initial = name.trimmed().isEmpty() ? QStringLiteral("?") : name.trimmed().left(1).toUpper();
p.setPen(Qt::white);
QFont f = p.font();
f.setBold(true);
f.setPixelSize(size / 2);
p.setFont(f);
p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, initial);
p.end();
return pm;
}
static void drawPhoneGlyph(QPainter* p, const QRectF& r, bool down) {
const qreal s = r.width();
p->save();
p->translate(r.center());
p->rotate(down ? 135.0 : -45.0);
const qreal w = s * 0.36, h = s * 0.64;
QPainterPath path;
path.addRoundedRect(QRectF(-w / 2, -h / 2, w, h), w * 0.5, w * 0.5);
p->setPen(Qt::NoPen);
p->setBrush(Qt::white);
p->drawPath(path);
p->restore();
}
static void drawMicGlyph(QPainter* p, const QRectF& r, bool off) {
const qreal s = r.width();
const QPointF c = r.center();
p->save();
QPen pen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
p->setPen(pen);
p->setBrush(Qt::NoBrush);
const qreal cw = s * 0.30;
p->drawRoundedRect(QRectF(c.x() - cw / 2, r.top() + s * 0.10, cw, s * 0.40), cw / 2, cw / 2);
p->drawArc(QRectF(c.x() - s * 0.20, r.top() + s * 0.42, s * 0.40, s * 0.26), 180 * 16, 180 * 16);
p->drawLine(QPointF(c.x(), r.top() + s * 0.60), QPointF(c.x(), r.top() + s * 0.68));
p->drawLine(QPointF(c.x() - s * 0.17, r.top() + s * 0.68), QPointF(c.x() + s * 0.17, r.top() + s * 0.68));
if (off) {
p->setPen(QPen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap));
p->drawLine(QPointF(c.x() - s * 0.26, c.y() - s * 0.26), QPointF(c.x() + s * 0.26, c.y() + s * 0.26));
}
p->restore();
}
static QIcon makeRoundIcon(const QColor& bg, Glyph glyph, int size) {
QPixmap pm(size, size);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(Qt::NoPen);
p.setBrush(bg);
p.drawEllipse(0, 0, size, size);
const QRectF glyphRect(size * 0.24, size * 0.24, size * 0.52, size * 0.52);
switch (glyph) {
case Glyph::Phone: drawPhoneGlyph(&p, glyphRect, false); break;
case Glyph::PhoneDown: drawPhoneGlyph(&p, glyphRect, true); break;
case Glyph::Mic: drawMicGlyph(&p, glyphRect, false); break;
case Glyph::MicOff: drawMicGlyph(&p, glyphRect, true); break;
}
p.end();
return QIcon(pm);
}
static QPushButton* makeRoundBtn(const QIcon& icon, const QString& tooltip, QWidget* parent) {
auto* btn = new QPushButton(parent);
btn->setIcon(icon);
btn->setIconSize(QSize(52, 52));
btn->setFixedSize(64, 64);
btn->setCursor(Qt::PointingHandCursor);
btn->setToolTip(tooltip);
btn->setStyleSheet(
"QPushButton { border: none; border-radius: 32px; background: transparent; }"
"QPushButton:pressed { background: rgba(255,255,255,0.15); }");
return btn;
}
static QWidget* wrapWithCaption(QWidget* btn, const QString& caption, QLabel** captionOut = nullptr) {
auto* w = new QWidget(btn->parentWidget());
auto* l = new QVBoxLayout(w);
l->setContentsMargins(0, 0, 0, 0);
l->setSpacing(6);
l->addWidget(btn, 0, Qt::AlignHCenter);
auto* cap = new QLabel(caption, w);
cap->setAlignment(Qt::AlignCenter);
cap->setStyleSheet("color: palette(text); font-size: 11px; background: transparent;");
l->addWidget(cap, 0, Qt::AlignHCenter);
if (captionOut) *captionOut = cap;
return w;
}
CallWindow::CallWindow(QWidget* parent) CallWindow::CallWindow(QWidget* parent)
: QWidget(parent, Qt::Window) { : QWidget(parent, Qt::Window) {
setWindowTitle(QStringLiteral("Звонок")); setWindowTitle(QStringLiteral("Звонок"));
setFixedSize(320, 300); setFixedSize(340, 400);
setAttribute(Qt::WA_DeleteOnClose, false); setAttribute(Qt::WA_DeleteOnClose, false);
m_avatarLabel = new QLabel(this);
m_avatarLabel->setFixedSize(80, 80);
m_avatarLabel->setAlignment(Qt::AlignCenter);
m_peerLabel = new QLabel(this); m_peerLabel = new QLabel(this);
m_peerLabel->setAlignment(Qt::AlignCenter); m_peerLabel->setAlignment(Qt::AlignCenter);
m_peerLabel->setStyleSheet("font-size: 17px; font-weight: bold; color: palette(text);");
m_statusLabel = new QLabel(this); m_statusLabel = new QLabel(this);
m_statusLabel->setAlignment(Qt::AlignCenter); m_statusLabel->setAlignment(Qt::AlignCenter);
m_statusLabel->setStyleSheet("color: gray; font-size: 13px;");
m_durationLabel = new QLabel(QStringLiteral("00:00"), this); m_durationLabel = new QLabel(QStringLiteral("00:00"), this);
m_durationLabel->setAlignment(Qt::AlignCenter); m_durationLabel->setAlignment(Qt::AlignCenter);
m_durationLabel->setStyleSheet("font-size: 15px; color: palette(text); font-family: monospace;");
m_acceptBtn = new QPushButton(QStringLiteral("Принять"), this); m_acceptBtn = makeRoundBtn(makeRoundIcon(QColor("#27ae60"), Glyph::Phone, 96), QStringLiteral("Принять"), this);
m_declineBtn = new QPushButton(QStringLiteral("Отклонить"), this); m_declineBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QStringLiteral("Отклонить"), this);
m_hangupBtn = new QPushButton(QStringLiteral("Завершить"), this); m_hangupBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QStringLiteral("Завершить"), this);
m_muteBtn = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::Mic, 96), QStringLiteral("Mute"), this);
m_muteBtn = new QPushButton(QStringLiteral("Mute"), this);
m_muteBtn->setCheckable(true); m_muteBtn->setCheckable(true);
m_muteBtn->setStyleSheet(
"QPushButton { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 4px 12px; font-size: 12px; background: palette(button); }"
"QPushButton:checked { background: #c0392b; color: white; }");
m_deviceCombo = new QComboBox(this); m_deviceCombo = new QComboBox(this);
m_deviceCombo->setStyleSheet( m_deviceCombo->setStyleSheet(
@ -49,15 +168,29 @@ CallWindow::CallWindow(QWidget* parent)
" padding: 6px 10px; font-size: 13px; background: palette(base); }"); " padding: 6px 10px; font-size: 13px; background: palette(base); }");
QVBoxLayout* lay = new QVBoxLayout(this); QVBoxLayout* lay = new QVBoxLayout(this);
lay->setContentsMargins(16, 20, 16, 16);
lay->setSpacing(8);
lay->addWidget(m_avatarLabel, 0, Qt::AlignHCenter);
lay->addWidget(m_peerLabel); lay->addWidget(m_peerLabel);
lay->addWidget(m_statusLabel); lay->addWidget(m_statusLabel);
lay->addWidget(m_durationLabel); lay->addWidget(m_durationLabel);
lay->addStretch(1);
m_acceptCell = wrapWithCaption(m_acceptBtn, QStringLiteral("Принять"));
m_declineCell = wrapWithCaption(m_declineBtn, QStringLiteral("Отклонить"));
m_hangupCell = wrapWithCaption(m_hangupBtn, QStringLiteral("Завершить"));
m_muteCell = wrapWithCaption(m_muteBtn, QStringLiteral("Mute"), &m_muteCaption);
QHBoxLayout* btn = new QHBoxLayout(); QHBoxLayout* btn = new QHBoxLayout();
btn->addWidget(m_acceptBtn); btn->setSpacing(10);
btn->addWidget(m_declineBtn); btn->addStretch(1);
btn->addWidget(m_hangupBtn); btn->addWidget(m_acceptCell);
btn->addWidget(m_declineCell);
btn->addWidget(m_hangupCell);
btn->addWidget(m_muteCell);
btn->addStretch(1);
lay->addLayout(btn); lay->addLayout(btn);
lay->addWidget(m_muteBtn); lay->addStretch(1);
lay->addWidget(m_deviceCombo); lay->addWidget(m_deviceCombo);
connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept); connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept);
@ -77,6 +210,7 @@ CallWindow::~CallWindow() = default;
void CallWindow::setPeerName(const QString& name) { void CallWindow::setPeerName(const QString& name) {
m_peerLabel->setText(name); m_peerLabel->setText(name);
m_avatarLabel->setPixmap(makeAvatar(name, 80));
} }
void CallWindow::setState(State s) { void CallWindow::setState(State s) {
@ -125,10 +259,10 @@ void CallWindow::populateDeviceCombo() {
} }
void CallWindow::updateButtons() { void CallWindow::updateButtons() {
m_acceptBtn->setVisible(m_state == Incoming); m_acceptCell->setVisible(m_state == Incoming);
m_declineBtn->setVisible(m_state == Incoming); m_declineCell->setVisible(m_state == Incoming);
m_hangupBtn->setVisible(m_state == Outgoing || m_state == Active); m_hangupCell->setVisible(m_state == Outgoing || m_state == Active);
m_muteBtn->setVisible(m_state == Active); m_muteCell->setVisible(m_state == Active);
m_deviceCombo->setVisible(m_state == Active); m_deviceCombo->setVisible(m_state == Active);
} }
@ -155,7 +289,10 @@ void CallWindow::onHangup() {
void CallWindow::onMuteToggled(bool checked) { void CallWindow::onMuteToggled(bool checked) {
CallAudioEngine::instance()->setMuted(checked); CallAudioEngine::instance()->setMuted(checked);
m_muteBtn->setText(checked ? QStringLiteral("Вкл. микрофон") : QStringLiteral("Mute")); m_muteBtn->setIcon(makeRoundIcon(checked ? QColor("#e74c3c") : QColor("#7f8c8d"),
checked ? Glyph::MicOff : Glyph::Mic, 96));
if (m_muteCaption)
m_muteCaption->setText(checked ? QStringLiteral("Вкл. микрофон") : QStringLiteral("Mute"));
} }
void CallWindow::onDeviceChanged(int index) { void CallWindow::onDeviceChanged(int index) {

7
tools/chatgui/src/callwindow.h

@ -48,6 +48,7 @@ private:
State m_state = Outgoing; State m_state = Outgoing;
qint64 m_startMs = 0; qint64 m_startMs = 0;
QLabel* m_avatarLabel;
QLabel* m_peerLabel; QLabel* m_peerLabel;
QLabel* m_statusLabel; QLabel* m_statusLabel;
QLabel* m_durationLabel; QLabel* m_durationLabel;
@ -57,4 +58,10 @@ private:
QPushButton* m_muteBtn; QPushButton* m_muteBtn;
QComboBox* m_deviceCombo; QComboBox* m_deviceCombo;
QTimer* m_durationTimer; QTimer* m_durationTimer;
QWidget* m_acceptCell;
QWidget* m_declineCell;
QWidget* m_hangupCell;
QWidget* m_muteCell;
QLabel* m_muteCaption;
}; };

Loading…
Cancel
Save