From 13039933da817479181efb6b0158151abd2057ff Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 00:49:00 +0100 Subject: [PATCH 1/8] android: separate avatar taps from conversation context menus --- .../com/utun/chat/ui/components/Avatar.kt | 12 +- .../chat/ui/screens/AvatarViewerScreen.kt | 135 ++++++++++++------ .../utun/chat/ui/screens/ChannelListScreen.kt | 90 +++++++++--- 3 files changed, 168 insertions(+), 69 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt index ce2f4435..98c6a6a6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt @@ -1,8 +1,9 @@ package com.utun.chat.ui.components import android.graphics.Bitmap +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -16,14 +17,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.utun.chat.ChatApplication +import com.utun.chat.R import com.utun.chat.ui.screens.AvatarViewerScreen +/** Короткое нажатие открывает просмотр; удержание можно передать меню содержащей строки. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun UserAvatar(group: String?, owner: Long, name: String, modifier: Modifier = Modifier, - size: Dp = 40.dp, profile: Boolean = false) { + size: Dp = 40.dp, profile: Boolean = false, enabled: Boolean = true, onLongClick: (() -> Unit)? = null) { val repository = ChatApplication.instance.avatars val revision by repository.revision.collectAsState() var viewing by rememberSaveable(group,owner) { mutableStateOf(false) } @@ -31,7 +36,8 @@ fun UserAvatar(group: String?, owner: Long, name: String, modifier: Modifier = M if (profile) repository.ensureProfile() else repository.ensureUser(group, owner) } AvatarImage(if (profile) repository.profileBitmap() else repository.userBitmap(group, owner), name, - modifier.clickable(enabled=!profile) { viewing=true }, size) + modifier.combinedClickable(enabled = enabled && !profile, onLongClick = onLongClick, + onLongClickLabel = if (onLongClick != null) stringResource(R.string.options) else null, onClick = { viewing = true }), size) if (viewing) AvatarViewerScreen(group,owner,name,onClose={ viewing=false }) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt index 63d21df0..d4ee3647 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt @@ -97,55 +97,102 @@ fun AvatarViewerScreen(group: String?, owner: Long, name: String, onClose: () -> } } } } fun move(delta: Int) { val next=index+delta; if (next in files.indices) selected=files[next] } - Dialog(onDismissRequest=onClose,properties=DialogProperties(decorFitsSystemWindows=false)) { + AvatarViewerDialog(name, onClose) { val view=LocalView.current - val window=(view.parent as DialogWindowProvider).window - DisposableEffect(window) { - // Compose 1.6 с usePlatformDefaultWidth=false измеряет по дисплею, а не по доступной области окна. - window.setLayout(WindowManager.LayoutParams.MATCH_PARENT,WindowManager.LayoutParams.MATCH_PARENT) - onDispose { } + Box(Modifier.weight(1f).fillMaxWidth().clipToBounds(),contentAlignment=Alignment.Center) { + val image=original ?: previews[selected] + if (image!=null) ZoomableImage(image,Modifier.fillMaxSize(),onSwipe={ move(it) }) + else if (source!=null && !failed) CircularProgressIndicator() + else Text(stringResource(if (source==null) R.string.avatar_none else R.string.avatar_load_failed),color=Color.White) } - Column(Modifier.fillMaxSize().background(Color.Black).safeDrawingPadding().onSizeChanged { - LogManager.addLog("DEBUG","Avatar","Viewer content=${it.width}x${it.height}") - }) { - Row(Modifier.fillMaxWidth(),verticalAlignment=Alignment.CenterVertically) { - Text(name,color=Color.White,modifier=Modifier.weight(1f).padding(12.dp)) - TextButton(onClick=onClose) { Text(stringResource(R.string.close)) } - } - Box(Modifier.weight(1f).fillMaxWidth().clipToBounds(),contentAlignment=Alignment.Center) { - val image=original ?: previews[selected] - if (image!=null) ZoomableImage(image,Modifier.fillMaxSize(),onSwipe={ move(it) }) - else if (source!=null && !failed) CircularProgressIndicator() - else Text(stringResource(if (source==null) R.string.avatar_none else R.string.avatar_load_failed),color=Color.White) - } - if (failed || catalogFailed || failedPreviews.isNotEmpty()) TextButton(onClick={ retry++ },modifier=Modifier.align(Alignment.CenterHorizontally)) { - Text(stringResource(R.string.avatar_retry)) - } else if (original==null && source!=null) LinearProgressIndicator(Modifier.fillMaxWidth()) - if (index>=0) Text("${index+1} / ${files.size}",color=Color.White,modifier=Modifier.align(Alignment.CenterHorizontally)) - Row(Modifier.fillMaxWidth().padding(vertical=8.dp).onGloballyPositioned { - val insets=ViewCompat.getRootWindowInsets(view)?.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) - LogManager.addLog("DEBUG","Avatar","Viewer thumbnails position=${it.positionInWindow()} size=${it.size} " + - "window=${view.width}x${view.height} insets=$insets") - },verticalAlignment=Alignment.CenterVertically, - horizontalArrangement=Arrangement.Center) { - TextButton(enabled=index>0,onClick={ move(-1) },contentPadding=PaddingValues(4.dp),modifier=Modifier.width(32.dp)) { Text("←") } - repeat(5) { slot -> - val item=index+slot-2; val file=files.getOrNull(item) - Box(Modifier.weight(1f).aspectRatio(1f).padding(2.dp) - .border(if (slot==2) 2.dp else 0.dp,if (slot==2) Color(0xFF77AAFF) else Color.Transparent) - .clickable(enabled=file!=null) { if (file!=null) selected=file },contentAlignment=Alignment.Center) { - previews[file]?.let { Image(it.asImageBitmap(),contentDescription=null,contentScale=ContentScale.Crop, - modifier=Modifier.fillMaxSize()) } - if (file!=null && file !in previews) { - if (file in failedPreviews) Text("!",color=Color.White) - else CircularProgressIndicator(Modifier.size(18.dp),strokeWidth=2.dp) - } + if (failed || catalogFailed || failedPreviews.isNotEmpty()) TextButton(onClick={ retry++ },modifier=Modifier.align(Alignment.CenterHorizontally)) { + Text(stringResource(R.string.avatar_retry)) + } else if (original==null && source!=null) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (index>=0) Text("${index+1} / ${files.size}",color=Color.White,modifier=Modifier.align(Alignment.CenterHorizontally)) + Row(Modifier.fillMaxWidth().padding(vertical=8.dp).onGloballyPositioned { + val insets=ViewCompat.getRootWindowInsets(view)?.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + LogManager.addLog("DEBUG","Avatar","Viewer thumbnails position=${it.positionInWindow()} size=${it.size} " + + "window=${view.width}x${view.height} insets=$insets") + },verticalAlignment=Alignment.CenterVertically, + horizontalArrangement=Arrangement.Center) { + TextButton(enabled=index>0,onClick={ move(-1) },contentPadding=PaddingValues(4.dp),modifier=Modifier.width(32.dp)) { Text("←") } + repeat(5) { slot -> + val item=index+slot-2; val file=files.getOrNull(item) + Box(Modifier.weight(1f).aspectRatio(1f).padding(2.dp) + .border(if (slot==2) 2.dp else 0.dp,if (slot==2) Color(0xFF77AAFF) else Color.Transparent) + .clickable(enabled=file!=null) { if (file!=null) selected=file },contentAlignment=Alignment.Center) { + previews[file]?.let { Image(it.asImageBitmap(),contentDescription=null,contentScale=ContentScale.Crop, + modifier=Modifier.fillMaxSize()) } + if (file!=null && file !in previews) { + if (file in failedPreviews) Text("!",color=Color.White) + else CircularProgressIndicator(Modifier.size(18.dp),strokeWidth=2.dp) } } - TextButton(enabled=index>=0 && index+1=0 && index+1 Unit) { + val repository = ChatApplication.instance.avatars + val groups by repository.groups.collectAsState() + val revision by repository.revision.collectAsState() + val account = repository.accountId + val state = groups[group] + val image = remember(group, state, revision) { repository.groupBitmap(group) } + DisposableEffect(group, account) { + LogManager.addLog("INFO", "Avatar", "Group viewer opened group=$group account=$account") + onDispose { LogManager.addLog("INFO", "Avatar", "Group viewer closed group=$group") } + } + LaunchedEffect(group, account, revision) { + if (account == 0L) onClose() else repository.ensureGroup(group) + } + LaunchedEffect(group, state, image) { + LogManager.addLog("DEBUG", "Avatar", "Group viewer group=$group version=${state?.version} loading=${state?.loading} " + + "error=${state?.error} hasImage=${state?.hasImage} bitmap=${image?.width}x${image?.height}") + } + AvatarViewerDialog(name, onClose) { + Box(Modifier.weight(1f).fillMaxWidth().clipToBounds(), contentAlignment = Alignment.Center) { + when { + image != null -> ZoomableImage(image, Modifier.fillMaxSize()) + state?.error == true -> Text(stringResource(R.string.avatar_load_failed), color = Color.White) + state == null || state.loading -> CircularProgressIndicator() + else -> Text(stringResource(R.string.avatar_none), color = Color.White) + } + } + if (state?.error == true) TextButton(onClick = { + LogManager.addLog("INFO", "Avatar", "Group viewer retry group=$group") + repository.ensureGroup(group, true) + }, modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text(stringResource(R.string.avatar_retry)) + } else if (state?.loading == true && image != null) LinearProgressIndicator(Modifier.fillMaxWidth()) + } +} + +/** Общие размеры окна, безопасные отступы и закрытие для обоих видов аватарок. */ +@Composable +private fun AvatarViewerDialog(name: String, onClose: () -> Unit, content: @Composable ColumnScope.() -> Unit) { + Dialog(onDismissRequest = onClose, properties = DialogProperties(decorFitsSystemWindows = false)) { + val view = LocalView.current + val window = (view.parent as DialogWindowProvider).window + DisposableEffect(window) { + // Compose 1.6 с usePlatformDefaultWidth=false измеряет по дисплею, а не по доступной области окна. + window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT) + onDispose { } + } + Column(Modifier.fillMaxSize().background(Color.Black).safeDrawingPadding().onSizeChanged { + LogManager.addLog("DEBUG", "Avatar", "Viewer content=${it.width}x${it.height}") + }) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(name, color = Color.White, modifier = Modifier.weight(1f).padding(12.dp)) + TextButton(onClick = onClose) { Text(stringResource(R.string.close)) } + } + content() } } } 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 12c2212d..248c2b72 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 @@ -3,13 +3,13 @@ package com.utun.chat.ui.screens import com.utun.chat.R import com.utun.chat.BuildConfig import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.QrCode @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Build import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import com.utun.chat.ui.components.PeerStatusDot import com.utun.chat.ui.components.CpuLoadIndicator import androidx.lifecycle.compose.LifecycleResumeEffect @@ -34,12 +35,9 @@ 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.data.LogManager -import com.utun.chat.ChatApplication -import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ChannelListScreen( channels: List, @@ -73,6 +71,20 @@ fun ChannelListScreen( var deleteConfirmChannel by remember { mutableStateOf(null) } var menuDmId by remember { mutableStateOf(null) } var deleteConfirmDm by remember { mutableStateOf(null) } + var avatarChannelId by rememberSaveable { mutableStateOf(null) } + val optionsLabel = stringResource(R.string.options) + + LaunchedEffect(channels, dmConversations, deletingDm) { + if (channels.none { it.id == menuChannelId }) menuChannelId = null + if (channels.none { it.id == avatarChannelId }) avatarChannelId = null + if (dmConversations.none { it.convId == menuDmId && it.convId !in deletingDm }) menuDmId = null + deleteConfirmDm?.let { selected -> + if (dmConversations.none { it.convId == selected.convId && it.generation == selected.generation && it.convId !in deletingDm }) { + LogManager.addLog("DEBUG", "UI", "Dismiss stale PM deletion conv=${selected.convId} generation=${selected.generation}") + deleteConfirmDm = null + } + } + } LaunchedEffect(Unit) { LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}") @@ -122,7 +134,7 @@ fun ChannelListScreen( } } else { LazyColumn { - items(channels) { ch -> + items(channels, key = { "channel:${it.id}" }) { ch -> val barColor = if (ch.peersOnline > 0) Color(0xFF81C784) else Color(0xFFAEBCC8) val badgeText = when { ch.peersOnline > 99 -> "99+" @@ -130,29 +142,47 @@ fun ChannelListScreen( else -> "" } - Surface(modifier = Modifier.clickable { onSelectChannel(ch) }) { + val openMenu: (String) -> Unit = { source -> + LogManager.addLog("DEBUG", "UI", "Channel menu opened channel=${ch.id} source=$source") + menuDmId = null + menuChannelId = ch.id + } + Surface(modifier = Modifier.combinedClickable(onLongClickLabel = optionsLabel, + onLongClick = { openMenu("row") }, onClick = { + LogManager.addLog("INFO", "UI", "Channel opened channel=${ch.id}") + onSelectChannel(ch) + })) { Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) Row(Modifier.fillMaxWidth().padding(12.dp)) { - Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { - IconButton(onClick = { menuChannelId = ch.id }, modifier = Modifier.size(44.dp)) { - GroupAvatar(ch.id, ch.name) - } + Box(Modifier.size(44.dp).combinedClickable(onLongClickLabel = optionsLabel, + onLongClick = { openMenu("avatar") }, onClick = { avatarChannelId = ch.id }), + contentAlignment = Alignment.Center) { + GroupAvatar(ch.id, ch.name) DropdownMenu( expanded = menuChannelId == ch.id, onDismissRequest = { menuChannelId = null } ) { DropdownMenuItem( text = { Text(stringResource(R.string.invite_link)) }, - onClick = { menuChannelId = null; onInviteClick(ch) } + onClick = { + LogManager.addLog("INFO", "UI", "Channel menu invite channel=${ch.id}") + menuChannelId = null; onInviteClick(ch) + } ) DropdownMenuItem( text = { Text(stringResource(R.string.group_properties)) }, - onClick = { menuChannelId = null; optionsChannel = ch } + onClick = { + LogManager.addLog("INFO", "UI", "Channel menu properties channel=${ch.id}") + menuChannelId = null; optionsChannel = ch + } ) DropdownMenuItem( text = { Text(stringResource(R.string.delete_channel), color = MaterialTheme.colorScheme.error) }, - onClick = { menuChannelId = null; deleteConfirmChannel = ch } + onClick = { + LogManager.addLog("INFO", "UI", "Channel menu delete channel=${ch.id}") + menuChannelId = null; deleteConfirmChannel = ch + } ) } if (badgeText.isNotEmpty()) { @@ -198,20 +228,32 @@ fun ChannelListScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - items(dmConversations) { dm -> - Surface(modifier = Modifier.clickable(enabled = dm.convId !in deletingDm) { onSelectDm(dm) }) { + items(dmConversations, key = { "dm:${it.convId}:${it.generation}" }) { dm -> + val enabled = dm.convId !in deletingDm + val openMenu: (String) -> Unit = { source -> + LogManager.addLog("DEBUG", "UI", "PM menu opened conv=${dm.convId} generation=${dm.generation} source=$source") + menuChannelId = null + menuDmId = dm.convId + } + Surface(modifier = Modifier.combinedClickable(enabled = enabled, onLongClickLabel = optionsLabel, + onLongClick = { openMenu("row") }, onClick = { + LogManager.addLog("INFO", "UI", "PM opened conv=${dm.convId} generation=${dm.generation}") + onSelectDm(dm) + })) { Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFFD2A8FF))) Row(Modifier.fillMaxWidth().padding(12.dp)) { Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { - IconButton(onClick = { menuDmId = dm.convId }, modifier = Modifier.size(44.dp)) { - UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp) - } + UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp, + enabled = enabled, onLongClick = { openMenu("avatar") }) DropdownMenu(expanded = menuDmId == dm.convId, onDismissRequest = { menuDmId = null }) { DropdownMenuItem( text = { Text(stringResource(R.string.delete_conversation), color = MaterialTheme.colorScheme.error) }, - enabled = dm.convId !in deletingDm, - onClick = { menuDmId = null; deleteConfirmDm = dm } + enabled = enabled, + onClick = { + LogManager.addLog("INFO", "UI", "PM menu delete conv=${dm.convId} generation=${dm.generation}") + menuDmId = null; deleteConfirmDm = dm + } ) } PeerStatusDot(dm.online, Modifier.align(Alignment.TopStart).offset(x = (-4).dp, y = (-6).dp)) @@ -231,6 +273,10 @@ fun ChannelListScreen( } } + channels.firstOrNull { it.id == avatarChannelId }?.let { ch -> + GroupAvatarViewerScreen(ch.id, ch.name, onClose = { avatarChannelId = null }) + } + deleteConfirmDm?.let { dm -> AlertDialog( onDismissRequest = { deleteConfirmDm = null }, From 2fe024fbbba655059576dabacbe6393d1115dfe9 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 00:54:11 +0100 Subject: [PATCH 2/8] docs(dm): clarify core and history ownership contracts --- src/dm/dm_core.h | 90 +++++++++++++++++++++++++++++---------------- src/dm/dm_history.h | 14 +++++-- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/dm/dm_core.h b/src/dm/dm_core.h index 3bd92a6e..00f72fcf 100644 --- a/src/dm/dm_core.h +++ b/src/dm/dm_core.h @@ -9,7 +9,11 @@ * при недоступности пира — через dm_mailbox (суперузлы). * - содержимое шифруется E2E (AES-256-CCM), запись подписывается Ed25519. * - * Два независимых направленных потока (я→пир и пир→я) с монотонным seq. + * Два независимых направленных потока (я→пир и пир→я) с монотонным исходящим seq. + * Все API и трамплины выполняются в uasync-потоке с живым instance/SQLite/router. + * Входные строки/тела заимствуются на время вызова; функции не забирают их владение. + * Запросы трамплинов — один блок u_malloc/u_calloc с NUL-строками; обработчик освобождает блок. + * Instance должен пережить запрос и его отложенное завершение; остановка сериализуется через uasync. */ #ifndef DM_CORE_H @@ -29,7 +33,9 @@ extern "C" { /* ── Каноническое тело DM-сообщения (общий wire-формат dm_core и dm_mailbox) ── * [conv_id:8][seq:8][ts:8][author:8][ct_len:1][ct:ct_len][data_len:2][data_enc:data_len][media?:56][sig:64] * media присутствует только для ct="file", подписан вместе с сообщением. - * data_enc — AES-256-CCM (шифротекст + tag), sig — Ed25519 автора по всем полям до sig. */ + * data_enc — AES-256-CCM (шифротекст + tag), sig — Ed25519 автора по всем полям до sig. + * Числа native-endian; seq=1..INT64_MAX, ts — миллисекунды NTP-времени instance. + * ETCP_RT_ID_DM=0x34: [MSG=1][тело] либо [ACK=2][квитанция:136]; router-пакет подписан. */ #define DM_MSG_FIXED_HDR 33 /* conv_id+seq+ts+author(32) + ct_len(1) — до ct */ #define DM_MSG_SIG_SIZE 64 #define DM_DATA_MAX 1024 @@ -54,22 +60,29 @@ static inline int dm_msg_len(const uint8_t* msg, size_t avail, size_t* out_len) return 1; } -/* Общие операции протокола DM; все вызовы в uasync-потоке. - * group_id — только область маршрутизации, идентичность беседы от неё не зависит. - * route_group выбирает общую группу с живым путём, возвращает 1/0. - * verify_message проверяет каноническое тело и подпись автора для recipient. - * accept_message возвращает 0 только после commit (включая точный повтор). - * Квитанция: ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(msg):32][sig:64]. - * verify_receipt проверяет подпись получателя и при msg!=NULL точное соответствие телу. - * accept_receipt удаляет только соответствующее исходящее pending-сообщение. */ +/* Выбрать общую CHAT-группу с путём router к peer, сначала preferred (0=без предпочтения). + * 1 и *group_id при успехе; 0 при отсутствии пути/ошибке, выход не меняется. + * group_id обязателен; наличие пути не гарантирует успешной последующей отправки. */ int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, uint64_t* group_id); +/* Проверить точную длину msg/len, conv/author/recipient/seq, descriptor и подпись автора из nodes. + * 0=корректно, -1=отказ. Нужна SQLite, владелец dm_core не требуется. + * Не расшифровывает тело и не проверяет членство/роль router-отправителя: это обязанность вызывающего. */ int dm_verify_message(struct UTUN_INSTANCE* inst, uint64_t recipient, const uint8_t* msg, size_t len); +/* Проверить и расшифровать входящее тело для локального узла, атомарно сохранить историю/беседу/media. + * 0 и receipt после commit либо точного повтора; -1 при отказе. Для file нужен живой dm_media. + * Точный удалённый повтор подтверждается по отпечатку без восстановления истории; конфликт отклоняется. + * receipt — обязательные 136 байт; при -1 буфер не использовать. Ошибка подписи квитанции возможна после commit. */ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len, uint8_t receipt[DM_RECEIPT_SIZE]); +/* receipt — ровно 136 доступных байт: ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(msg):32][sig:64]. + * Проверяет идентичность и подпись B по первым 72 байтам; при msg!=NULL также точное тело msg/len. + * 0=корректно, -1=отказ. Не проверяет наличие outbox и ничего не записывает. */ int dm_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE], const uint8_t* msg, size_t len); +/* Для локального автора проверить receipt и удалить только точный outbox; история/file receipt сохраняются. + * 0=удалено либо outbox уже отсутствует, -1=ошибка/конфликт. Требуется запущенный dm_core. */ int dm_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE]); /* Жизненный цикл в uasync: init после chat_core_init с SQLite/router/ua ядра; 0=успех, -1=ошибка. - * Повторный init запущенного владельца возвращает 0. Для send/accept нужен также dm_media_init. + * Повторный init запущенного владельца возвращает 0. Для file-send/accept нужен также dm_media_init. * destroy отменяет invite/таймер и снимает bind; БД/outbox сохраняются. Вызывать до chat_core_destroy. */ int dm_core_init(struct UTUN_INSTANCE* inst); void dm_core_destroy(struct UTUN_INSTANCE* inst); @@ -99,9 +112,16 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id, int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content_type, const uint8_t* data, uint32_t data_len); -/* Локальное удаление; generation защищает повторно открытую беседу от старого запроса. */ -struct dm_delete_req { struct UTUN_INSTANCE* inst; char conv_id[64]; uint64_t generation, request_id; }; +/* Локальное удаление по dm_history_delete; generation защищает повторно открытую беседу. + * CHAT_EVT_DM_DELETE_RESULT: [request_id:8][conv:u64][generation:8][success:u8], всего 25 байт native-endian. */ +struct dm_delete_req { + struct UTUN_INSTANCE* inst; /* Заимствованное ядро до завершения запроса. */ + char conv_id[64]; /* Десятичный ID беседы с NUL. */ + uint64_t generation; /* Ненулевое ожидаемое поколение. */ + uint64_t request_id; /* Корреляция результата с запросом UI. */ +}; void dm_delete_trampoline(void* arg); +/* После commit удаления отменить ожидающий invite этой беседы; квитанции/другие беседы не меняет. */ void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv); /* До 200 видимых бесед по last_ts DESC: [{conv_id,peer_node_id,peer_name,last_ts,out_seq,in_seq,generation}]. @@ -113,7 +133,9 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf * count=1..200 задаёт предел; count<=0 или count>200 означает 200. * offset>=0 отсчитывается от новых сообщений; offset=0 выбирает последние count. * Выбранная страница возвращается по возрастанию ts, seq, dir. - * buf — выходной буфер с NUL; 0 и *out_len байт без NUL при успехе, -1 при ошибке. */ + * buf — выходной буфер с NUL; 0 и *out_len байт без NUL при успехе, -1 при ошибке. + * pending относится к квитанции сообщения. media_status: delivered/ready/downloading/expired/pending. + * hidden здесь не фильтруется; интерфейс выбирает видимую беседу по списку/поколению. */ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int count, int offset, char* buf, size_t buf_size, size_t* out_len); @@ -123,20 +145,20 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c * пира в таблице nodes (как headless hc_handle_dm_start), после чего вызывает * dm_start(). Результат — событие CHAT_EVT_DM_CONV_UPDATED. */ struct dm_start_req { - struct UTUN_INSTANCE* inst; - uint64_t peer_node_id; - char source_ch_id[64]; - char peer_name[128]; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ + uint64_t peer_node_id; /* Другой участник общей группы; ключи читаются из nodes. */ + char source_ch_id[64]; /* Десятичный ID общей группы либо пустая строка. */ + char peer_name[128]; /* UTF-8 имя с NUL; dm_start ограничит до 63 байт. */ }; void dm_start_trampoline(void* arg); /* Отправить текстовое сообщение в беседу (content_type="text"). */ struct dm_send_req { - struct UTUN_INSTANCE* inst; - char conv_id[64]; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ + char conv_id[64]; /* Десятичный ID беседы с NUL. */ uint64_t generation; /* 0: захватить при входе в uasync, до любой фоновой работы. */ - uint32_t data_len; - uint8_t* data; /* data_len байт (выделяется в хвосте структуры) */ + uint32_t data_len; /* Число байт текста, до DM_DATA_MAX. */ + uint8_t* data; /* Указывает на data_len байт в том же блоке после структуры. */ }; void dm_send_trampoline(void* arg); @@ -151,12 +173,15 @@ enum dm_invite_result { DM_INVITE_SEND_ERROR = -3, DM_INVITE_CANCELLED = -4, DM_INVITE_BUSY = -5 }; struct dm_invite_req { - struct UTUN_INSTANCE* inst; - uint64_t request_id, peer_node_id, channel_id, generation; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро до итогового callback. */ + uint64_t request_id; /* Ненулевой ID; cancel использует тот же ID. */ + uint64_t peer_node_id; /* Фиксированный получатель, не локальный узел. */ + uint64_t channel_id; /* Выбранная активная CHAT-группа отправителя. */ + uint64_t generation; /* Заполняет uasync до регистрации; проверяет callback. */ char source_ch_id[64]; /* общая группа для новой PM; из шапки PM может быть пустой */ - char peer_name[128]; - int cancel; - struct chat_invite_build* pending; /* только uasync */ + char peer_name[128]; /* UTF-8 имя для новой беседы с NUL. */ + int cancel; /* 0=создать, 1=отменить ожидающий request_id. */ + struct chat_invite_build* pending; /* Внутренний handle; запрос создаётся обнулённым. */ }; void dm_invite_trampoline(void* arg); @@ -164,15 +189,18 @@ void dm_invite_trampoline(void* arg); * [conv_id_len:1][conv_id:var][generation:8][JSON:utf8]. Все чтения в uasync. * Ненулевое generation проверяется до чтения; 0 захватывает текущее поколение. GUI отбрасывает старый снимок. */ struct dm_messages_req { - struct UTUN_INSTANCE* inst; - char conv_id[64]; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ + char conv_id[64]; /* Десятичный ID с NUL, также для запроса маршрута звонка. */ uint64_t generation; /* Поколение выбранного PM; для запроса маршрута не используется. */ }; void dm_messages_trampoline(void* arg); -/* Живой BGP-маршрут PM; group_id=0 в снимке означает недоступность. Все вызовы в uasync. */ +/* 1 при живом BGP-маршруте общей группы, 0 при недоступности/ошибке. peer/group_id обязательны. + * При корректных аргументах выходы сначала обнуляются; peer может быть известен и без маршрута. + * Трамплин выдаёт CHAT_EVT_DM_CALL_ROUTE: [conv_len:1][conv][peer:8][group:8]; group=0 без маршрута. */ int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id); void dm_call_route_trampoline(void* arg); /* аргумент: dm_messages_req */ -struct dm_conversations_req { struct UTUN_INSTANCE* inst; }; +/* Снимок бесед публикуется как CHAT_EVT_DM_CONVERSATIONS с JSON без NUL. */ +struct dm_conversations_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ }; void dm_conversations_trampoline(void* arg); #ifdef __cplusplus diff --git a/src/dm/dm_history.h b/src/dm/dm_history.h index 22eb4212..9c906de5 100644 --- a/src/dm/dm_history.h +++ b/src/dm/dm_history.h @@ -1,7 +1,9 @@ /* Локальная история DM: поколения, скрытые беседы и точные отпечатки удалённых сообщений. * Все операции выполняются в uasync с SQLite ядра. init вызывается после создания DM-таблиц. * Удаление скрывает беседу и сохраняет её ключи/счётчики в dm_conversations; show открывает её снова. - * Поколение передавать в отложенные операции и проверять current перед применением результата. */ + * Поколение передавать в отложенные операции и проверять current перед применением результата. + * Беседой/seq владеет dm_core, файлами/загрузками — dm_media; этот модуль связывает их удаление. + * Аргументы заимствованы на время вызова; самостоятельные транзакции выполняются без внешней транзакции. */ #ifndef DM_HISTORY_H #define DM_HISTORY_H #include @@ -10,7 +12,8 @@ extern "C" { #endif struct UTUN_INSTANCE; -/* Создать служебные таблицы, сбросить busy файлов после restart. 0=успех, -1=ошибка. +/* Создать dm_history, dm_deleted_messages и dm_local_files; сбросить busy после restart. + * 0=успех, -1=ошибка. Вызывает dm_core_init; повторять при живых workers нельзя: они ещё держат пути. * inst и его topo_sqlite_db обязательны; частично созданные таблицы при ошибке остаются. */ int dm_history_init(struct UTUN_INSTANCE* inst); /* Поколение существующей беседы: 1 до первого удаления; 0 при отсутствии/ошибке. @@ -28,8 +31,11 @@ int dm_history_show(struct UTUN_INSTANCE* inst, const char* conv); int dm_history_deleted(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int dir, uint8_t* media_receipt); /* Удалить видимую беседу текущего generation