Report channel file export failures and preserve attachment choices

This commit is contained in:
evgeny committed 2026-10-29 13:34:25 +01:00
1 parent 666210c3fb
commit 98d4f903a0
3 files changed
+50 -20

No files matched your search

@@ -77,8 +77,8 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
var preparing by remember(target) { mutableStateOf(false) }
var error by remember(target) { mutableStateOf<Int?>(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()
@@ -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<String?>(null) }
var pendingSaveFilePath by rememberSaveable { mutableStateOf<String?>(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 ->
@@ -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
}
}