feat(chat): stabilize multi-image attachment picker (#986)
This commit is contained in:
1 parent
c7d7abf4a1
commit
0ae5ed4db2
11 files changed
+432
-55
No files matched your search
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class AttachmentChipTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun imageThumbnailClick_opensPreviewWithoutRemovingAttachment() {
|
||||||
|
var previewed = false
|
||||||
|
var removed = false
|
||||||
|
val attachment =
|
||||||
|
Attachment(
|
||||||
|
uri = "content://image/preview",
|
||||||
|
name = "preview.jpg",
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
size = 123L,
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
AttachmentChip(
|
||||||
|
attachment = attachment,
|
||||||
|
onPreview = { previewed = true },
|
||||||
|
onRemove = { removed = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("attachment_preview").performClick()
|
||||||
|
composeTestRule.runOnIdle {
|
||||||
|
assertTrue("image tap must open preview", previewed)
|
||||||
|
assertTrue("image tap must not remove attachment", !removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.m57.hermescontrol
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
|
||||||
|
/** Coordinates background reply handling and a bounded connection lease for external activities. */
|
||||||
|
object ExternalActivityLifecycleGuard {
|
||||||
|
private const val CONNECTION_LEASE_TIMEOUT_MS = 10 * 60 * 1_000L
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private var generation = 0L
|
||||||
|
private var externalActivityInFlight = false
|
||||||
|
private var resultDelivered = false
|
||||||
|
private var hostResumed = true
|
||||||
|
private var releaseConnectionLease: (() -> Unit)? = null
|
||||||
|
|
||||||
|
fun launchExternalActivity(
|
||||||
|
acquireConnectionLease: () -> Unit,
|
||||||
|
releaseConnectionLease: () -> Unit,
|
||||||
|
prepareForBackground: () -> Unit,
|
||||||
|
cleanupAfterLaunchFailure: () -> Unit,
|
||||||
|
scheduleTimeout: (Long, () -> Unit) -> Unit = ::scheduleTimeout,
|
||||||
|
launch: () -> Unit,
|
||||||
|
) {
|
||||||
|
val launchGeneration =
|
||||||
|
synchronized(lock) {
|
||||||
|
generation += 1
|
||||||
|
externalActivityInFlight = true
|
||||||
|
resultDelivered = false
|
||||||
|
this.releaseConnectionLease = releaseConnectionLease
|
||||||
|
generation
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
acquireConnectionLease()
|
||||||
|
scheduleTimeout(CONNECTION_LEASE_TIMEOUT_MS) {
|
||||||
|
finishExternalActivity(launchGeneration)
|
||||||
|
}
|
||||||
|
prepareForBackground()
|
||||||
|
launch()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
cleanupAfterLaunchFailure()
|
||||||
|
finishExternalActivity(launchGeneration)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onHostPaused() {
|
||||||
|
synchronized(lock) {
|
||||||
|
hostResumed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onHostResumed() {
|
||||||
|
val release =
|
||||||
|
synchronized(lock) {
|
||||||
|
hostResumed = true
|
||||||
|
if (externalActivityInFlight && resultDelivered) finishExternalActivityLocked() else null
|
||||||
|
}
|
||||||
|
release?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun externalActivityReturned() {
|
||||||
|
val release =
|
||||||
|
synchronized(lock) {
|
||||||
|
if (!externalActivityInFlight) return
|
||||||
|
resultDelivered = true
|
||||||
|
if (hostResumed) finishExternalActivityLocked() else null
|
||||||
|
}
|
||||||
|
release?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resetForTest() {
|
||||||
|
synchronized(lock) {
|
||||||
|
generation += 1
|
||||||
|
externalActivityInFlight = false
|
||||||
|
resultDelivered = false
|
||||||
|
hostResumed = true
|
||||||
|
releaseConnectionLease = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishExternalActivity(expectedGeneration: Long) {
|
||||||
|
val release =
|
||||||
|
synchronized(lock) {
|
||||||
|
if (!externalActivityInFlight || generation != expectedGeneration) return
|
||||||
|
finishExternalActivityLocked()
|
||||||
|
}
|
||||||
|
release?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishExternalActivityLocked(): (() -> Unit)? {
|
||||||
|
externalActivityInFlight = false
|
||||||
|
resultDelivered = false
|
||||||
|
return releaseConnectionLease.also { releaseConnectionLease = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleTimeout(
|
||||||
|
delayMs: Long,
|
||||||
|
action: () -> Unit,
|
||||||
|
) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed(action, delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,19 +75,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
consumeNotificationIntent(intent)
|
consumeNotificationIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onResume() {
|
||||||
super.onStart()
|
super.onResume()
|
||||||
NotificationHelper.setAppForeground(this, true)
|
NotificationHelper.setAppForeground(this, true)
|
||||||
|
ExternalActivityLifecycleGuard.onHostResumed()
|
||||||
NotificationHelper.stop(this)
|
NotificationHelper.stop(this)
|
||||||
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
|
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onPause() {
|
||||||
|
ExternalActivityLifecycleGuard.onHostPaused()
|
||||||
NotificationHelper.setAppForeground(this, false)
|
NotificationHelper.setAppForeground(this, false)
|
||||||
NotificationHelper.start(this)
|
NotificationHelper.start(this)
|
||||||
super.onStop()
|
super.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumeNotificationIntent(intent: Intent?) {
|
private fun consumeNotificationIntent(intent: Intent?) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ object HermesWsClient {
|
|||||||
private val intentionalClose = AtomicBoolean(false)
|
private val intentionalClose = AtomicBoolean(false)
|
||||||
private val acceptQueuedMessages = AtomicBoolean(true)
|
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||||
private val appInForeground = AtomicBoolean(true)
|
private val appInForeground = AtomicBoolean(true)
|
||||||
|
private val externalActivityConnectionLease = AtomicBoolean(false)
|
||||||
private val messageQueue = ConcurrentLinkedQueue<String>()
|
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||||
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||||
private val outboundLock = Any()
|
private val outboundLock = Any()
|
||||||
@@ -285,9 +286,19 @@ object HermesWsClient {
|
|||||||
if (!foreground) disconnectIfIdleInBackground()
|
if (!foreground) disconnectIfIdleInBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun acquireExternalActivityConnectionLease() {
|
||||||
|
externalActivityConnectionLease.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseExternalActivityConnectionLease() {
|
||||||
|
externalActivityConnectionLease.set(false)
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
private fun disconnectIfIdleInBackground() {
|
private fun disconnectIfIdleInBackground() {
|
||||||
synchronized(outboundLock) {
|
synchronized(outboundLock) {
|
||||||
if (!appInForeground.get() &&
|
if (!appInForeground.get() &&
|
||||||
|
!externalActivityConnectionLease.get() &&
|
||||||
!pendingReply &&
|
!pendingReply &&
|
||||||
pendingCalls.isEmpty() &&
|
pendingCalls.isEmpty() &&
|
||||||
messageQueue.isEmpty()
|
messageQueue.isEmpty()
|
||||||
|
|||||||
@@ -20,17 +20,22 @@ class ChatAttachmentsDelegate(
|
|||||||
name: String,
|
name: String,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
size: Long,
|
size: Long,
|
||||||
) {
|
) = addAttachments(
|
||||||
|
listOf(
|
||||||
|
Attachment(
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = size,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun addAttachments(attachments: List<Attachment>) {
|
||||||
|
if (attachments.isEmpty()) return
|
||||||
uiState.update { state ->
|
uiState.update { state ->
|
||||||
val attachment =
|
|
||||||
Attachment(
|
|
||||||
uri = uri,
|
|
||||||
name = name,
|
|
||||||
mimeType = mimeType,
|
|
||||||
size = size,
|
|
||||||
)
|
|
||||||
state.copy(
|
state.copy(
|
||||||
pendingAttachments = state.pendingAttachments + attachment,
|
pendingAttachments = state.pendingAttachments + attachments,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
import com.m57.hermescontrol.HistoryScreen
|
import com.m57.hermescontrol.HistoryScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
@@ -85,6 +86,7 @@ import com.m57.hermescontrol.data.update.AppUpdateState
|
|||||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
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.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
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
|
||||||
@@ -170,6 +172,7 @@ fun ChatScreen(
|
|||||||
val saveAttachmentLauncher =
|
val saveAttachmentLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
try {
|
try {
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
val path = pendingSavePath
|
val path = pendingSavePath
|
||||||
val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
|
val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
|
||||||
if (destination != null && path != null) {
|
if (destination != null && path != null) {
|
||||||
@@ -293,6 +296,15 @@ fun ChatScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||||
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
|
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||||
|
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
|
||||||
|
prepareForBackground = { NotificationHelper.start(context) },
|
||||||
|
cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
|
||||||
|
launch = launch,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
||||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||||
@@ -303,6 +315,7 @@ fun ChatScreen(
|
|||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
) { result ->
|
) { result ->
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
isListening = false
|
isListening = false
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
val spokenText =
|
val spokenText =
|
||||||
@@ -327,6 +340,7 @@ fun ChatScreen(
|
|||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
if (granted) {
|
if (granted) {
|
||||||
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||||
val intent =
|
val intent =
|
||||||
@@ -341,7 +355,9 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
isListening = true
|
isListening = true
|
||||||
speechLauncher.launch(intent)
|
launchExternalActivity {
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollScope.launch {
|
scrollScope.launch {
|
||||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
@@ -354,24 +370,43 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// File picker launcher for attachments (issue #195)
|
// Multi-file picker for attachments (issue #195).
|
||||||
val filePickerLauncher =
|
val filePickerLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.GetContent(),
|
ActivityResultContracts.GetMultipleContents(),
|
||||||
) { uri: Uri? ->
|
) { uris: List<Uri> ->
|
||||||
if (uri != null) {
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
val attachments =
|
||||||
cursor?.use { c ->
|
uris.mapNotNull { uri ->
|
||||||
if (c.moveToFirst()) {
|
runCatching {
|
||||||
val nameIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
var name = uri.lastPathSegment ?: "file"
|
||||||
val sizeIdx = c.getColumnIndex(OpenableColumns.SIZE)
|
var size = 0L
|
||||||
val name = if (nameIdx >= 0) c.getString(nameIdx) else uri.lastPathSegment ?: "file"
|
context.contentResolver
|
||||||
val size = if (sizeIdx >= 0) c.getLong(sizeIdx) else 0L
|
.query(
|
||||||
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
uri,
|
||||||
viewModel.addAttachment(uri.toString(), name, mimeType, size)
|
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)
|
// Camera photo launcher (issue #195)
|
||||||
@@ -381,6 +416,7 @@ fun ChatScreen(
|
|||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.TakePicture(),
|
ActivityResultContracts.TakePicture(),
|
||||||
) { success ->
|
) { success ->
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
val uri = pendingCameraUri
|
val uri = pendingCameraUri
|
||||||
pendingCameraUri = null
|
pendingCameraUri = null
|
||||||
if (success && uri != null) {
|
if (success && uri != null) {
|
||||||
@@ -635,23 +671,25 @@ fun ChatScreen(
|
|||||||
pendingSavePath = viewModel.gatewayPathFor(attachment)
|
pendingSavePath = viewModel.gatewayPathFor(attachment)
|
||||||
pendingSaveName = attachment.name
|
pendingSaveName = attachment.name
|
||||||
pendingSaveMimeType = attachment.mimeType
|
pendingSaveMimeType = attachment.mimeType
|
||||||
saveAttachmentLauncher.launch(
|
launchExternalActivity {
|
||||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
saveAttachmentLauncher.launch(
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
type =
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
attachment.mimeType
|
type =
|
||||||
.substringBefore(';')
|
attachment.mimeType
|
||||||
.trim()
|
.substringBefore(';')
|
||||||
.takeIf { it.isNotBlank() } ?: "application/octet-stream"
|
.trim()
|
||||||
putExtra(
|
.takeIf { it.isNotBlank() } ?: "application/octet-stream"
|
||||||
Intent.EXTRA_TITLE,
|
putExtra(
|
||||||
attachment.name
|
Intent.EXTRA_TITLE,
|
||||||
.substringAfterLast('/')
|
attachment.name
|
||||||
.substringAfterLast('\\')
|
.substringAfterLast('/')
|
||||||
.ifBlank { "download" },
|
.substringAfterLast('\\')
|
||||||
)
|
.ifBlank { "download" },
|
||||||
},
|
)
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,14 +792,18 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
isListening = true
|
isListening = true
|
||||||
speechLauncher.launch(intent)
|
launchExternalActivity {
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollScope.launch {
|
scrollScope.launch {
|
||||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
launchExternalActivity {
|
||||||
|
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isListening = isListening,
|
isListening = isListening,
|
||||||
@@ -784,14 +826,34 @@ fun ChatScreen(
|
|||||||
photoFile,
|
photoFile,
|
||||||
)
|
)
|
||||||
pendingCameraUri = uri
|
pendingCameraUri = uri
|
||||||
cameraLauncher.launch(uri)
|
launchExternalActivity {
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ChatScreen", "Camera launch failed", e)
|
Log.e("ChatScreen", "Camera launch failed", e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onImageTap = { filePickerLauncher.launch("image/*") },
|
onImageTap = {
|
||||||
onFileTap = { filePickerLauncher.launch("*/*") },
|
launchExternalActivity {
|
||||||
|
filePickerLauncher.launch("image/*")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFileTap = {
|
||||||
|
launchExternalActivity {
|
||||||
|
filePickerLauncher.launch("*/*")
|
||||||
|
}
|
||||||
|
},
|
||||||
onRemoveAttachment = viewModel::removeAttachment,
|
onRemoveAttachment = viewModel::removeAttachment,
|
||||||
|
onPreviewAttachment = { attachment ->
|
||||||
|
if (attachment.isImage) {
|
||||||
|
viewingImage =
|
||||||
|
ImageViewerModel(
|
||||||
|
model = attachment.uri,
|
||||||
|
name = attachment.name,
|
||||||
|
mimeType = attachment.mimeType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
// Composer toolbar wiring (PR 1)
|
// Composer toolbar wiring (PR 1)
|
||||||
currentSessionModel = state.currentSessionModel,
|
currentSessionModel = state.currentSessionModel,
|
||||||
reasoningLevel = state.reasoningLevel,
|
reasoningLevel = state.reasoningLevel,
|
||||||
|
|||||||
@@ -1512,6 +1512,8 @@ class ChatViewModel(
|
|||||||
size: Long,
|
size: Long,
|
||||||
) = attachmentsDelegate.addAttachment(uri, name, mimeType, size)
|
) = attachmentsDelegate.addAttachment(uri, name, mimeType, size)
|
||||||
|
|
||||||
|
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
|
||||||
|
|
||||||
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
||||||
|
|
||||||
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -88,6 +90,7 @@ fun ChatInputBar(
|
|||||||
onImageTap: () -> Unit = {},
|
onImageTap: () -> Unit = {},
|
||||||
onFileTap: () -> Unit = {},
|
onFileTap: () -> Unit = {},
|
||||||
onRemoveAttachment: (Int) -> Unit = {},
|
onRemoveAttachment: (Int) -> Unit = {},
|
||||||
|
onPreviewAttachment: (Attachment) -> Unit = {},
|
||||||
// NEW: composer toolbar wiring
|
// NEW: composer toolbar wiring
|
||||||
currentSessionModel: String? = null,
|
currentSessionModel: String? = null,
|
||||||
reasoningLevel: String? = null,
|
reasoningLevel: String? = null,
|
||||||
@@ -272,16 +275,17 @@ fun ChatInputBar(
|
|||||||
enter = fadeIn() + expandVertically(),
|
enter = fadeIn() + expandVertically(),
|
||||||
exit = fadeOut() + shrinkVertically(),
|
exit = fadeOut() + shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
Row(
|
LazyRow(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
pendingAttachments.forEachIndexed { index, attachment ->
|
itemsIndexed(pendingAttachments) { index, attachment ->
|
||||||
AttachmentChip(
|
AttachmentChip(
|
||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
|
onPreview = { onPreviewAttachment(attachment) },
|
||||||
onRemove = { onRemoveAttachment(index) },
|
onRemove = { onRemoveAttachment(index) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -475,6 +479,7 @@ fun ChatInputBar(
|
|||||||
@Composable
|
@Composable
|
||||||
fun AttachmentChip(
|
fun AttachmentChip(
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
|
onPreview: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -494,8 +499,10 @@ fun AttachmentChip(
|
|||||||
contentDescription = attachment.name,
|
contentDescription = attachment.name,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(24.dp)
|
.size(40.dp)
|
||||||
.clip(RoundedCornerShape(4.dp)),
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onPreview)
|
||||||
|
.testTag("attachment_preview"),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.m57.hermescontrol
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ExternalActivityLifecycleGuardTest {
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
ExternalActivityLifecycleGuard.resetForTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
ExternalActivityLifecycleGuard.resetForTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun externalActivityLaunch_acquiresConnectionLeaseBeforePreparingServiceAndLaunching() {
|
||||||
|
val calls = mutableListOf<String>()
|
||||||
|
|
||||||
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
|
acquireConnectionLease = { calls += "acquire" },
|
||||||
|
releaseConnectionLease = { calls += "release" },
|
||||||
|
prepareForBackground = { calls += "prepare" },
|
||||||
|
cleanupAfterLaunchFailure = { calls += "cleanup" },
|
||||||
|
scheduleTimeout = { _, _ -> },
|
||||||
|
launch = { calls += "launch" },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("acquire", "prepare", "launch"), calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transientResumeDuringPicker_doesNotReleaseLeaseUntilResultReturns() {
|
||||||
|
var released = false
|
||||||
|
|
||||||
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
|
acquireConnectionLease = {},
|
||||||
|
releaseConnectionLease = { released = true },
|
||||||
|
prepareForBackground = {},
|
||||||
|
cleanupAfterLaunchFailure = {},
|
||||||
|
scheduleTimeout = { _, _ -> },
|
||||||
|
launch = {},
|
||||||
|
)
|
||||||
|
ExternalActivityLifecycleGuard.onHostPaused()
|
||||||
|
ExternalActivityLifecycleGuard.onHostResumed()
|
||||||
|
|
||||||
|
assertFalse(released)
|
||||||
|
|
||||||
|
ExternalActivityLifecycleGuard.onHostPaused()
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
|
||||||
|
assertFalse(released)
|
||||||
|
|
||||||
|
ExternalActivityLifecycleGuard.onHostResumed()
|
||||||
|
|
||||||
|
assertTrue(released)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun abandonedPicker_timeoutReleasesConnectionLease() {
|
||||||
|
var timeoutAction: (() -> Unit)? = null
|
||||||
|
var released = false
|
||||||
|
|
||||||
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
|
acquireConnectionLease = {},
|
||||||
|
releaseConnectionLease = { released = true },
|
||||||
|
prepareForBackground = {},
|
||||||
|
cleanupAfterLaunchFailure = {},
|
||||||
|
scheduleTimeout = { _, action -> timeoutAction = action },
|
||||||
|
launch = {},
|
||||||
|
)
|
||||||
|
ExternalActivityLifecycleGuard.onHostPaused()
|
||||||
|
|
||||||
|
timeoutAction?.invoke()
|
||||||
|
|
||||||
|
assertTrue(released)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedExternalActivityLaunch_cleansUpServiceAndConnectionLease() {
|
||||||
|
val calls = mutableListOf<String>()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
|
acquireConnectionLease = { calls += "acquire" },
|
||||||
|
releaseConnectionLease = { calls += "release" },
|
||||||
|
prepareForBackground = { calls += "prepare" },
|
||||||
|
cleanupAfterLaunchFailure = { calls += "cleanup" },
|
||||||
|
scheduleTimeout = { _, _ -> },
|
||||||
|
launch = { error("picker unavailable") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(listOf("acquire", "prepare", "cleanup", "release"), calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ class HermesWsClientTest {
|
|||||||
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
|
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
|
||||||
|
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.setAppForeground(true)
|
HermesWsClient.setAppForeground(true)
|
||||||
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
||||||
acceptQueuedMessagesField.isAccessible = true
|
acceptQueuedMessagesField.isAccessible = true
|
||||||
@@ -97,6 +98,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
||||||
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
|
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
|
||||||
@@ -837,6 +839,23 @@ class HermesWsClientTest {
|
|||||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() {
|
||||||
|
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
|
|
||||||
|
HermesWsClient.acquireExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.setAppForeground(false)
|
||||||
|
|
||||||
|
assertTrue(HermesWsClient.isConnected)
|
||||||
|
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
|
||||||
|
assertFalse(HermesWsClient.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testBackgroundWithPendingReplyStaysConnected() {
|
fun testBackgroundWithPendingReplyStaysConnected() {
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
|
|||||||
@@ -1305,6 +1305,24 @@ class ChatViewModelTest {
|
|||||||
assertEquals("dummy.txt", pending[0].name)
|
assertEquals("dummy.txt", pending[0].name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addAttachments_keepsEverySelectedImageInOrder() =
|
||||||
|
runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
val selected =
|
||||||
|
listOf(
|
||||||
|
Attachment("content://image/1", "one.jpg", "image/jpeg", 100L),
|
||||||
|
Attachment("content://image/2", "two.png", "image/png", 200L),
|
||||||
|
Attachment("content://image/3", "three.webp", "image/webp", 300L),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.addAttachments(selected)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(selected, viewModel.uiState.value.pendingAttachments)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testRemoveAttachment_validIndex() =
|
fun testRemoveAttachment_validIndex() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in new issue
Block a user