diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index b8779fc4..883f3301 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -94,8 +94,8 @@ class MainActivity : ComponentActivity() { // Issue #832: MainActivity is exported (launcher requirement) — only // honor intents stamped with our own notification action. if (intent?.action != ACTION_OPEN_CHAT_FROM_NOTIFICATION) return - val sessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) - intent?.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) + val sessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) + intent.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt index 7131ac38..eb491e49 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt @@ -86,7 +86,7 @@ open class AppUpdateChecker( .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use null - val body = response.body?.string().orEmpty() + val body = response.body.string().orEmpty() parseUpdateInfo(body) } } @@ -106,7 +106,7 @@ open class AppUpdateChecker( try { client.newCall(request).execute().use { response -> if (!response.isSuccessful) return@withContext false - val body = response.body ?: return@withContext false + val body = response.body val total = body.contentLength() dest.outputStream().use { out -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) 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 6c0473d9..b14e6e35 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 @@ -303,7 +303,7 @@ object HermesWsClient { } if (socketToCancel != null) { rejectAllPending() - socketToCancel?.cancel() + socketToCancel.cancel() } if (shouldOpen) openSocket() } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt index e17e87c1..d09fd10d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt @@ -153,7 +153,7 @@ class KanbanEventsClient { request, object : WebSocketListener() { override fun onOpen( - ws: WebSocket, + webSocket: WebSocket, response: Response, ) { if (closed || gen != generation) return @@ -162,7 +162,7 @@ class KanbanEventsClient { } override fun onMessage( - ws: WebSocket, + webSocket: WebSocket, text: String, ) { if (closed || gen != generation) return @@ -173,20 +173,20 @@ class KanbanEventsClient { } override fun onMessage( - ws: WebSocket, + webSocket: WebSocket, bytes: ByteString, ) = Unit override fun onClosing( - ws: WebSocket, + webSocket: WebSocket, code: Int, reason: String, ) { - ws.close(code, reason) + webSocket.close(code, reason) } override fun onClosed( - ws: WebSocket, + webSocket: WebSocket, code: Int, reason: String, ) { @@ -199,7 +199,7 @@ class KanbanEventsClient { } override fun onFailure( - ws: WebSocket, + webSocket: WebSocket, t: Throwable, response: Response?, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingScreen.kt index 7b95cc28..aa5797b3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingScreen.kt @@ -135,7 +135,7 @@ private fun BillingContent( shape = RoundedCornerShape(12.dp), ) { Text( - text = state.errorMessage ?: "", + text = state.errorMessage, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer, @@ -152,7 +152,7 @@ private fun BillingContent( shape = RoundedCornerShape(12.dp), ) { Text( - text = state.actionMessage ?: "", + text = state.actionMessage, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium, ) @@ -207,7 +207,7 @@ private fun PlanCard(subscription: SubscriptionCurrent) { } if (subscription.pending_downgrade_display != null) { Text( - text = subscription.pending_downgrade_display ?: "", + text = subscription.pending_downgrade_display, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), 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 abaa75b6..0aa8d291 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 @@ -2890,7 +2890,7 @@ class ChatViewModel( if (lastAssistantIdx >= 0) { val target = mapped[lastAssistantIdx] if (target.reasoningText.isBlank()) { - mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning!!) + mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning) } } } 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 bb1240a7..01745b4d 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 @@ -28,9 +28,9 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -405,7 +405,7 @@ fun AttachmentChip( Spacer(modifier = Modifier.width(4.dp)) } else { Icon( - imageVector = Icons.Default.InsertDriveFile, + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = attachment.name, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 5c6e7834..2d979171 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -90,7 +90,7 @@ object ToolViewBuilder { raw = firstString(parseMaybeObject(value), listOf("context")) } - return compactPreview(raw ?: "", max) + return compactPreview(raw, max) } private fun contextValue(value: JsonElement?): String { @@ -1328,8 +1328,8 @@ object ToolViewBuilder { val elements = result?.get("elements") as? JsonArray if (elements != null) { val mode = firstString(result, listOf("mode")) - val width = intValue(result?.get("width")) - val height = intValue(result?.get("height")) + val width = intValue(result.get("width")) + val height = intValue(result.get("height")) val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: "" val sizePart = if (width != null && height != null) " ${width}x$height" else "" return "${elements.size} elements$modePart$sizePart" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt index b4a8e119..dde8ab3f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt @@ -5,6 +5,7 @@ package com.m57.hermescontrol.ui.keys +import android.content.ClipData import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -29,6 +30,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Delete @@ -41,7 +43,6 @@ import androidx.compose.material.icons.outlined.Build import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Forum -import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.material.icons.outlined.SmartToy import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.AlertDialog @@ -66,11 +67,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -97,6 +100,7 @@ import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect +import kotlinx.coroutines.launch private const val FILTER_ALL = "ALL" @@ -527,8 +531,9 @@ private fun EnvVarCard( val isRevealed = revealedValue != null var editedValue by remember(config, revealedValue) { mutableStateOf(revealedValue ?: "") } - val clipboardManager = LocalClipboardManager.current + val clipboard = LocalClipboard.current val uriHandler = LocalUriHandler.current + val scope = rememberCoroutineScope() val displayValue = if (isRevealed) { @@ -645,7 +650,7 @@ private fun EnvVarCard( }.padding(vertical = 2.dp), ) { Icon( - imageVector = Icons.Outlined.OpenInNew, + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = stringResource(R.string.keys_action_open_url), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(14.dp), @@ -759,7 +764,10 @@ private fun EnvVarCard( } if (isRevealed && !revealedValue.isNullOrEmpty()) { IconButton(onClick = { - clipboardManager.setText(AnnotatedString(revealedValue)) + val text = AnnotatedString(revealedValue) + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, text))) + } onShowToast(copiedMessage) }) { Icon( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 6045eed7..cd5bb288 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.profiles +import android.content.ClipData import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,11 +42,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -64,6 +67,7 @@ import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.ui.profiles.components.ProfileBuilderView import com.m57.hermescontrol.ui.profiles.components.validateProfileName +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -91,7 +95,8 @@ fun ProfilesScreen( var setupCmdProfileName by remember { mutableStateOf(null) } var copiedSetupCmd by remember { mutableStateOf(false) } - val clipboardManager = LocalClipboardManager.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() var isBuildingProfile by remember { mutableStateOf(false) } @@ -839,7 +844,8 @@ fun ProfilesScreen( if (!state.isLoadingSetupCommand && state.setupCommand != null) { Button( onClick = { - clipboardManager.setText(AnnotatedString(state.setupCommand.orEmpty())) + val text = AnnotatedString(state.setupCommand.orEmpty()) + scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, text))) } copiedSetupCmd = true }, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt index 24e86e0c..81f691ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt @@ -288,7 +288,7 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost { state.copy( postSetup = state.postSetup?.copy( - lines = data.lines ?: state.postSetup?.lines ?: emptyList(), + lines = data.lines ?: state.postSetup.lines, running = data.running == true, exitCode = data.exit_code, ), diff --git a/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt b/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt index d04340ce..11508623 100644 --- a/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt @@ -31,24 +31,34 @@ object LocaleContextWrapper { code.contains("-r", ignoreCase = true) -> { val parts = code.split("-r", ignoreCase = true) - Locale(parts[0], parts.getOrElse(1) { "" }) + localeFor(parts[0], parts.getOrElse(1) { "" }) } code.contains("-") -> { val parts = code.split("-") - Locale(parts[0], parts.getOrElse(1) { "" }) + localeFor(parts[0], parts.getOrElse(1) { "" }) } code.contains("_") -> { val parts = code.split("_") - Locale(parts[0], parts.getOrElse(1) { "" }) + localeFor(parts[0], parts.getOrElse(1) { "" }) } else -> { - Locale(code) + Locale.forLanguageTag(code) } } + private fun localeFor( + language: String, + region: String, + ): Locale = + if (region.isEmpty()) { + Locale.forLanguageTag(language) + } else { + Locale.forLanguageTag("$language-$region") + } + /** * Wrap [base] so the configuration carries [locale]. * diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index ac4f7960..d8ceaff4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -6,11 +6,13 @@ import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository +import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -95,6 +97,16 @@ class ChatUpdateCommandTest { } every { HermesWsClient.disconnect() } returns Unit + // ChatViewModel's init subscribes to ProfileSwitchCoordinator.switched + // on viewModelScope. Without mocking the singleton, every VM created + // here parks a collector on the REAL flow — when a later test class + // (e.g. ProfileSwitchCoordinatorTest) emits on it, the stale-Main + // resumption crashes with DispatchException (CI flakes). Mirror + // ChatViewModelTest: stub the flow with a never-emitting mock so the + // collectors park harmlessly. + mockkObject(ProfileSwitchCoordinator) + every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow() + every { HermesWsClient.send(any(), any(), any()) } answers { reqCount++ val id = "req-id-$reqCount" @@ -122,7 +134,11 @@ class ChatUpdateCommandTest { } private suspend fun TestScope.createViewModelWithSession(): Pair { - val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) + // FakeSlashUsageStore: the real store inits a DataStore on real IO + // threads — with a relaxed-mock app that NPEs on cacheDir and leaks + // an uncaught exception into the NEXT runTest class (CI flakes: + // UncaughtExceptionsBeforeTest). + val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) @@ -154,7 +170,7 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate triggers the REST action and starts the progress popup`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) + val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) coEvery { mockApi.updateHermes() } returns Response.success(ActionResponse(ok = true, name = "hermes-update")) // One running poll, then the action exits — runTest's teardown @@ -193,7 +209,7 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate settles on success when the action exits cleanly`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) + val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) coEvery { mockApi.updateHermes() } returns Response.success(ActionResponse(ok = true, name = "hermes-update")) coEvery { mockApi.getActionStatus("hermes-update") } returns @@ -221,7 +237,7 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate surfaces a rejected trigger in the popup`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) + val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) coEvery { mockApi.updateHermes() } returns Response.error(404, "no update endpoint".toResponseBody()) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 43b61c2f..7d4b367f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -4,6 +4,7 @@ import android.app.Application import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent @@ -75,6 +76,15 @@ class SlashCommandDispatchRpcTest { mockkObject(ApiClient) mockkObject(HermesDatabase) + // ChatViewModel's init subscribes to ProfileSwitchCoordinator.switched + // on viewModelScope. Without mocking the singleton, every VM created + // here parks a collector on the REAL flow — when a later test class + // emits on it, the stale-Main resumption crashes (DispatchException, + // CI flakes). Stub it with a never-emitting mock flow (ChatViewModelTest + // pattern) so the collectors park harmlessly. + mockkObject(ProfileSwitchCoordinator) + every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow() + app = mockk(relaxed = true) fakeRepo = FakeChatPersistenceRepository()