From 0ae5ed4db2ed39c6cb93940cec4ed8cfb2e3eac5 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:50:55 +0200 Subject: [PATCH] feat(chat): stabilize multi-image attachment picker (#986) --- .../ui/chat/components/AttachmentChipTest.kt | 46 ++++++ .../ExternalActivityLifecycleGuard.kt | 104 +++++++++++++ .../com/m57/hermescontrol/MainActivity.kt | 10 +- .../hermescontrol/data/ws/HermesWsClient.kt | 11 ++ .../ui/chat/ChatAttachmentsDelegate.kt | 23 +-- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 138 +++++++++++++----- .../hermescontrol/ui/chat/ChatViewModel.kt | 2 + .../ui/chat/components/ChatComposer.kt | 15 +- .../ExternalActivityLifecycleGuardTest.kt | 101 +++++++++++++ .../data/ws/HermesWsClientTest.kt | 19 +++ .../ui/chat/ChatViewModelTest.kt | 18 +++ 11 files changed, 432 insertions(+), 55 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/AttachmentChipTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ExternalActivityLifecycleGuard.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ExternalActivityLifecycleGuardTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/AttachmentChipTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/AttachmentChipTest.kt new file mode 100644 index 00000000..5dd0e003 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/AttachmentChipTest.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ExternalActivityLifecycleGuard.kt b/app/src/main/java/com/m57/hermescontrol/ExternalActivityLifecycleGuard.kt new file mode 100644 index 00000000..56aa4104 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ExternalActivityLifecycleGuard.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 60456818..75692ca4 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -75,19 +75,21 @@ class MainActivity : ComponentActivity() { consumeNotificationIntent(intent) } - override fun onStart() { - super.onStart() + override fun onResume() { + super.onResume() NotificationHelper.setAppForeground(this, true) + ExternalActivityLifecycleGuard.onHostResumed() NotificationHelper.stop(this) if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) { HermesWsClient.connect() } } - override fun onStop() { + override fun onPause() { + ExternalActivityLifecycleGuard.onHostPaused() NotificationHelper.setAppForeground(this, false) NotificationHelper.start(this) - super.onStop() + super.onPause() } private fun consumeNotificationIntent(intent: Intent?) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 78c68728..965da7f8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -97,6 +97,7 @@ object HermesWsClient { private val intentionalClose = AtomicBoolean(false) private val acceptQueuedMessages = AtomicBoolean(true) private val appInForeground = AtomicBoolean(true) + private val externalActivityConnectionLease = AtomicBoolean(false) private val messageQueue = ConcurrentLinkedQueue() private val queuedMessagesById = ConcurrentHashMap() private val outboundLock = Any() @@ -285,9 +286,19 @@ object HermesWsClient { if (!foreground) disconnectIfIdleInBackground() } + fun acquireExternalActivityConnectionLease() { + externalActivityConnectionLease.set(true) + } + + fun releaseExternalActivityConnectionLease() { + externalActivityConnectionLease.set(false) + disconnectIfIdleInBackground() + } + private fun disconnectIfIdleInBackground() { synchronized(outboundLock) { if (!appInForeground.get() && + !externalActivityConnectionLease.get() && !pendingReply && pendingCalls.isEmpty() && messageQueue.isEmpty() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatAttachmentsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatAttachmentsDelegate.kt index 8d0adcb4..55f418dc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatAttachmentsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatAttachmentsDelegate.kt @@ -20,17 +20,22 @@ class ChatAttachmentsDelegate( name: String, mimeType: String, size: Long, - ) { + ) = addAttachments( + listOf( + Attachment( + uri = uri, + name = name, + mimeType = mimeType, + size = size, + ), + ), + ) + + fun addAttachments(attachments: List) { + if (attachments.isEmpty()) return uiState.update { state -> - val attachment = - Attachment( - uri = uri, - name = name, - mimeType = mimeType, - size = size, - ) state.copy( - pendingAttachments = state.pendingAttachments + attachment, + pendingAttachments = state.pendingAttachments + attachments, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 7a1a0778..3dcc531e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -75,6 +75,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ExternalActivityLifecycleGuard import com.m57.hermescontrol.HistoryScreen import com.m57.hermescontrol.NavigationController 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.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner import com.m57.hermescontrol.ui.chat.components.ChatInputBar @@ -170,6 +172,7 @@ fun ChatScreen( val saveAttachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> try { + ExternalActivityLifecycleGuard.externalActivityReturned() val path = pendingSavePath val destination = acceptedSaveDestination(result.resultCode, result.data?.data) if (destination != null && path != null) { @@ -293,6 +296,15 @@ fun ChatScreen( val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() 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 sttNotAvailableMsg = stringResource(R.string.stt_not_available) @@ -303,6 +315,7 @@ fun ChatScreen( rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { result -> + ExternalActivityLifecycleGuard.externalActivityReturned() isListening = false if (result.resultCode == Activity.RESULT_OK) { val spokenText = @@ -327,6 +340,7 @@ fun ChatScreen( rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> + ExternalActivityLifecycleGuard.externalActivityReturned() if (granted) { if (SpeechRecognizer.isRecognitionAvailable(context)) { val intent = @@ -341,7 +355,9 @@ fun ChatScreen( ) } isListening = true - speechLauncher.launch(intent) + launchExternalActivity { + speechLauncher.launch(intent) + } } else { scrollScope.launch { 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 = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent(), - ) { uri: Uri? -> - if (uri != null) { - val cursor = context.contentResolver.query(uri, null, null, null, null) - cursor?.use { c -> - if (c.moveToFirst()) { - val nameIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = c.getColumnIndex(OpenableColumns.SIZE) - val name = if (nameIdx >= 0) c.getString(nameIdx) else uri.lastPathSegment ?: "file" - val size = if (sizeIdx >= 0) c.getLong(sizeIdx) else 0L - val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" - viewModel.addAttachment(uri.toString(), name, mimeType, size) - } + ActivityResultContracts.GetMultipleContents(), + ) { uris: List -> + ExternalActivityLifecycleGuard.externalActivityReturned() + val attachments = + uris.mapNotNull { uri -> + runCatching { + var name = uri.lastPathSegment ?: "file" + var size = 0L + context.contentResolver + .query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx) + if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx) + } + } + Attachment( + uri = uri.toString(), + name = name, + mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream", + size = size, + ) + }.onFailure { error -> + Log.w("ChatScreen", "Skipping unreadable picked attachment", error) + }.getOrNull() } - } + viewModel.addAttachments(attachments) } // Camera photo launcher (issue #195) @@ -381,6 +416,7 @@ fun ChatScreen( rememberLauncherForActivityResult( ActivityResultContracts.TakePicture(), ) { success -> + ExternalActivityLifecycleGuard.externalActivityReturned() val uri = pendingCameraUri pendingCameraUri = null if (success && uri != null) { @@ -635,23 +671,25 @@ fun ChatScreen( pendingSavePath = viewModel.gatewayPathFor(attachment) pendingSaveName = attachment.name pendingSaveMimeType = attachment.mimeType - saveAttachmentLauncher.launch( - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = - attachment.mimeType - .substringBefore(';') - .trim() - .takeIf { it.isNotBlank() } ?: "application/octet-stream" - putExtra( - Intent.EXTRA_TITLE, - attachment.name - .substringAfterLast('/') - .substringAfterLast('\\') - .ifBlank { "download" }, - ) - }, - ) + launchExternalActivity { + saveAttachmentLauncher.launch( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = + attachment.mimeType + .substringBefore(';') + .trim() + .takeIf { it.isNotBlank() } ?: "application/octet-stream" + putExtra( + Intent.EXTRA_TITLE, + attachment.name + .substringAfterLast('/') + .substringAfterLast('\\') + .ifBlank { "download" }, + ) + }, + ) + } } } @@ -754,14 +792,18 @@ fun ChatScreen( ) } isListening = true - speechLauncher.launch(intent) + launchExternalActivity { + speechLauncher.launch(intent) + } } else { scrollScope.launch { snackbarHostState.showSnackbar(sttNotAvailableMsg) } } } else { - micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + launchExternalActivity { + micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } } }, isListening = isListening, @@ -784,14 +826,34 @@ fun ChatScreen( photoFile, ) pendingCameraUri = uri - cameraLauncher.launch(uri) + launchExternalActivity { + cameraLauncher.launch(uri) + } } catch (e: Exception) { Log.e("ChatScreen", "Camera launch failed", e) } }, - onImageTap = { filePickerLauncher.launch("image/*") }, - onFileTap = { filePickerLauncher.launch("*/*") }, + onImageTap = { + launchExternalActivity { + filePickerLauncher.launch("image/*") + } + }, + onFileTap = { + launchExternalActivity { + filePickerLauncher.launch("*/*") + } + }, onRemoveAttachment = viewModel::removeAttachment, + onPreviewAttachment = { attachment -> + if (attachment.isImage) { + viewingImage = + ImageViewerModel( + model = attachment.uri, + name = attachment.name, + mimeType = attachment.mimeType, + ) + } + }, // Composer toolbar wiring (PR 1) currentSessionModel = state.currentSessionModel, reasoningLevel = state.reasoningLevel, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 8553a278..29114bed 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -1512,6 +1512,8 @@ class ChatViewModel( size: Long, ) = attachmentsDelegate.addAttachment(uri, name, mimeType, size) + fun addAttachments(attachments: List) = attachmentsDelegate.addAttachments(attachments) + fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index) fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 8a76b71d..4276e838 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -25,7 +25,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons @@ -88,6 +90,7 @@ fun ChatInputBar( onImageTap: () -> Unit = {}, onFileTap: () -> Unit = {}, onRemoveAttachment: (Int) -> Unit = {}, + onPreviewAttachment: (Attachment) -> Unit = {}, // NEW: composer toolbar wiring currentSessionModel: String? = null, reasoningLevel: String? = null, @@ -272,16 +275,17 @@ fun ChatInputBar( enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - Row( + LazyRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - pendingAttachments.forEachIndexed { index, attachment -> + itemsIndexed(pendingAttachments) { index, attachment -> AttachmentChip( attachment = attachment, + onPreview = { onPreviewAttachment(attachment) }, onRemove = { onRemoveAttachment(index) }, ) } @@ -475,6 +479,7 @@ fun ChatInputBar( @Composable fun AttachmentChip( attachment: Attachment, + onPreview: () -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -494,8 +499,10 @@ fun AttachmentChip( contentDescription = attachment.name, modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(4.dp)), + .size(40.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onPreview) + .testTag("attachment_preview"), contentScale = ContentScale.Crop, ) Spacer(modifier = Modifier.width(4.dp)) diff --git a/app/src/test/java/com/m57/hermescontrol/ExternalActivityLifecycleGuardTest.kt b/app/src/test/java/com/m57/hermescontrol/ExternalActivityLifecycleGuardTest.kt new file mode 100644 index 00000000..745e864a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ExternalActivityLifecycleGuardTest.kt @@ -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() + + 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() + + 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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index c8e8c102..0abb0472 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -89,6 +89,7 @@ class HermesWsClientTest { (queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue).clear() HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean + HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.setAppForeground(true) val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages") acceptQueuedMessagesField.isAccessible = true @@ -97,6 +98,7 @@ class HermesWsClientTest { @After fun tearDown() { + HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.disconnect(clearPendingMessages = true) // 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 @@ -837,6 +839,23 @@ class HermesWsClientTest { 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 fun testBackgroundWithPendingReplyStaysConnected() { mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index c20b10c2..566fa3c0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -1305,6 +1305,24 @@ class ChatViewModelTest { 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 fun testRemoveAttachment_validIndex() = runTest {