Preserve composer commit receipts and finish message batches after rotation

This commit is contained in:
evgeny committed 2026-10-29 13:34:26 +01:00
1 parent 47e997df15
commit dae19c06d7
2 files changed
+46 -19

No files matched your search

@@ -26,22 +26,23 @@ import com.utun.chat.viewmodel.ChatViewModel
import java.io.File
import kotlinx.coroutines.*
private data class SelectedAttachment(val target: ConversationTarget, val uri: Uri, val name: String, val size: Long, val video: Boolean)
private data class SelectedAttachment(val target: ConversationTarget, val uri: Uri, val name: String, val size: Long, val video: Boolean,
val id: String = java.util.UUID.randomUUID().toString())
private val selectedAttachmentSaver = listSaver<SelectedAttachment?, Any>(save = {
if (it == null) emptyList() else listOf(it.target.nativeId, (it.target as? ConversationTarget.Pm)?.generation ?: 0L,
it.uri.toString(), it.name, it.size, it.video)
it.uri.toString(), it.name, it.size, it.video, it.id)
}, restore = {
if (it.isEmpty()) null else {
val id = it[0] as String
val target = if (id.startsWith("dm:")) ConversationTarget.Pm(id.removePrefix("dm:"), it[1] as Long)
else ConversationTarget.Channel(id)
SelectedAttachment(target, Uri.parse(it[2] as String), it[3] as String, it[4] as Long, it[5] as Boolean)
SelectedAttachment(target, Uri.parse(it[2] as String), it[3] as String, it[4] as Long, it[5] as Boolean, it[6] as String)
}
})
/** Общий ввод и вложения: draft/выбор URI переживают пересоздание Activity, адресат и reply фиксируются до подготовки.
* Экран владеет cache до передачи задачи ViewModel; отменённое копирование очищается в NonCancellable.
* После ухода с экрана coroutine UI отменяется, уже переданная задача остаётся у ViewModel. */
* ViewModel продолжает пакет «вложение + текст» после ухода UI; token/revision очищают только подтверждённый прежний draft. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
@@ -66,12 +67,27 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
}
val sendFailed by viewModel.messageSendFailed.collectAsState()
val submitting by viewModel.submittingChannels.collectAsState()
val batchStates by viewModel.composerSubmissions.states.collectAsState()
val batch = batchStates[target]
val deletingDm by viewModel.dmDeleting.collectAsState()
val pending = target.nativeId in submitting || (target is ConversationTarget.Pm && target.id in deletingDm)
val pending = batch?.finished == false || target.nativeId in submitting || (target is ConversationTarget.Pm && target.id in deletingDm)
if (sendFailed) AlertDialog(onDismissRequest = viewModel::dismissMessageSendError,
text = { Text(stringResource(R.string.message_send_failed)) },
confirmButton = { TextButton(onClick = viewModel::dismissMessageSendError) { Text(stringResource(R.string.close)) } })
var selected by rememberSaveable(target, stateSaver = selectedAttachmentSaver) { mutableStateOf<SelectedAttachment?>(null) }
var pendingToken by rememberSaveable(target) { mutableStateOf<String?>(null) }
var pendingTextRevision by rememberSaveable(target) { mutableStateOf(0L) }
var pendingAttachmentId by rememberSaveable(target) { mutableStateOf<String?>(null) }
LaunchedEffect(batch, pendingToken) {
if (batch != null && batch.token == pendingToken) {
if (batch.attachmentSaved == true && selected?.id == pendingAttachmentId) selected = null
if (batch.textSaved == true && textRevision == pendingTextRevision) { text = TextFieldValue(); textRevision++ }
if (batch.finished) {
viewModel.composerSubmissions.consume(target, batch.token)
pendingToken = null; pendingAttachmentId = null
}
}
}
var pickerId by rememberSaveable { mutableStateOf<String?>(null) }
var pickerGeneration by rememberSaveable { mutableStateOf(0L) }
var preparing by remember(target) { mutableStateOf(false) }
@@ -166,28 +182,23 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
val sentText = text.text
val revision = textRevision
val capturedReply = reply
if (attachment == null && sentText.isBlank()) return@InputBar
preparing = true; error = null
scope.launch {
var cache: String? = null
var attachmentSaved = attachment == null
var textSaved = sentText.isBlank()
try {
if (attachment != null) {
withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) }
val path = cache ?: throw IllegalStateException("Attachment copy failed")
val submission = if (video) viewModel.sendVideoMessage(path, attachment.target, capturedReply, clearReply = false)
else viewModel.sendAttachment(path, attachment.target, attachment.name, capturedReply)
cache = null // Задача ViewModel владеет файлом до завершения отправки.
attachmentSaved = submission.await()
if (attachmentSaved && selected === attachment) selected = null
checkNotNull(cache) { "Attachment copy failed" }
}
if (sentText.isNotBlank()) {
textSaved = if (target is ConversationTarget.Channel)
viewModel.sendChannelText(target, sentText, capturedReply).await()
else viewModel.sendDmText(target as ConversationTarget.Pm, sentText).await()
if (textSaved && textRevision == revision) { text = TextFieldValue(); textRevision++ }
val token = java.util.UUID.randomUUID().toString()
pendingToken = token; pendingTextRevision = revision; pendingAttachmentId = attachment?.id
if (viewModel.sendConversation(target, token, sentText, cache, attachment?.name.orEmpty(), video, capturedReply)) {
cache = null // ViewModel приняла весь пакет и владеет cache.
} else {
pendingToken = null; pendingAttachmentId = null
error = R.string.attachment_send_failed
}
if (attachmentSaved && textSaved) viewModel.finishReply(capturedReply)
} catch (e: CancellationException) { throw e }
catch (e: Exception) {
LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}")
@@ -30,6 +30,22 @@ import org.json.JSONArray
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
class ChatViewModel : ViewModel() {
val composerSubmissions = ConversationSubmissions(viewModelScope)
/** После приёма ViewModel владеет cache и обеими частями отправки, даже если Activity уничтожена. */
fun sendConversation(target: ConversationTarget, token: String, text: String, filePath: String?, name: String,
video: Boolean, reply: ChannelReply?): Boolean {
val attachmentTask: (suspend () -> Boolean)? = filePath?.let { path ->
{ if (video) sendVideoMessage(path, target, reply, clearReply = false).await()
else sendAttachment(path, target, name, reply).await() }
}
val textTask: (suspend () -> Boolean)? = if (text.isBlank()) null else {
{ if (target is ConversationTarget.Channel) sendChannelText(target, text, reply).await()
else sendDmText(target as ConversationTarget.Pm, text).await() }
}
return composerSubmissions.submit(target, token, attachmentTask, textTask, { finishReply(reply) }, { _messageSendFailed.value = true })
}
data class ChannelEdit(val channelId: String, val message: Message, val revision: Long)
private val _editing = MutableStateFlow<ChannelEdit?>(null)
val editing: StateFlow<ChannelEdit?> = _editing