refactor(chat): modularize update section and media launchers

Extract ChatAppUpdateSection and rememberChatMediaLaunchers to
components to decouple STT and update dialogs from ChatScreen.
This commit is contained in:
Hy4ri committed 2026-09-11 19:38:29 +03:00
1 parent 3d711c5a51
commit 1edd5449c9
3 files changed
+384 -285

No files matched your search

@@ -1,13 +1,8 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import android.speech.RecognizerIntent
import android.util.Log import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -68,10 +63,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
@@ -82,13 +74,11 @@ import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
import com.m57.hermescontrol.ui.chat.components.ChatInputBar import com.m57.hermescontrol.ui.chat.components.ChatInputBar
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
@@ -104,26 +94,21 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
import com.m57.hermescontrol.ui.common.ActionProgressDialog import com.m57.hermescontrol.ui.common.ActionProgressDialog
import com.m57.hermescontrol.ui.common.AppUpdateDialog
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private const val SESSION_SYNC_INTERVAL_MS = 30_000L private const val SESSION_SYNC_INTERVAL_MS = 30_000L
@@ -304,7 +289,6 @@ fun ChatScreen(
viewModel.consumeComposerTextRestore() viewModel.consumeComposerTextRestore()
} }
} }
var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showReloginDialog by rememberSaveable { mutableStateOf(false) }
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
@@ -322,137 +306,24 @@ fun ChatScreen(
) )
} }
val micListeningPrompt = stringResource(R.string.chat_mic_listening) val mediaLaunchers =
val sttNotAvailableMsg = stringResource(R.string.stt_not_available) rememberChatMediaLaunchers(
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) inputFieldValue = inputFieldValue,
onInputFieldValueChange = { inputFieldValue = it },
// Speech-to-text recognition launcher (issue #194) onAddAttachment = { uri, name, mimeType, size ->
val speechLauncher = viewModel.addAttachment(uri, name, mimeType, size)
rememberLauncherForActivityResult( },
ActivityResultContracts.StartActivityForResult(), onAddAttachments = { attachments ->
) { result -> viewModel.addAttachments(attachments)
ExternalActivityLifecycleGuard.externalActivityReturned() },
isListening = false onShowMessage = { msg ->
if (result.resultCode == Activity.RESULT_OK) {
val spokenText =
result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
.orEmpty()
if (spokenText.isNotBlank()) {
val merged =
if (inputFieldValue.text.isBlank()) {
spokenText
} else {
"${inputFieldValue.text} $spokenText"
}
inputFieldValue = ChatInputPolicy.commandFieldValue(merged)
}
}
}
// Mic permission launcher
val micPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
ExternalActivityLifecycleGuard.externalActivityReturned()
if (granted) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
}
} else {
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
} else {
scrollScope.launch { scrollScope.launch {
snackbarHostState.showSnackbar(sttPermissionDeniedMsg) snackbarHostState.showSnackbar(msg)
} }
} },
} launchExternalActivity = launchExternalActivity,
context = context,
// Multi-file picker for attachments (issue #195). )
val filePickerLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.GetMultipleContents(),
) { uris: List<Uri> ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val attachments =
uris.mapNotNull { uri ->
runCatching {
var name = uri.lastPathSegment ?: "file"
var size = 0L
context.contentResolver
.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
}
}
Attachment(
uri = uri.toString(),
name = name,
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
size = size,
)
}.onFailure { error ->
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
}.getOrNull()
}
viewModel.addAttachments(attachments)
}
// Camera photo launcher (issue #195)
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
val cameraLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture(),
) { success ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val uri = pendingCameraUri
pendingCameraUri = null
if (success && uri != null) {
try {
val fileName =
"photo_${
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
Date(),
)
}.jpg"
val inputStream = context.contentResolver.openInputStream(uri)
val size = inputStream?.use { it.available().toLong() } ?: 0L
viewModel.addAttachment(uri.toString(), fileName, "image/jpeg", size)
} catch (e: Exception) {
Log.e("ChatScreen", "Camera capture failed", e)
scrollScope.launch {
snackbarHostState.showSnackbar(
cameraErrorMsg,
)
}
}
}
}
// Lifecycle effects, permissions, session switching, auto-scroll, errors // Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects( ChatLifecycleEffects(
@@ -592,80 +463,7 @@ fun ChatScreen(
// Issue #890: launch update check — non-blocking banner when a // Issue #890: launch update check — non-blocking banner when a
// newer release exists. Tapping "Update" opens the in-place dialog. // newer release exists. Tapping "Update" opens the in-place dialog.
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle() ChatAppUpdateSection()
val appUpdateViewModel: AppUpdateViewModel =
viewModel {
val app =
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
?: error("Application not available")
AppUpdateViewModel(app)
}
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
appUpdateViewModel.resumeInstallAfterPermission()
}
}
}
updateLifecycleOwner.lifecycle.addObserver(observer)
onDispose {
updateLifecycleOwner.lifecycle.removeObserver(observer)
}
}
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
com.m57.hermescontrol.ui.common.UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() },
)
}
}
if (AppUpdateCache.isDialogVisible) {
val context = androidx.compose.ui.platform.LocalContext.current
val dialogState =
if (appUpdateState !is AppUpdateState.Idle) {
appUpdateState
} else {
updateNotice
}
AppUpdateDialog(
state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() },
onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
val intent =
android.content
.Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:${context.packageName}"),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: Exception) {
context.startActivity(
android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
},
)
}
AnimatedVisibility( AnimatedVisibility(
visible = searchState.isActive, visible = searchState.isActive,
@@ -808,75 +606,16 @@ fun ChatScreen(
// Jump to bottom after send (serialized through the controller). // Jump to bottom after send (serialized through the controller).
scrollController.jumpToBottom(animated = true) scrollController.jumpToBottom(animated = true)
}, },
onMicTap = { onMicTap = mediaLaunchers.onMicTap,
if (isListening) { isListening = mediaLaunchers.isListening,
isListening = false
} else if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
}
} else {
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
} else {
launchExternalActivity {
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
},
isListening = isListening,
isAgentTyping = state.isAgentTyping, isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected, isConnected = state.isConnected,
commandCatalog = state.commandCatalog, commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts, slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments, pendingAttachments = state.pendingAttachments,
onCameraTap = { onCameraTap = mediaLaunchers.onCameraTap,
try { onImageTap = mediaLaunchers.onImageTap,
val timeStamp = onFileTap = mediaLaunchers.onFileTap,
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val photoFile =
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile,
)
pendingCameraUri = uri
launchExternalActivity {
cameraLauncher.launch(uri)
}
} catch (e: Exception) {
Log.e("ChatScreen", "Camera launch failed", e)
}
},
onImageTap = {
launchExternalActivity {
filePickerLauncher.launch("image/*")
}
},
onFileTap = {
launchExternalActivity {
filePickerLauncher.launch("*/*")
}
},
onRemoveAttachment = viewModel::removeAttachment, onRemoveAttachment = viewModel::removeAttachment,
onPreviewAttachment = { attachment -> onPreviewAttachment = { attachment ->
if (attachment.isImage) { if (attachment.isImage) {
@@ -0,0 +1,103 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.ui.common.AppUpdateDialog
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
@Composable
fun ChatAppUpdateSection() {
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
val appUpdateViewModel: AppUpdateViewModel =
viewModel {
val app =
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
?: error("Application not available")
AppUpdateViewModel(app)
}
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
val updateLifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(updateLifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
appUpdateViewModel.resumeInstallAfterPermission()
}
}
}
updateLifecycleOwner.lifecycle.addObserver(observer)
onDispose {
updateLifecycleOwner.lifecycle.removeObserver(observer)
}
}
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() },
)
}
}
if (AppUpdateCache.isDialogVisible) {
val context = LocalContext.current
val dialogState =
if (appUpdateState !is AppUpdateState.Idle) {
appUpdateState
} else {
updateNotice
}
AppUpdateDialog(
state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() },
onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
launchUnknownAppSourcesSettings(context)
},
)
}
}
private fun launchUnknownAppSourcesSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (_: Exception) {
context.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}
@@ -0,0 +1,257 @@
package com.m57.hermescontrol.ui.chat.components
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import android.speech.RecognizerIntent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ChatMediaLaunchers(
val isListening: Boolean,
val onMicTap: () -> Unit,
val onCameraTap: () -> Unit,
val onImageTap: () -> Unit,
val onFileTap: () -> Unit,
)
@Composable
fun rememberChatMediaLaunchers(
inputFieldValue: TextFieldValue,
onInputFieldValueChange: (TextFieldValue) -> Unit,
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
onAddAttachments: (List<Attachment>) -> Unit,
onShowMessage: (String) -> Unit,
launchExternalActivity: (() -> Unit) -> Unit,
context: Context = LocalContext.current,
): ChatMediaLaunchers {
var isListening by remember { mutableStateOf(false) }
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
// Speech-to-text recognition launcher (issue #194)
val speechLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
ExternalActivityLifecycleGuard.externalActivityReturned()
isListening = false
if (result.resultCode == Activity.RESULT_OK) {
val spokenText =
result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
.orEmpty()
if (spokenText.isNotBlank()) {
val currentText = currentInputFieldValue.text
val merged =
if (currentText.isBlank()) {
spokenText
} else {
"$currentText $spokenText"
}
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
}
}
}
// Mic permission launcher
val micPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
ExternalActivityLifecycleGuard.externalActivityReturned()
if (granted) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
currentLaunchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
currentOnShowMessage(sttNotAvailableMsg)
}
}
} else {
currentOnShowMessage(sttNotAvailableMsg)
}
} else {
currentOnShowMessage(sttPermissionDeniedMsg)
}
}
// Multi-file picker for attachments (issue #195)
val filePickerLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.GetMultipleContents(),
) { uris: List<Uri> ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val attachments =
uris.mapNotNull { uri ->
runCatching {
var name = uri.lastPathSegment ?: "file"
var size = 0L
context.contentResolver
.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
}
}
Attachment(
uri = uri.toString(),
name = name,
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
size = size,
)
}.onFailure { error ->
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
}.getOrNull()
}
currentOnAddAttachments(attachments)
}
// Camera photo launcher (issue #195)
val cameraLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture(),
) { success ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val uri = pendingCameraUri
pendingCameraUri = null
if (success && uri != null) {
try {
val fileName =
"photo_${
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
Date(),
)
}.jpg"
val inputStream = context.contentResolver.openInputStream(uri)
val size = inputStream?.use { it.available().toLong() } ?: 0L
currentOnAddAttachment(uri.toString(), fileName, "image/jpeg", size)
} catch (e: Exception) {
Log.e("ChatScreen", "Camera capture failed", e)
currentOnShowMessage(cameraErrorMsg)
}
}
}
val onMicTap: () -> Unit = {
if (isListening) {
isListening = false
} else if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
currentLaunchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
currentOnShowMessage(sttNotAvailableMsg)
}
}
} else {
currentOnShowMessage(sttNotAvailableMsg)
}
} else {
currentLaunchExternalActivity {
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
}
val onCameraTap: () -> Unit = {
try {
val timeStamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val photoFile =
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile,
)
pendingCameraUri = uri
currentLaunchExternalActivity {
cameraLauncher.launch(uri)
}
} catch (e: Exception) {
Log.e("ChatScreen", "Camera launch failed", e)
}
}
val onImageTap: () -> Unit = {
currentLaunchExternalActivity {
filePickerLauncher.launch("image/*")
}
}
val onFileTap: () -> Unit = {
currentLaunchExternalActivity {
filePickerLauncher.launch("*/*")
}
}
return remember(isListening) {
ChatMediaLaunchers(
isListening = isListening,
onMicTap = onMicTap,
onCameraTap = onCameraTap,
onImageTap = onImageTap,
onFileTap = onFileTap,
)
}
}