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:
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
|
||||
}
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+35
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
+31
-18
@@ -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
|
||||
|
||||
Reference in new issue
Block a user