Report channel file export failures and preserve attachment choices
This commit is contained in:
1 parent
666210c3fb
commit
98d4f903a0
3 files changed
+50
-20
No files matched your search
+2
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user