feat(chat): stabilize multi-image attachment picker (#986)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-27 10:50:55 +03:00
1 parent c7d7abf4a1
commit 0ae5ed4db2
11 files changed
+404 -27

No files matched your search

@@ -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(
uiState.update { state -> listOf(
val attachment =
Attachment( Attachment(
uri = uri, uri = uri,
name = name, name = name,
mimeType = mimeType, mimeType = mimeType,
size = size, size = size,
),
),
) )
fun addAttachments(attachments: List<Attachment>) {
if (attachments.isEmpty()) return
uiState.update { state ->
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
launchExternalActivity {
speechLauncher.launch(intent) 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,6 +671,7 @@ fun ChatScreen(
pendingSavePath = viewModel.gatewayPathFor(attachment) pendingSavePath = viewModel.gatewayPathFor(attachment)
pendingSaveName = attachment.name pendingSaveName = attachment.name
pendingSaveMimeType = attachment.mimeType pendingSaveMimeType = attachment.mimeType
launchExternalActivity {
saveAttachmentLauncher.launch( saveAttachmentLauncher.launch(
Intent(Intent.ACTION_CREATE_DOCUMENT).apply { Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
@@ -654,6 +691,7 @@ fun ChatScreen(
) )
} }
} }
}
// Full-bleed chat renderer (issue #866) — the single chat // Full-bleed chat renderer (issue #866) — the single chat
// surface since the bubble renderer was removed. // surface since the bubble renderer was removed.
@@ -754,15 +792,19 @@ fun ChatScreen(
) )
} }
isListening = true isListening = true
launchExternalActivity {
speechLauncher.launch(intent) speechLauncher.launch(intent)
}
} else { } else {
scrollScope.launch { scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg) snackbarHostState.showSnackbar(sttNotAvailableMsg)
} }
} }
} else { } else {
launchExternalActivity {
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} }
}
}, },
isListening = isListening, isListening = isListening,
isAgentTyping = state.isAgentTyping, isAgentTyping = state.isAgentTyping,
@@ -784,14 +826,34 @@ fun ChatScreen(
photoFile, photoFile,
) )
pendingCameraUri = uri pendingCameraUri = uri
launchExternalActivity {
cameraLauncher.launch(uri) 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 {