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:
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user