diff --git a/src/dm/dm_core.c b/src/dm/dm_core.c index 35d23f8b..a6156eff 100644 --- a/src/dm/dm_core.c +++ b/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; } -/* Есть ли общая группа с пиром (anti-spam: принимаем DM только от знакомых из общих каналов). */ -static int dm_share_group(struct dm_state* dm, uint64_t peer_id) { +/* Общий CHAT-канал с пиром. Возвращает 1 и кладёт group_id (== channel_id) в *out_group_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; int 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; for (int i = 0; i < ch_count; 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); 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 штук. * Используется для offline-pull: потенциальные отправители почты = мемберы общих групп. */ 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->last_out_seq = (uint64_t)sqlite3_column_int64(st, 5); 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); 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); 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); return; } memset(&c, 0, sizeof(c)); snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id); c.peer_node_id = author; + c.group_id = gid; 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); return; } 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); - DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: auto-created conversation conv=%s peer=0x%016llx", - DM_ID, conv_id, (unsigned long long)author); + DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: auto-created conversation conv=%s peer=0x%016llx group=%llu", + DM_ID, conv_id, (unsigned long long)author, (unsigned long long)c.group_id); /* событие 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); 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); return; } memset(&c, 0, sizeof(c)); snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id); c.peer_node_id = from_peer; + c.group_id = gid; 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); return; } dm_derive_content_key(dm->inst->my_keys.private_key, c.peer_x25519, c.content_key); dm_conv_save(dm, &c); - DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: hello auto-created conversation conv=%s peer=0x%016llx", - DM_ID, conv_id, (unsigned long long)from_peer); + 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, (unsigned long long)c.group_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; 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 { memset(&c, 0, sizeof(c)); 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-строку: " \ \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. */ 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); @@ -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 out_seq = (uint64_t)sqlite3_column_int64(st, 4); 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, "%s{\"conv_id\":\"%s\",\"peer_node_id\":%llu,\"peer_name\":\"%s\"," "\"last_ts\":%llu,\"out_seq\":%llu,\"in_seq\":%llu}", 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); first = 0; } @@ -858,11 +912,11 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c char sql[256]; if (count > 0) 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); else 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; 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); } + 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, "%s{\"dir\":%d,\"seq\":%llu,\"ts\":%llu,\"author\":%llu,\"ct\":\"%s\",\"data\":\"%s\"}", 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; } } @@ -974,7 +1031,7 @@ void dm_messages_trampoline(void* arg) { size_t msgs_len = 0, msgs_cap = 0; int count = 0; 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) { sqlite3_bind_text(st, 1, conv_id, -1, SQLITE_STATIC); while (sqlite3_step(st) == SQLITE_ROW) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index b1579485..7ce71c00 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -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(null) } var photoMessage by remember { mutableStateOf(null) } var hadCall by remember { mutableStateOf(false) } + var screenBeforeCall by remember { mutableStateOf(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, + 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, + onOpen = { screen = Screen.Call }, + onHangup = { vm.hangupCall() } + ) + } + 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, - 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 } + 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.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.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.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 } - ) - Screen.QrScan -> { - QrScanScreen( - onResult = { link -> - qrResult = link - screen = Screen.ChannelList - }, - onBack = { screen = Screen.ChannelList } + 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) + } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index d47d86e8..d404fd31 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/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 = "", 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 = 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 { + val list = mutableListOf() + 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 { + val list = mutableListOf() + 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 + } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index c2d03178..ac135062 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/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 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? } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt new file mode 100644 index 00000000..b564ca7c --- /dev/null +++ b/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)) + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index 2aac6664..ee639aff 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/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.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), diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index f3a48cb0..67698f70 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/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.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, + dmConversations: List = 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() + } + } } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt new file mode 100644 index 00000000..4071e7ee --- /dev/null +++ b/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, + 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) + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 4ab0e075..975805bf 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/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) }, 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") } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index f042b0be..ca2aba36 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/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 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 = _muted @@ -118,6 +121,22 @@ class ChatViewModel : ViewModel() { private val _inviteLinkPending = MutableStateFlow(false) val inviteLinkPending: StateFlow = _inviteLinkPending + private val _dmConversations = MutableStateFlow>(emptyList()) + val dmConversations: StateFlow> = _dmConversations + + private val _dmMessages = MutableStateFlow>(emptyList()) + val dmMessages: StateFlow> = _dmMessages + + private val _currentDm = MutableStateFlow(null) + val currentDm: StateFlow = _currentDm + + /* Одноразовый сигнал навигации: MainActivity открывает экран DM и сбрасывает. */ + private val _dmOpen = MutableStateFlow(null) + val dmOpen: StateFlow = _dmOpen + + /* peer-данные начатой беседы — для автооткрытия по событию DM_CONV_UPDATED. */ + private var pendingStartPeer: Pair? = 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 diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 53c9292a..3f698422 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -18,6 +18,7 @@ #include "../libutun_lite/standby.h" #include "../libutun_lite/call_audio.h" #include "../../../src/call/call.h" +#include "../../../src/dm/dm_core.h" #include "../../../lib/debug_config.h" #include "../../../lib/u_async.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; } +/* ── 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) { if (!level) return; if (!category || !category[0]) { @@ -1724,6 +1782,46 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages( 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( JNIEnv* env, jobject thiz) { (void)env; (void)thiz; diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index ac2b996d..6763180b 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/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_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 } #endif diff --git a/tools/chatgui/src/callwindow.cpp b/tools/chatgui/src/callwindow.cpp index 653007d8..327cb0f1 100644 --- a/tools/chatgui/src/callwindow.cpp +++ b/tools/chatgui/src/callwindow.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include extern "C" { @@ -19,29 +21,146 @@ extern "C" { #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) : QWidget(parent, Qt::Window) { setWindowTitle(QStringLiteral("Звонок")); - setFixedSize(320, 300); + setFixedSize(340, 400); 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->setAlignment(Qt::AlignCenter); + m_peerLabel->setStyleSheet("font-size: 17px; font-weight: bold; color: palette(text);"); + m_statusLabel = new QLabel(this); m_statusLabel->setAlignment(Qt::AlignCenter); + m_statusLabel->setStyleSheet("color: gray; font-size: 13px;"); + m_durationLabel = new QLabel(QStringLiteral("00:00"), this); m_durationLabel->setAlignment(Qt::AlignCenter); + m_durationLabel->setStyleSheet("font-size: 15px; color: palette(text); font-family: monospace;"); - m_acceptBtn = new QPushButton(QStringLiteral("Принять"), this); - m_declineBtn = new QPushButton(QStringLiteral("Отклонить"), this); - m_hangupBtn = new QPushButton(QStringLiteral("Завершить"), this); - - m_muteBtn = new QPushButton(QStringLiteral("Mute"), this); + m_acceptBtn = makeRoundBtn(makeRoundIcon(QColor("#27ae60"), Glyph::Phone, 96), QStringLiteral("Принять"), this); + m_declineBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), 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->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->setStyleSheet( @@ -49,15 +168,29 @@ CallWindow::CallWindow(QWidget* parent) " padding: 6px 10px; font-size: 13px; background: palette(base); }"); 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_statusLabel); 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(); - btn->addWidget(m_acceptBtn); - btn->addWidget(m_declineBtn); - btn->addWidget(m_hangupBtn); + btn->setSpacing(10); + btn->addStretch(1); + btn->addWidget(m_acceptCell); + btn->addWidget(m_declineCell); + btn->addWidget(m_hangupCell); + btn->addWidget(m_muteCell); + btn->addStretch(1); lay->addLayout(btn); - lay->addWidget(m_muteBtn); + lay->addStretch(1); lay->addWidget(m_deviceCombo); connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept); @@ -77,6 +210,7 @@ CallWindow::~CallWindow() = default; void CallWindow::setPeerName(const QString& name) { m_peerLabel->setText(name); + m_avatarLabel->setPixmap(makeAvatar(name, 80)); } void CallWindow::setState(State s) { @@ -125,10 +259,10 @@ void CallWindow::populateDeviceCombo() { } void CallWindow::updateButtons() { - m_acceptBtn->setVisible(m_state == Incoming); - m_declineBtn->setVisible(m_state == Incoming); - m_hangupBtn->setVisible(m_state == Outgoing || m_state == Active); - m_muteBtn->setVisible(m_state == Active); + m_acceptCell->setVisible(m_state == Incoming); + m_declineCell->setVisible(m_state == Incoming); + m_hangupCell->setVisible(m_state == Outgoing || m_state == Active); + m_muteCell->setVisible(m_state == Active); m_deviceCombo->setVisible(m_state == Active); } @@ -155,7 +289,10 @@ void CallWindow::onHangup() { void CallWindow::onMuteToggled(bool 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) { diff --git a/tools/chatgui/src/callwindow.h b/tools/chatgui/src/callwindow.h index 94ee6975..b4dc86ef 100644 --- a/tools/chatgui/src/callwindow.h +++ b/tools/chatgui/src/callwindow.h @@ -48,6 +48,7 @@ private: State m_state = Outgoing; qint64 m_startMs = 0; + QLabel* m_avatarLabel; QLabel* m_peerLabel; QLabel* m_statusLabel; QLabel* m_durationLabel; @@ -57,4 +58,10 @@ private: QPushButton* m_muteBtn; QComboBox* m_deviceCombo; QTimer* m_durationTimer; + + QWidget* m_acceptCell; + QWidget* m_declineCell; + QWidget* m_hangupCell; + QWidget* m_muteCell; + QLabel* m_muteCaption; };