From 1edd5449c9ca8ce7c958731c199aaa1c4934d339 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:38:29 +0300 Subject: [PATCH] refactor(chat): modularize update section and media launchers Extract ChatAppUpdateSection and rememberChatMediaLaunchers to components to decouple STT and update dialogs from ChatScreen. --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 309 ++---------------- .../chat/components/ChatAppUpdateSection.kt | 103 ++++++ .../ui/chat/components/ChatMediaLaunchers.kt | 257 +++++++++++++++ 3 files changed, 384 insertions(+), 285 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index b31d5774..3223cc33 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -1,13 +1,8 @@ package com.m57.hermescontrol.ui.chat -import android.Manifest import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Intent -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 @@ -68,10 +63,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle @@ -82,13 +74,11 @@ import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment 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.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper 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.ChatInputBar 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.SubagentInspectionSheet 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.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList 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.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog -import com.m57.hermescontrol.ui.settings.AppUpdateViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged 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 @@ -304,7 +289,6 @@ fun ChatScreen( viewModel.consumeComposerTextRestore() } } - var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog 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 sttNotAvailableMsg = stringResource(R.string.stt_not_available) - val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) - - // 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 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 { + val mediaLaunchers = + rememberChatMediaLaunchers( + inputFieldValue = inputFieldValue, + onInputFieldValueChange = { inputFieldValue = it }, + onAddAttachment = { uri, name, mimeType, size -> + viewModel.addAttachment(uri, name, mimeType, size) + }, + onAddAttachments = { attachments -> + viewModel.addAttachments(attachments) + }, + onShowMessage = { msg -> scrollScope.launch { - snackbarHostState.showSnackbar(sttPermissionDeniedMsg) + snackbarHostState.showSnackbar(msg) } - } - } - - // Multi-file picker for attachments (issue #195). - val filePickerLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.GetMultipleContents(), - ) { uris: List -> - 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(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, - ) - } - } - } - } + }, + launchExternalActivity = launchExternalActivity, + context = context, + ) // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( @@ -592,80 +463,7 @@ fun ChatScreen( // Issue #890: launch update check — non-blocking banner when a // newer release exists. Tapping "Update" opens the in-place dialog. - 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 = 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) - }, - ) - } - }, - ) - } + ChatAppUpdateSection() AnimatedVisibility( visible = searchState.isActive, @@ -808,75 +606,16 @@ fun ChatScreen( // Jump to bottom after send (serialized through the controller). scrollController.jumpToBottom(animated = true) }, - onMicTap = { - 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 - 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, + onMicTap = mediaLaunchers.onMicTap, + isListening = mediaLaunchers.isListening, isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, commandCatalog = state.commandCatalog, slashUsageCounts = state.slashUsageCounts, pendingAttachments = state.pendingAttachments, - onCameraTap = { - 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 - launchExternalActivity { - cameraLauncher.launch(uri) - } - } catch (e: Exception) { - Log.e("ChatScreen", "Camera launch failed", e) - } - }, - onImageTap = { - launchExternalActivity { - filePickerLauncher.launch("image/*") - } - }, - onFileTap = { - launchExternalActivity { - filePickerLauncher.launch("*/*") - } - }, + onCameraTap = mediaLaunchers.onCameraTap, + onImageTap = mediaLaunchers.onImageTap, + onFileTap = mediaLaunchers.onFileTap, onRemoveAttachment = viewModel::removeAttachment, onPreviewAttachment = { attachment -> if (attachment.isImage) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt new file mode 100644 index 00000000..2ec2e26b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt @@ -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) + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt new file mode 100644 index 00000000..afb68002 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt @@ -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) -> Unit, + onShowMessage: (String) -> Unit, + launchExternalActivity: (() -> Unit) -> Unit, + context: Context = LocalContext.current, +): ChatMediaLaunchers { + var isListening by remember { mutableStateOf(false) } + var pendingCameraUri by remember { mutableStateOf(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 -> + 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, + ) + } +}