feat(chat): add local DM deletion and improve Android member list

Delete local conversations atomically, retain replay protection and sequence counters, and guard asynchronous media work by history generation. Add Android avatar menu and confirmation, persistent file cleanup, and regression coverage.

Fix unnamed member wire padding and compact Android member action spacing.

Validation: 158 core tests passed; targeted DM tests, desktop build, Android unit tests and debug APK build passed.
This commit is contained in:
evgeny committed 2026-10-18 02:04:05 +02:00
1 parent 97710b609f
commit be295f768c
38 files changed
+1005 -126

No files matched your search

@@ -218,9 +218,10 @@ class MainActivity : AppCompatActivity() {
var showJoinDialog by rememberSaveable { mutableStateOf(false) }
var inviteChannelId by rememberSaveable { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var videoTargetGeneration by rememberSaveable { mutableStateOf(0L) }
var videoTargetId by rememberSaveable { mutableStateOf<String?>(null) }
val videoTarget = videoTargetId?.let {
if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:")) else ConversationTarget.Channel(it)
if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:"), videoTargetGeneration) else ConversationTarget.Channel(it)
}
var photoMessage by remember { mutableStateOf<Message?>(null) }
var photoTargetId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -358,17 +359,23 @@ class MainActivity : AppCompatActivity() {
Screen.ChannelList -> {
val channels by vm.channels.collectAsState()
val dmConversations by vm.dmConversations.collectAsState()
val dmDeleting by vm.dmDeleting.collectAsState()
val dmDeleteFailed by vm.dmDeleteFailed.collectAsState()
val stuck by vm.isStuck.collectAsState()
val cpuLoad by vm.cpuLoad.collectAsState()
ChannelListScreen(
channels = channels,
dmConversations = dmConversations,
onDeleteDm = vm::deleteDmConversation,
deletingDm = dmDeleting,
dmDeleteFailed = dmDeleteFailed,
onDismissDmDeleteError = vm::dismissDmDeleteError,
connected = connected,
isStuck = stuck,
cpuLoad = cpuLoad,
onCpuLoadVisible = vm::setCpuLoadVisible,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName, dm.generation); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true },
onInviteClick = showInvite,
@@ -387,7 +394,6 @@ class MainActivity : AppCompatActivity() {
messages = messages,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onFiles = { owner ->
filesVm.open(channel.id, owner); filesReturnScreen = Screen.Chat; screen = Screen.Files
@@ -401,7 +407,8 @@ class MainActivity : AppCompatActivity() {
onRecordVideo = {
if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen
val target = ConversationTarget.Channel(channel.id)
videoTargetId = target.nativeId; vm.captureCameraReply(target); screen = Screen.VideoRecord
videoTargetGeneration = 0; videoTargetId = target.nativeId
vm.captureCameraReply(target); screen = Screen.VideoRecord
},
onCapturePhoto = {
if (com.utun.chat.data.StreamController.blocksCapture()) return@ChatScreen
@@ -450,10 +457,10 @@ class MainActivity : AppCompatActivity() {
conv = dm,
messages = dmMessages,
viewModel = vm,
onSendMessage = { vm.sendDmMessage(it) },
onRecordVideo = {
val target = ConversationTarget.Pm(dm.convId)
videoTargetId = target.nativeId; vm.captureCameraReply(target); screen = Screen.VideoRecord
val target = ConversationTarget.Pm(dm.convId, dm.generation)
videoTargetGeneration = target.generation; videoTargetId = target.nativeId
vm.captureCameraReply(target); screen = Screen.VideoRecord
},
onPlayVideo = { videoMessage = it },
onBack = { vm.closeDm(); screen = Screen.ChannelList }
@@ -34,11 +34,11 @@ class AudioRecorderManager {
PackageManager.PERMISSION_GRANTED
}
fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean {
fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean {
if (isRecording) return false
if (!hasPermission()) return false
val ok = NativeLib.voiceStart(channelId, replyTo, requestId)
val ok = NativeLib.voiceStart(channelId, replyTo, requestId, generation)
if (!ok) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed")
return false
@@ -57,7 +57,7 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va
val peersOnline: Int = 0)
data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "",
val lastTs: Long = 0, val online: Boolean = false) {
val lastTs: Long = 0, val online: Boolean = false, val generation: Long = 1) {
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,
@@ -198,10 +198,10 @@ fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? {
val natType = buf.get().toInt() and 0xFF
val storage = buf.get().toInt() != 0
val nameBytes = ByteArray(64); buf.get(nameBytes)
val name = String(nameBytes).trimEnd('\u0000')
val name = String(nameBytes, Charsets.UTF_8).substringBefore('\u0000')
val avatarServer = buf.getLong()
val avatarBytes = ByteArray(70); buf.get(avatarBytes)
val avatarFile = String(avatarBytes).trimEnd('\u0000')
val avatarFile = String(avatarBytes, Charsets.UTF_8).substringBefore('\u0000')
val storageRole = buf.get().toInt() != 0
return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name, avatarServer, avatarFile, storageRole)
}
@@ -595,7 +595,7 @@ class ChatRepository {
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) =
NativeLib.dmStart(nodeId, sourceChId, peerName)
fun dmSend(convId: String, text: String) = NativeLib.dmSend(convId, text)
fun dmSend(convId: String, text: String, generation: Long) = NativeLib.dmSend(convId, text, generation)
fun parseDmConversations(json: String): List<DmConversation> {
val list = mutableListOf<DmConversation>()
@@ -607,7 +607,7 @@ class ChatRepository {
convId = obj.getString("conv_id"),
peerNodeId = obj.optLong("peer_node_id", 0),
peerName = obj.optString("peer_name", ""),
lastTs = obj.optLong("last_ts", 0)
lastTs = obj.optLong("last_ts", 0), generation = obj.getLong("generation")
))
}
} catch (e: Exception) { Log.w("utun-gui", "getDmConversations error", e) }
@@ -4,6 +4,6 @@ package com.utun.chat.data
sealed class ConversationTarget {
abstract val id: String
data class Channel(override val id: String) : ConversationTarget()
data class Pm(override val id: String) : ConversationTarget()
data class Pm(override val id: String, val generation: Long) : ConversationTarget()
val nativeId: String get() = if (this is Pm) "dm:$id" else id
}
@@ -172,8 +172,8 @@ object NativeLib {
fun requestLocalSockets() = nativeRequestLocalSockets()
/* ── Voice recording ── */
fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean =
!StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId)
fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
!StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId, generation)
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
fun voiceStop(): Int = nativeVoiceStop()
fun voiceCancel() { nativeVoiceCancel() }
@@ -195,8 +195,8 @@ object NativeLib {
/* ── Video message ── */
fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int,
replyTo: String = "", requestId: Long = 0): Boolean =
nativeVideoSend(channelId, filePath, durationMs, width, height, messageIdBytes(replyTo), requestId)
replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
nativeVideoSend(channelId, filePath, durationMs, width, height, messageIdBytes(replyTo), requestId, generation)
/* Live media используют отдельную ограниченную очередь, минуя чатовый event bus. */
fun streamOpen(id: Long, group: Long, source: Long = 0, width: Int = 0, height: Int = 0,
@@ -302,14 +302,15 @@ object NativeLib {
/* ── DM (личные сообщения) ── */
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) }
fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) }
fun dmSend(convId: String, text: String, generation: Long) { nativeDmSend(convId, text, generation) }
fun dmInvite(requestId: Long, peer: Long, source: String, channelId: Long, name: String, cancel: Boolean = false): Boolean =
nativeDmInvite(requestId, peer, source, channelId, name, cancel)
fun isChannelMember(channelId: String, nodeId: Long): Boolean = nativeIsChannelMember(channelId, nodeId)
fun dmDelete(convId: String, generation: Long, request: Long): Boolean = nativeDmDelete(convId, generation, request)
fun dmRequestConversations() { nativeDmRequestConversations() }
fun dmRequestCallRoute(convId: String) { nativeDmRequestCallRoute(convId) }
fun dmRequestMessages(convId: String) { nativeDmRequestMessages(convId) }
fun dmSendFile(convId: String, path: String, name: String): Boolean = nativeDmSendFile(convId, path, name)
fun dmSendFile(convId: String, path: String, name: String, generation: Long): Boolean = nativeDmSendFile(convId, path, name, generation)
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
@@ -367,7 +368,7 @@ object NativeLib {
private external fun nativeRequestLocalSockets()
/* ── Voice recording JNI ── */
private external fun nativeVoiceStart(channelId: String, replyTo: ByteArray, requestId: Long): Boolean
private external fun nativeVoiceStart(channelId: String, replyTo: ByteArray, requestId: Long, generation: Long): Boolean
private external fun nativeVoiceFeed(samples: ShortArray): Boolean
private external fun nativeVoiceStop(): Int
private external fun nativeVoiceCancel()
@@ -388,7 +389,7 @@ object NativeLib {
/* ── Video message JNI ── */
private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int,
replyTo: ByteArray, requestId: Long): Boolean
replyTo: ByteArray, requestId: Long, generation: Long): Boolean
/* ── Photo message JNI ── */
private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int,
@@ -433,12 +434,13 @@ object NativeLib {
private external fun nativeRadioTalkEnd(groupId: Long)
/* ── DM JNI ── */
private external fun nativeDmDelete(convId: String, generation: Long, request: Long): Boolean
private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String)
private external fun nativeDmSend(convId: String, text: String)
private external fun nativeDmSend(convId: String, text: String, generation: Long)
private external fun nativeDmInvite(requestId: Long, peer: Long, source: String, channelId: Long, name: String, cancel: Boolean): Boolean
private external fun nativeIsChannelMember(channelId: String, nodeId: Long): Boolean
private external fun nativeDmRequestConversations()
private external fun nativeDmRequestCallRoute(convId: String)
private external fun nativeDmRequestMessages(convId: String)
private external fun nativeDmSendFile(convId: String, path: String, name: String): Boolean
private external fun nativeDmSendFile(convId: String, path: String, name: String, generation: Long): Boolean
}
@@ -31,13 +31,13 @@ private data class SelectedAttachment(val target: ConversationTarget, val uri: U
/** Общий ввод и вложения; URI принадлежит экрану, адресат операции неизменяем. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, onSendText: (String) -> Unit,
fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
onRecordVideo: () -> Unit, onCapturePhoto: (() -> Unit)? = null) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val activeTarget by rememberUpdatedState(target)
var text by rememberSaveable(target.nativeId, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
var textRevision by rememberSaveable(target.nativeId) { mutableStateOf(0L) }
var text by rememberSaveable(target, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
var textRevision by rememberSaveable(target) { mutableStateOf(0L) }
val replies by viewModel.replies.collectAsState()
val reply = if (target is ConversationTarget.Channel) replies[target.id] else null
val sendFailed by viewModel.messageSendFailed.collectAsState()
@@ -126,7 +126,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
if (sentText.isNotBlank()) {
textSaved = if (target is ConversationTarget.Channel)
viewModel.sendChannelText(target, sentText, capturedReply).await()
else { onSendText(sentText); true }
else viewModel.sendDmText(target as ConversationTarget.Pm, sentText)
if (textSaved && textRevision == revision) { text = TextFieldValue(); textRevision++ }
}
if (attachmentSaved && textSaved) viewModel.finishReply(capturedReply)
@@ -49,6 +49,10 @@ fun ChannelListScreen(
onCpuLoadVisible: (Boolean) -> Unit = {},
onSelectChannel: (Channel) -> Unit,
onSelectDm: (DmConversation) -> Unit = {},
onDeleteDm: (DmConversation) -> Unit,
deletingDm: Set<String> = emptySet(),
dmDeleteFailed: Boolean = false,
onDismissDmDeleteError: () -> Unit,
onCreateChannel: (String) -> Unit,
onJoinChannel: () -> Unit,
onInviteClick: (Channel) -> Unit,
@@ -65,6 +69,8 @@ fun ChannelListScreen(
var menuChannelId by remember { mutableStateOf<String?>(null) }
var optionsChannel by remember { mutableStateOf<Channel?>(null) }
var deleteConfirmChannel by remember { mutableStateOf<Channel?>(null) }
var menuDmId by remember { mutableStateOf<String?>(null) }
var deleteConfirmDm by remember { mutableStateOf<DmConversation?>(null) }
LaunchedEffect(Unit) {
LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}")
@@ -195,7 +201,16 @@ fun ChannelListScreen(
Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFFD2A8FF)))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp)
IconButton(onClick = { menuDmId = dm.convId }, modifier = Modifier.size(44.dp)) {
UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp)
}
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 }
)
}
PeerStatusDot(dm.online, Modifier.align(Alignment.TopStart).offset(x = (-4).dp, y = (-6).dp))
}
Spacer(Modifier.width(10.dp))
@@ -213,6 +228,25 @@ fun ChannelListScreen(
}
}
deleteConfirmDm?.let { dm ->
AlertDialog(
onDismissRequest = { deleteConfirmDm = null },
title = { Text(stringResource(R.string.delete_conversation)) },
text = { Text(stringResource(R.string.delete_conversation_confirm, dm.displayName)) },
confirmButton = {
TextButton(onClick = { deleteConfirmDm = null; onDeleteDm(dm) },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
Text(stringResource(R.string.delete))
}
},
dismissButton = { TextButton(onClick = { deleteConfirmDm = null }) { Text(stringResource(R.string.cancel)) } }
)
}
if (dmDeleteFailed) {
AlertDialog(onDismissRequest = onDismissDmDeleteError, text = { Text(stringResource(R.string.delete_conversation_failed)) },
confirmButton = { TextButton(onClick = onDismissDmDeleteError) { Text(stringResource(android.R.string.ok)) } })
}
if (showCreateDialog) {
CreateChannelDialog(
onDismiss = { showCreateDialog = false },
@@ -83,7 +83,6 @@ fun ChatScreen(
messages: List<Message>,
connected: Boolean,
viewModel: ChatViewModel,
onSendMessage: (String) -> Unit,
onMembersClick: () -> Unit,
onFiles: (Long) -> Unit = {},
onInvite: () -> Unit,
@@ -276,7 +275,7 @@ fun ChatScreen(
)
}
}
ConversationComposer(ConversationTarget.Channel(channel.id), viewModel, onSendMessage, onRecordVideo, onCapturePhoto)
ConversationComposer(ConversationTarget.Channel(channel.id), viewModel, onRecordVideo, onCapturePhoto)
}
}
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatViewModel,
onSendMessage: (String) -> Unit, onBack: () -> Unit,
onBack: () -> Unit,
onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) {
val target = ConversationTarget.Pm(conv.convId)
val target = ConversationTarget.Pm(conv.convId, conv.generation)
val playback by viewModel.playbackState.collectAsState()
val callAvailable by viewModel.dmCallAvailable.collectAsState()
val callState by viewModel.callState.collectAsState()
@@ -87,6 +87,6 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
onOpenFile = { if (it.filePath.isNotEmpty()) { pathToSave = it.filePath; save.launch(it.text) } },
onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink)
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
ConversationComposer(target, viewModel, onSendMessage, onRecordVideo)
ConversationComposer(target, viewModel, onRecordVideo)
}
}
@@ -65,7 +65,10 @@ fun MemberListScreen(
var showEditDialog by remember { mutableStateOf(false) }
var editMember by remember { mutableStateOf<ChatMember?>(null) }
LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) }
LaunchedEffect(channel.id) {
Log.d("utun-gui", "Member list: channel=${channel.id} actionWidth=36dp statusIconGap=2dp")
viewModel.refreshMembers(channel.id)
}
val memberLinks by viewModel.memberLinks.collectAsState()
val localSockets by viewModel.localSockets.collectAsState()
@@ -170,6 +173,7 @@ fun MemberListScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MemberListItem(
member: ChatMember,
@@ -212,27 +216,27 @@ private fun MemberListItem(
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.isSuper) {
Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isAdmin) {
Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isModer) {
Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isStorage) {
Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(2.dp))
}
if (member.radioActive) {
PortableRadioIcon(stringResource(R.string.radio_active), Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(2.dp))
}
if (!member.isVerified) {
Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(2.dp))
}
Text(
text = member.name,
@@ -261,18 +265,27 @@ private fun MemberListItem(
val radioStatus = stringResource(if (speaking) R.string.radio_member_speaking else R.string.radio_member_silent) +
if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else ""
if (member.isSelf) {
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
RadioSpeakerIcon(speaking, false, radioStatus)
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
val actionModifier = Modifier.size(width = 36.dp, height = 48.dp)
if (member.isSelf) {
Box(actionModifier, contentAlignment = Alignment.Center) {
RadioSpeakerIcon(speaking, false, radioStatus)
}
} else {
IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }, modifier = actionModifier) {
RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " +
stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute))
}
IconButton(onClick = onInvite, modifier = actionModifier) {
Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group))
}
IconButton(onClick = onCall, enabled = member.online, modifier = actionModifier) {
Icon(Icons.Filled.Call, stringResource(R.string.call))
}
IconButton(onClick = onMessage, modifier = actionModifier) {
Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message))
}
}
} else {
IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }) {
RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " +
stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute))
}
IconButton(onClick = onInvite) { Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) }
IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) }
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) }
}
}
}
@@ -137,8 +137,10 @@ class ChatViewModel : ViewModel() {
data class AttachmentPreparation(val jobs: Set<String> = emptySet(), val failed: Boolean = false)
private val _attachmentPreparations = MutableStateFlow<Map<String, AttachmentPreparation>>(emptyMap())
val attachmentPreparations: StateFlow<Map<String, AttachmentPreparation>> = _attachmentPreparations
private val discardedPreparationJobs = mutableSetOf<String>()
private fun preparationState(target: String, job: String, state: Int) {
if (job in discardedPreparationJobs) { if (state != 0) discardedPreparationJobs.remove(job); return }
_attachmentPreparations.update { map ->
val previous = map[target] ?: AttachmentPreparation()
val value = if (state == 0) AttachmentPreparation(previous.jobs + job)
@@ -296,6 +298,12 @@ class ChatViewModel : ViewModel() {
private val _dmConversations = MutableStateFlow<List<DmConversation>>(emptyList())
val dmConversations: StateFlow<List<DmConversation>> = _dmConversations
private var nextDmDeleteRequest = System.nanoTime().ushr(1)
private val dmDeleteRequests = mutableMapOf<Long, Pair<String, Long>>()
private val _dmDeleting = MutableStateFlow<Set<String>>(emptySet())
val dmDeleting: StateFlow<Set<String>> = _dmDeleting
private val _dmDeleteFailed = MutableStateFlow(false)
val dmDeleteFailed: StateFlow<Boolean> = _dmDeleteFailed
private val _dmMessages = MutableStateFlow<List<Message>>(emptyList())
val dmMessages: StateFlow<List<Message>> = _dmMessages
@@ -563,7 +571,7 @@ class ChatViewModel : ViewModel() {
memberRadios.clear()
memberRadioMutes.clear()
_members.value = _members.value.map { it.copy(radioActive = false) }
_attachmentPreparations.value = emptyMap()
_attachmentPreparations.value = emptyMap(); discardedPreparationJobs.clear()
_pmInvite.value?.let { state ->
if (state.pending) _pmInvite.value = state.copy(pending = false, errorRes = R.string.invite_core_unavailable)
}
@@ -571,17 +579,22 @@ class ChatViewModel : ViewModel() {
if (state.pending) _linkJoin.value = state.copy(pending = false, result = -7)
}
LogManager.addLog("INFO", "VM", "service stopped; pending invitation UI released")
if (dmDeleteRequests.isNotEmpty()) {
dmDeleteRequests.clear(); _dmDeleting.value = emptySet(); _dmDeleteFailed.value = true
LogManager.addLog("WARN", "PM", "Service stopped before pending delete results")
}
}
48 -> { /* [request_id:8][result:4][peer:8][conv:8] */
if (data == null || data.size != 28) { LogManager.addLog("ERROR", "INVITE", "invalid PM invite result"); return }
48 -> { /* [request_id:8][result:4][peer:8][conv:8][generation:8] */
if (data == null || data.size != 36) { LogManager.addLog("ERROR", "INVITE", "invalid PM invite result"); return }
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val request = buf.long; val result = buf.int; val peer = buf.long; val conv = buf.long.toULong().toString()
val generation = buf.long
val state = _pmInvite.value ?: return
if (!state.pending || state.requestId != request || state.peer != peer) return
LogManager.addLog(if (result == 0) "INFO" else "ERROR", "INVITE", "PM invite result request=$request peer=$peer result=$result")
if (result == 0) {
_pmInvite.value = null
openDm(conv, peer, state.name); _dmOpen.value = conv; refreshDmConversations()
openDm(conv, peer, state.name, generation); _dmOpen.value = conv; refreshDmConversations()
} else {
val error = when (result) {
-1 -> R.string.invite_pm_start_failed
@@ -857,10 +870,10 @@ class ChatViewModel : ViewModel() {
}
refreshChannels()
}
33 -> { /* DM_CONV_UPDATED: [conv_id_len:1][conv_id:var] */
33 -> { /* DM_CONV_UPDATED: [conv_id_len:1][conv_id:var][generation:8] */
if (data == null || data.size < 2) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl) return
if (data.size != cl + 9) { LogManager.addLog("ERROR", "PM", "Invalid conversation update bytes=${data.size}"); return }
val convId = String(data, 1, cl)
LogManager.addLog("INFO", "VM", "DM_CONV_UPDATED conv=$convId")
refreshDmConversations()
@@ -868,7 +881,9 @@ class ChatViewModel : ViewModel() {
val pend = pendingStartPeer
if (pend != null) {
pendingStartPeer = null
_currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second)
val generation = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long
_currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second,
generation = generation)
_dmCallAvailable.value = false
refreshDmCallRoute()
refreshDmMessages()
@@ -904,8 +919,30 @@ class ChatViewModel : ViewModel() {
_dmConversations.value = (repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList()).map {
it.copy(online = previous[it.convId]?.online == true)
}
_currentDm.value?.let { current ->
_dmConversations.value.find { it.convId == current.convId }?.let { _currentDm.value = it }
}
refreshDmCallRoute()
}
59 -> { /* DM_DELETE_RESULT: request, conv, generation, success. */
if (data == null || data.size != 25) { LogManager.addLog("ERROR", "PM", "Invalid delete result"); return }
val b = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val request = b.long; val conv = b.long.toULong().toString(); val generation = b.long; val success = b.get().toInt() == 1
val expected = dmDeleteRequests[request] ?: return
if (expected != (conv to generation)) { LogManager.addLog("ERROR", "PM", "Delete result target mismatch"); return }
dmDeleteRequests.remove(request); _dmDeleting.value = dmDeleteRequests.values.map { it.first }.toSet()
LogManager.addLog(if (success) "INFO" else "ERROR", "PM", "Delete result conv=$conv generation=$generation success=$success")
if (!success) { _dmDeleteFailed.value = true; return }
val deleted = _dmConversations.value.find { it.convId == conv && it.generation == generation }
_dmConversations.value = _dmConversations.value.filterNot { it.convId == conv && it.generation == generation }
_currentDm.value?.let { if (it.convId == conv && it.generation == generation) closeDm() }
val target = ConversationTarget.Pm(conv, generation)
_attachmentPreparations.value[target.nativeId]?.let { discardedPreparationJobs.addAll(it.jobs) }
_attachmentPreparations.value = _attachmentPreparations.value - target.nativeId
cancelVoiceRecording(target)
if (deleted != null && _pmInvite.value?.peer == deleted.peerNodeId) dismissPmInvite()
refreshDmConversations()
}
46 -> {
if (data == null || data.size < 18) return
val length = data[0].toInt() and 255
@@ -1319,9 +1356,9 @@ class ChatViewModel : ViewModel() {
fun consumeDmOpen() { _dmOpen.value = null }
fun openDm(convId: String, peerNodeId: Long, peerName: String) {
fun openDm(convId: String, peerNodeId: Long, peerName: String, generation: Long) {
if (repo == null) return
val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName)
val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName, generation = generation)
_currentDm.value = dm
_dmCallAvailable.value = false
refreshDmCallRoute()
@@ -1335,13 +1372,31 @@ class ChatViewModel : ViewModel() {
_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)
fun sendDmText(target: ConversationTarget.Pm, text: String): Boolean {
val r = repo ?: return false
if (text.isBlank()) return false
val dm = _dmConversations.value.find { it.convId == target.id } ?: _currentDm.value?.takeIf { it.convId == target.id }
if (dm?.generation != target.generation || target.id in _dmDeleting.value) {
LogManager.addLog("INFO", "PM", "Discarded stale text conv=${target.id} generation=${target.generation}"); return false
}
r.dmSend(target.id, text, target.generation)
return true
}
fun deleteDmConversation(dm: DmConversation) {
if (dm.convId in _dmDeleting.value) return
val request = ++nextDmDeleteRequest
dmDeleteRequests[request] = dm.convId to dm.generation
_dmDeleting.value = _dmDeleting.value + dm.convId
LogManager.addLog("INFO", "PM", "Delete requested conv=${dm.convId} generation=${dm.generation} request=$request")
if (!NativeLib.dmDelete(dm.convId, dm.generation, request)) {
dmDeleteRequests.remove(request); _dmDeleting.value = _dmDeleting.value - dm.convId; _dmDeleteFailed.value = true
LogManager.addLog("ERROR", "PM", "Delete request rejected conv=${dm.convId}")
}
}
fun dismissDmDeleteError() { _dmDeleteFailed.value = false }
fun showPmInvite(peer: Long, name: String, source: String = "") {
if (peer == 0L || peer == NativeLib.getMyNodeId()) { LogManager.addLog("ERROR", "INVITE", "invalid PM recipient"); return }
if (_pmInvite.value != null) return
@@ -1395,10 +1450,6 @@ class ChatViewModel : ViewModel() {
fun dismissLinkJoin() { _linkJoin.value = null }
fun consumeJoinedChannel() { _joinedChannel.value = null }
fun sendDmFile(convId: String, path: String, name: String) {
if (repo != null) NativeLib.dmSendFile(convId, path, name)
}
fun refreshMembers(chId: String) {
val r = repo ?: return
val statuses = _nodeStatuses.value
@@ -1549,7 +1600,8 @@ class ChatViewModel : ViewModel() {
val reply = replyFor(target)
val request = if (target is ConversationTarget.Channel) ++nextSubmissionId to CompletableDeferred<Boolean>() else null
if (request != null) submissions[request.first] = request.second
if (!audioRecorder.startRecording(target.nativeId, reply?.message?.messageId.orEmpty(), request?.first ?: 0)) {
if (!audioRecorder.startRecording(target.nativeId, reply?.message?.messageId.orEmpty(), request?.first ?: 0,
(target as? ConversationTarget.Pm)?.generation ?: 0)) {
request?.let { submissions.remove(it.first) }
LogManager.addLog("ERROR", "VM", "voice recording failed to start")
return
@@ -1633,7 +1685,7 @@ class ChatViewModel : ViewModel() {
fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name,
reply: ChannelReply? = replyFor(target)): Deferred<Boolean> = viewModelScope.async {
if (target is ConversationTarget.Pm) {
val accepted = NativeLib.dmSendFile(target.id, filePath, name)
val accepted = NativeLib.dmSendFile(target.id, filePath, name, target.generation)
if (!accepted) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true }
return@async accepted
}
@@ -1674,7 +1726,7 @@ class ChatViewModel : ViewModel() {
}
val queue: (Long) -> Boolean = { request ->
NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third,
reply?.message?.messageId.orEmpty(), request).also { accepted = it }
reply?.message?.messageId.orEmpty(), request, (target as? ConversationTarget.Pm)?.generation ?: 0).also { accepted = it }
}
saved = if (target is ConversationTarget.Channel) submitChannel(target.id, reply, queue) else queue(0)
if (!saved) _messageSendFailed.value = true