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 c9ab2af1..0cd3d6da 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 @@ -77,8 +77,8 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, var preparing by remember(target) { mutableStateOf(false) } 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) } + var chooseVideo by rememberSaveable(target) { mutableStateOf(false) } + var sendAsVideo by rememberSaveable(target) { mutableStateOf(false) } val recording by viewModel.isRecording.collectAsState() val locked by viewModel.isRecordingLocked.collectAsState() val duration by viewModel.recordingDurationMs.collectAsState() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 42a6dd7c..8b156108 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -5,6 +5,7 @@ import com.utun.chat.data.NativeLib import com.utun.chat.R import androidx.compose.ui.res.stringResource import android.os.SystemClock +import android.widget.Toast import android.view.ViewConfiguration import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -74,7 +75,12 @@ import com.utun.chat.data.StreamSource import androidx.compose.material.icons.filled.LiveTv import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException +/** Канальный чат: история/ответы/правки принадлежат ViewModel, draft и выбор вложения — общему composer. + * Кнопка PTT удерживает источник CHAT_BUTTON до отпускания/dispose; двойной короткий тап фиксирует передачу. + * Просмотр трансляций исключает собственный источник. SAF export не изменяет оригинал и сообщает об отказе записи. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( @@ -95,7 +101,7 @@ fun ChatScreen( onWatchStream: (StreamSource) -> Unit = {}, onWatchAllStreams: () -> Unit = {} ) { - val listState = rememberLazyListState() + val listState = key(channel.id) { rememberLazyListState() } val context = LocalContext.current val view = LocalView.current val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() } @@ -138,22 +144,24 @@ fun ChatScreen( val soundOn by viewModel.soundOn.collectAsState() val reactionCatalog by viewModel.reactionCatalog.collectAsState() val interactionFailed by viewModel.interactionFailed.collectAsState() - var createPoll by rememberSaveable { mutableStateOf(false) } + var createPoll by rememberSaveable(channel.id) { mutableStateOf(false) } if (createPoll) CreatePollDialog({ createPoll = false }, viewModel::createPoll) if (interactionFailed) AlertDialog(onDismissRequest = viewModel::dismissInteractionError, text = { Text(stringResource(R.string.poll_interaction_failed)) }, confirmButton = { TextButton(onClick = viewModel::dismissInteractionError) { Text(stringResource(R.string.close)) } }) /* save dialog for downloaded files */ - var pendingSaveFilePath by remember { mutableStateOf(null) } + var pendingSaveFilePath by rememberSaveable { mutableStateOf(null) } + var saveError by remember(channel.id) { mutableStateOf(false) } val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri -> - if (saveUri != null) { - val filePath = pendingSaveFilePath - pendingSaveFilePath = null - if (filePath != null) { - scope.launch(Dispatchers.IO) { - viewModel.saveDownloadedFile(filePath, saveUri) - } + val filePath = pendingSaveFilePath + pendingSaveFilePath = null + if (saveUri != null && filePath != null) scope.launch { + try { saveError = !withContext(Dispatchers.IO) { viewModel.saveDownloadedFile(filePath, saveUri) } } + catch (e: CancellationException) { throw e } + catch (e: Exception) { + saveError = true + LogManager.addLog("ERROR", "Attachment", "File export failed: $e") } } } @@ -249,6 +257,8 @@ fun ChatScreen( if (radioActive) { RadioSourceBar(viewModel) } + if (saveError) Text(stringResource(R.string.file_save_failed), color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(8.dp)) if (radioTalking != null) { Surface(color = MaterialTheme.colorScheme.primaryContainer) { Text( @@ -274,7 +284,15 @@ fun ChatScreen( onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) }, onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) }, onDownloadFile = { viewModel.downloadAttachment(it) }, - onOpenFile = { if (it.filePath.isNotEmpty()) { pendingSaveFilePath = it.filePath; saveFileLauncher.launch(it.text) } }, + onOpenFile = { + if (it.filePath.isNotEmpty() && pendingSaveFilePath == null) { + pendingSaveFilePath = it.filePath; saveError = false + try { saveFileLauncher.launch(it.text) } catch (e: Exception) { + pendingSaveFilePath = null; saveError = true + LogManager.addLog("ERROR", "Attachment", "Cannot launch file export: $e") + } + } + }, onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink, reactionCatalog = reactionCatalog, onVote = viewModel::vote, onReact = viewModel::react, authorName = viewModel::radioName, onReply = viewModel::selectReply, onOriginal = viewModel::showOriginal, originals = originals, highlightedId = highlighted, @@ -368,6 +386,7 @@ private fun RadioPttButton( /* Единый выбор входа/выхода, PTT громкостью, VAD и плавающая PTT-кнопка. */ @Composable private fun RadioSourceBar(viewModel: ChatViewModel) { + val context = LocalContext.current val provider = ChatApplication.instance.configProvider val scope = rememberCoroutineScope() val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) @@ -398,9 +417,15 @@ private fun RadioSourceBar(viewModel: ChatViewModel) { RadioAutomaticButton(autoSettings) { viewModel.setRadioAutomatic(!autoSettings.enabled) } RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) { scope.launch { - if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions() - else RadioOverlayService.setEnabled(!overlayEnabled) - } + try { + if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions() + else RadioOverlayService.setEnabled(!overlayEnabled) + } catch (e: CancellationException) { throw e } + catch (e: Exception) { + LogManager.addLog("ERROR", "radio", "Cannot change overlay setting enabled=${!overlayEnabled}: $e") + Toast.makeText(context, R.string.overlay_start_failed, Toast.LENGTH_LONG).show() + } + } } } if (overlayEnabled && !overlayShown) overlayStatus?.let { message -> diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 755d30d2..7b46ae66 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -1804,20 +1804,25 @@ class ChatViewModel : ViewModel() { } } - fun saveDownloadedFile(srcPath: String, saveUri: Uri) { + /** IO caller: копирует локальный файл в выбранный SAF-документ; true только после закрытия обоих потоков. */ + fun saveDownloadedFile(srcPath: String, saveUri: Uri): Boolean { try { val src = File(srcPath) - if (!src.exists()) { + if (!src.isFile) { LogManager.addLog("ERROR", "VM", "saveDownloadedFile: src not found $srcPath") - return + return false } val ctx = ChatApplication.instance ctx.contentResolver.openOutputStream(saveUri)?.use { output -> src.inputStream().use { input -> input.copyTo(output) } - } + } ?: throw java.io.IOException("Cannot open selected document") LogManager.addLog("INFO", "VM", "file saved: $srcPath -> $saveUri") + return true + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { LogManager.addLog("ERROR", "VM", "saveDownloadedFile failed: ${e.message}") + return false } }