diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt index a56a764e..c9ab2af1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt @@ -1,7 +1,6 @@ package com.utun.chat.ui.components import com.utun.chat.R -import com.utun.chat.data.AppLanguage import androidx.compose.ui.res.stringResource import android.net.Uri import android.provider.OpenableColumns @@ -15,6 +14,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.ui.Alignment import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.listSaver import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue @@ -27,8 +27,21 @@ 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 val selectedAttachmentSaver = listSaver(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) +}, 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) + } +}) -/** Общий ввод и вложения; URI принадлежит экрану, адресат операции неизменяем. */ +/** Общий ввод и вложения: draft/выбор URI переживают пересоздание Activity, адресат и reply фиксируются до подготовки. + * Экран владеет cache до передачи задачи ViewModel; отменённое копирование очищается в NonCancellable. + * После ухода с экрана coroutine UI отменяется, уже переданная задача остаётся у ViewModel. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, @@ -58,10 +71,11 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, 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 remember(target) { mutableStateOf(null) } - var pickerTarget by remember { mutableStateOf(null) } + var selected by rememberSaveable(target, stateSaver = selectedAttachmentSaver) { mutableStateOf(null) } + var pickerId by rememberSaveable { mutableStateOf(null) } + var pickerGeneration by rememberSaveable { mutableStateOf(0L) } var preparing by remember(target) { mutableStateOf(false) } - var error by remember(target) { mutableStateOf("") } + var error by remember(target) { mutableStateOf(null) } var menu by remember(target) { mutableStateOf(false) } var chooseVideo by remember(target) { mutableStateOf(false) } var sendAsVideo by remember(target) { mutableStateOf(false) } @@ -74,18 +88,22 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, val preparation = preparations[target.nativeId] DisposableEffect(target) { onDispose { viewModel.cancelVoiceRecording(target) } } LaunchedEffect(recording) { - while (recording) { level = NativeLib.voiceGetPeakLevel(); delay(80) } + try { while (recording) { level = NativeLib.voiceGetPeakLevel(); delay(80) } } + catch (e: CancellationException) { throw e } + catch (e: Exception) { LogManager.addLog("ERROR", "Voice", "Cannot read recording level: $e") } level = 0f } val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - val pinned = pickerTarget - pickerTarget = null + val pinned = pickerId?.let { + if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:"), pickerGeneration) else ConversationTarget.Channel(it) + } + pickerId = null if (uri != null && pinned != null) scope.launch { try { val attachment = withContext(Dispatchers.IO) { var name = "attachment.bin"; var size = 0L context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { - if (it.moveToFirst()) { name = it.getString(0) ?: name; size = if (it.isNull(1)) 0 else it.getLong(1) } + if (it.moveToFirst()) { name = it.getString(0) ?: name; size = if (it.isNull(1)) 0 else it.getLong(1).coerceAtLeast(0) } } SelectedAttachment(pinned, uri, name, size, context.contentResolver.getType(uri)?.startsWith("video/") == true) } @@ -94,7 +112,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, } catch (e: CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "picker failed: ${e.message}") - error = AppLanguage.text(R.string.attachment_open_failed) + error = R.string.attachment_open_failed } } } @@ -115,7 +133,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, } } } - if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) + error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) } if (preparing || preparation?.jobs?.isNotEmpty() == true) Text(stringResource(R.string.media_preparing), modifier = Modifier.padding(8.dp)) else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) @@ -130,8 +148,12 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, preparing = true scope.launch { try { - error = "" - if (!viewModel.saveEdit(edit, sent).await()) error = AppLanguage.text(R.string.edit_failed) + error = null + if (!viewModel.saveEdit(edit, sent).await()) error = R.string.edit_failed + } catch (e: CancellationException) { throw e } + catch (e: Exception) { + LogManager.addLog("ERROR", "Edit", "Cannot submit edit: $e") + error = R.string.edit_failed } finally { preparing = false } } return@InputBar @@ -141,15 +163,15 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, val sentText = text.text val revision = textRevision val capturedReply = reply + preparing = true; error = null scope.launch { - preparing = true; error = "" 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(AppLanguage.text(R.string.attachment_copy_failed)) + 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 владеет файлом до завершения отправки. @@ -166,11 +188,16 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, } catch (e: CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}") - error = e.message ?: AppLanguage.text(R.string.attachment_send_failed) + error = R.string.attachment_send_failed } finally { val abandoned = cache - if (abandoned != null) withContext(NonCancellable + Dispatchers.IO) { File(abandoned).delete() } + if (abandoned != null) withContext(NonCancellable + Dispatchers.IO) { + try { + val file = File(abandoned) + if (!file.delete() && file.exists()) LogManager.addLog("ERROR", "Attachment", "Abandoned cache removal failed path=$abandoned") + } catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "Abandoned cache removal threw path=$abandoned: $e") } + } preparing = false } } @@ -188,7 +215,11 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, } if (menu) ModalBottomSheet(onDismissRequest = { menu = false }) { ListItem(headlineContent = { Text(stringResource(R.string.select_file)) }, modifier = Modifier.clickable { - menu = false; pickerTarget = target; picker.launch(arrayOf("*/*")) + menu = false; pickerId = target.nativeId; pickerGeneration = (target as? ConversationTarget.Pm)?.generation ?: 0 + try { picker.launch(arrayOf("*/*")) } catch (e: Exception) { + pickerId = null; error = R.string.attachment_open_failed + LogManager.addLog("ERROR", "Attachment", "Cannot launch attachment picker: $e") + } }) ListItem(headlineContent = { Text(stringResource(R.string.record_video)) }, modifier = Modifier.clickable { menu = false; onRecordVideo() }) if (onCapturePhoto != null) ListItem(headlineContent = { Text(stringResource(R.string.take_photo)) },