From 5c1492a80e193adfdd63d9c9a87535ef21c543ef Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:32:45 +0300 Subject: [PATCH] feat(chat): add animated GIF rendering with tap-to-play animation (#721) (#731) * feat(chat): add animated GIF rendering with tap-to-play animation Closes #721 * fix(#721): add missing GifImageThumbnail component and format ktlint * fix(analytics): resolve ktlint indentation error in itemsIndexed * feat(chat): add interactive play/pause toggle for animated GIFs * fix(chat): control GIF playback via Animatable start and stop --- app/build.gradle.kts | 1 + .../com/m57/hermescontrol/HermesControlApp.kt | 11 +- .../data/config/ServerStoreMigration.kt | 10 +- .../data/config/ServerUrlMigration.kt | 20 +-- .../hermescontrol/data/local/AuthManager.kt | 10 +- .../hermescontrol/data/model/Attachment.kt | 7 + .../data/remote/CookieManager.kt | 3 +- .../data/remote/ServerEndpoint.kt | 7 +- .../data/ws/BillingRepository.kt | 38 +++-- .../ui/achievements/AchievementsViewModel.kt | 4 +- .../ui/analytics/AnalyticsScreen.kt | 24 ++-- .../ui/billing/BillingViewModel.kt | 18 ++- .../m57/hermescontrol/ui/chat/ChatBubble.kt | 30 ++-- .../ui/chat/ImageBytesResolver.kt | 7 +- .../ui/chat/ImageViewerDialog.kt | 7 +- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 41 +++--- .../ui/chat/components/ComposerToolbar.kt | 5 +- .../ui/chat/components/GifImageThumbnail.kt | 136 ++++++++++++++++++ .../ui/chat/components/MessageCards.kt | 5 +- .../m57/hermescontrol/ui/common/StateViews.kt | 6 +- .../hermescontrol/ui/model/ModelViewModel.kt | 4 +- .../ui/process/ProcessesViewModel.kt | 4 +- .../ui/sessions/SessionsViewModel.kt | 4 +- .../util/LocaleContextWrapper.kt | 12 +- .../data/local/ChatMessageMapperTest.kt | 10 +- .../hermescontrol/data/remote/CallExtTest.kt | 3 +- .../data/remote/CookieManagerTest.kt | 4 +- .../data/ws/HermesWsClientTest.kt | 6 +- .../ui/chat/ChatViewModelTest.kt | 5 +- .../ui/chat/MarkdownTextFeatureTest.kt | 22 +++ .../ui/chat/SlashCommandDispatchRpcTest.kt | 23 ++- gradle/libs.versions.toml | 1 + 32 files changed, 370 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d96ed14d..a9a67287 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,6 +166,7 @@ dependencies { // Image loading implementation(libs.coil.compose) + implementation(libs.coil.gif) // Encrypted storage implementation(libs.androidx.security.crypto) diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index c451fd3d..0acb0bb1 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -1,8 +1,11 @@ package com.m57.hermescontrol import android.app.Application +import android.os.Build import coil.ImageLoader import coil.ImageLoaderFactory +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider @@ -28,6 +31,12 @@ class HermesControlApp : ImageLoader .Builder(this) .okHttpClient(OkHttpProvider.base) - .crossfade(true) + .components { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + }.crossfade(true) .build() } diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt index 484dc100..2961cbc1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt @@ -105,10 +105,12 @@ class ServerStoreMigration( profile.copy( baseUrl = profile.baseUrl - ?: ServerEndpoint.fromLegacy( - profile.host, - profile.port, - ).baseUrl.toString(), + ?: ServerEndpoint + .fromLegacy( + profile.host, + profile.port, + ).baseUrl + .toString(), ) }, selectedProfileId = selectedProfileId, diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerUrlMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerUrlMigration.kt index 02c926a7..2d0c5331 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerUrlMigration.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerUrlMigration.kt @@ -19,19 +19,23 @@ class ServerUrlMigration : DataMigration { state.copy( baseUrl = normalizeExisting(state.baseUrl) - ?: ServerEndpoint.fromLegacy( - state.host, - state.port, - ).baseUrl.toString(), + ?: ServerEndpoint + .fromLegacy( + state.host, + state.port, + ).baseUrl + .toString(), connectionProfiles = state.connectionProfiles.map { profile -> profile.copy( baseUrl = normalizeExisting(profile.baseUrl) - ?: ServerEndpoint.fromLegacy( - profile.host, - profile.port, - ).baseUrl.toString(), + ?: ServerEndpoint + .fromLegacy( + profile.host, + profile.port, + ).baseUrl + .toString(), ) }, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index c773d149..6b853e33 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -427,10 +427,12 @@ object AuthManager { fun setBaseUrl(baseUrl: String) { val normalized = - ServerEndpoint.parse( - baseUrl, - CleartextPolicy.ALLOW_WITH_WARNING, - ).baseUrl.toString() + ServerEndpoint + .parse( + baseUrl, + CleartextPolicy.ALLOW_WITH_WARNING, + ).baseUrl + .toString() val selectedId = getSelectedProfileId() ?: run { ensureDefaultSelected() diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt index 001feb73..d12d1635 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt @@ -28,6 +28,13 @@ data class Attachment( val isImage: Boolean get() = mimeType.startsWith("image/") + val isGif: Boolean + get() = + mimeType.equals("image/gif", ignoreCase = true) || + fileExtension == "gif" || + uri.contains(".gif", ignoreCase = true) || + uri.startsWith("data:image/gif", ignoreCase = true) + val isGateway: Boolean get() = source == AttachmentSource.GATEWAY diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt index db11b010..1baa49c6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt @@ -81,8 +81,7 @@ object CookieManager { .expiresAt( System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000, - ) - .hostOnlyDomain(endpoint.baseUrl.host) + ).hostOnlyDomain(endpoint.baseUrl.host) .path(endpoint.baseUrl.encodedPath) .httpOnly() if (endpoint.baseUrl.isHttps) builder.secure() diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt index 8efeade1..08da6c9f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt @@ -149,7 +149,12 @@ class ServerEndpoint private constructor( val converted = replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull() ?: return "" - val redacted = converted.newBuilder().query(null).build().toString() + val redacted = + converted + .newBuilder() + .query(null) + .build() + .toString() return replaceScheme(redacted, socketScheme) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt index ee259080..277162e6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt @@ -54,17 +54,39 @@ object BillingRepository { @Suppress("UNCHECKED_CAST") private fun anyToJsonElement(value: Any?): JsonElement = when (value) { - null -> JsonNull - is JsonElement -> value - is Map<*, *> -> + null -> { + JsonNull + } + + is JsonElement -> { + value + } + + is Map<*, *> -> { JsonObject( (value as Map).mapValues { (_, v) -> anyToJsonElement(v) }, ) - is List<*> -> JsonArray(value.map { anyToJsonElement(it) }) - is String -> JsonPrimitive(value) - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - else -> JsonPrimitive(value.toString()) + } + + is List<*> -> { + JsonArray(value.map { anyToJsonElement(it) }) + } + + is String -> { + JsonPrimitive(value) + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value) + } + + else -> { + JsonPrimitive(value.toString()) + } } suspend fun getSubscriptionState(): SubscriptionStateResponse? { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt index a014b9e5..a01a8388 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt @@ -39,7 +39,9 @@ data class AchievementsUiState( val toastMessage: String? = null, ) -class AchievementsViewModel : ViewModel(), ToastHost { +class AchievementsViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(AchievementsUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt index 6fe43a0f..a92a06b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt @@ -210,12 +210,12 @@ private fun AnalyticsContent( ModelRow(model = model) } } else if ((usage?.by_model ?: emptyList()).isNotEmpty()) { - itemsIndexed(usage?.by_model ?: emptyList(), key = { - index, - it, - -> - "usage_${it.model}_$index" - }) { _, entry -> + itemsIndexed( + usage?.by_model ?: emptyList(), + key = { index, it -> + "usage_${it.model}_$index" + }, + ) { _, entry -> ModelEntryRow(entry = entry) } } else { @@ -232,12 +232,12 @@ private fun AnalyticsContent( if (selectedSectionTab == 1) { if ((usage?.skills?.top_skills ?: emptyList()).isNotEmpty()) { - itemsIndexed(usage?.skills?.top_skills ?: emptyList(), key = { - index, - it, - -> - "skill_${it.skill}_$index" - }) { _, skill -> + itemsIndexed( + usage?.skills?.top_skills ?: emptyList(), + key = { index, it -> + "skill_${it.skill}_$index" + }, + ) { _, skill -> SkillRow(skill = skill) } } else { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt index c1f0d459..98671375 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt @@ -162,11 +162,21 @@ class BillingViewModel : ViewModel() { } val msg = when { - resp.requires_action == true && resp.recovery_url != null -> + resp.requires_action == true && resp.recovery_url != null -> { "Action required — open: ${resp.recovery_url}" - resp.payment_failed == true -> resp.message ?: "Payment failed" - resp.ok == true -> "Upgrade successful" - else -> resp.message ?: "Upgrade requested" + } + + resp.payment_failed == true -> { + resp.message ?: "Payment failed" + } + + resp.ok == true -> { + "Upgrade successful" + } + + else -> { + resp.message ?: "Upgrade requested" + } } _uiState.update { it.copy(isActionInFlight = false, actionMessage = msg) } load() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 52760662..0190317e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -59,7 +59,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalConfiguration @@ -78,7 +77,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.remote.OkHttpProvider @@ -2301,24 +2299,20 @@ private fun InlineAttachment( ) { val clickable = Modifier.clickable { onOpen(attachment) } if (attachment.isImage) { - // Image attachment — show as a rounded thumbnail; tap opens the viewer. - AsyncImage( + // Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation. + com.m57.hermescontrol.ui.chat.components.GifImageThumbnail( model = attachment.uri, contentDescription = attachment.name, - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { - onImageClick( - ImageViewerModel( - model = attachment.uri, - name = attachment.name, - mimeType = attachment.mimeType, - ), - ) - }, - contentScale = ContentScale.FillWidth, + isGif = attachment.isGif, + onClick = { + onImageClick( + ImageViewerModel( + model = attachment.uri, + name = attachment.name, + mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType, + ), + ) + }, ) } else { // Non-image file — show a card with file icon and name. Tapping fetches diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt index f16b0bd4..8d5886b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt @@ -33,7 +33,9 @@ object ImageBytesResolver { ) : Result /** Could not load the image; [message] is safe to surface to the user. */ - data class Error(val message: String) : Result + data class Error( + val message: String, + ) : Result } suspend fun resolve( @@ -102,7 +104,8 @@ object ImageBytesResolver { if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})") val bytes = resp.body.bytes() val mime = - resp.header("Content-Type") + resp + .header("Content-Type") ?.substringBefore(";") ?.ifBlank { fallbackMime } ?: fallbackMime diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt index 44ba3f52..d5c84cd3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt @@ -96,8 +96,9 @@ fun ImageViewerDialog( } } - is ImageBytesResolver.Result.Error -> + is ImageBytesResolver.Result.Error -> { String.format(loadFailedFmt, resolved.message) + } } withContext(Dispatchers.Main) { isBusy = false @@ -124,7 +125,9 @@ fun ImageViewerDialog( ) } - is ImageBytesResolver.Result.Error -> null + is ImageBytesResolver.Result.Error -> { + null + } } withContext(Dispatchers.Main) { isBusy = false diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 3f17f584..ad3920db 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -1,7 +1,6 @@ package com.m57.hermescontrol.ui.chat import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -269,35 +268,31 @@ fun MarkdownText( } is MdBlock.Image -> { - // AsyncImage is a Compose runtime composable — load it lazily - // behind remember so the parser stays pure and testable (the - // parseBlocks/parseInline unit tests never touch Compose). val model: Any = remember(block.uri) { resolveImageUrl(block.uri) } - androidx.compose.foundation.layout.Box( + val isGif = + remember(block.uri) { + block.uri.contains(".gif", ignoreCase = true) || + block.uri.startsWith("data:image/gif", ignoreCase = true) + } + Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable( - onClick = { - onImageClick( - ImageViewerModel( - model = block.uri, - name = block.alt, - mimeType = "image/*", - ), - ) - }, - ).padding(vertical = 4.dp), + .padding(vertical = 4.dp), ) { - coil.compose.AsyncImage( + com.m57.hermescontrol.ui.chat.components.GifImageThumbnail( model = model, contentDescription = block.alt.ifBlank { null }, - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)), - contentScale = androidx.compose.ui.layout.ContentScale.FillWidth, + isGif = isGif, + onClick = { + onImageClick( + ImageViewerModel( + model = block.uri, + name = block.alt, + mimeType = if (isGif) "image/gif" else "image/*", + ), + ) + }, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index ab067fa2..5c366d4b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -200,8 +200,8 @@ fun ComposerToolbar( * "xhigh", "max", "ultra", or null for model default. * @return Display string such as "None", "Low", "XHigh", "Ultra", etc. */ -private fun buildReasoningLabel(level: String?): String { - return when (level) { +private fun buildReasoningLabel(level: String?): String = + when (level) { null -> "Med" "none" -> "None" "minimal" -> "Minimal" @@ -213,4 +213,3 @@ private fun buildReasoningLabel(level: String?): String { "ultra" -> "Ultra" else -> level } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt new file mode 100644 index 00000000..a23b7d84 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt @@ -0,0 +1,136 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.graphics.drawable.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage + +@Composable +fun GifImageThumbnail( + model: Any, + contentDescription: String?, + isGif: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var isPlaying by remember { mutableStateOf(true) } + var animatableDrawable by remember { mutableStateOf(null) } + + val togglePlayPause: () -> Unit = { + val nextState = !isPlaying + isPlaying = nextState + animatableDrawable?.let { anim -> + if (nextState) { + anim.start() + } else { + anim.stop() + } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onClick() }, + ) { + AsyncImage( + model = model, + contentDescription = contentDescription, + onSuccess = { result -> + val drawable = result.result.drawable + if (drawable is Animatable) { + animatableDrawable = drawable + if (isPlaying) { + drawable.start() + } else { + drawable.stop() + } + } + }, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.FillWidth, + ) + + if (isGif) { + // Play / Pause toggle badge + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .background( + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f), + shape = RoundedCornerShape(16.dp), + ).clickable { togglePlayPause() } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Pause GIF" else "Play GIF", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = " GIF", + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + style = MaterialTheme.typography.labelSmall, + ) + } + } + + // Center overlay button when paused + if (!isPlaying) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .background( + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f), + shape = CircleShape, + ).clickable { togglePlayPause() } + .padding(12.dp), + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play GIF", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index 63511519..f161eb6b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -350,7 +350,10 @@ internal fun highlightSyntax(code: String): AnnotatedString = } } -private data class TokenPattern(val regex: Regex, val color: Color) +private data class TokenPattern( + val regex: Regex, + val color: Color, +) private fun copyToClipboard( context: Context, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt index 815b8100..4f9bd413 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt @@ -207,7 +207,11 @@ private fun shimmerBrush(): Brush { val base = MaterialTheme.colorScheme.surfaceVariant val highlight = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) val transition = rememberInfiniteTransition(label = "shimmer") - val widthPx = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.toPx() } + val widthPx = + with(LocalDensity.current) { + LocalConfiguration.current.screenWidthDp.dp + .toPx() + } val offset by transition.animateFloat( initialValue = -widthPx, targetValue = widthPx, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index bd7d5694..c3f67177 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -535,7 +535,9 @@ class ModelViewModel : return@launch } - is NetworkResult.Success -> activeProfileNameResResult.data.active + is NetworkResult.Success -> { + activeProfileNameResResult.data.active + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt index e0ce22b6..6a67378b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt @@ -24,7 +24,9 @@ data class ProcessesUiState( val sessionId: String? = null, ) -class ProcessesViewModel : ViewModel(), ToastHost { +class ProcessesViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(ProcessesUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index a8b915b1..3b15d019 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -53,7 +53,9 @@ data class SessionsUiState( val isSearchMode: Boolean get() = searchQuery.isNotBlank() } -class SessionsViewModel : ViewModel(), ToastHost { +class SessionsViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(SessionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() 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 24d7f891..d04340ce 100644 --- a/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt @@ -25,20 +25,28 @@ object LocaleContextWrapper { */ fun localeForCode(code: String): Locale = when { - code.isEmpty() || code == SYSTEM_LANGUAGE -> Locale.getDefault() + code.isEmpty() || code == SYSTEM_LANGUAGE -> { + Locale.getDefault() + } + code.contains("-r", ignoreCase = true) -> { val parts = code.split("-r", ignoreCase = true) Locale(parts[0], parts.getOrElse(1) { "" }) } + code.contains("-") -> { val parts = code.split("-") Locale(parts[0], parts.getOrElse(1) { "" }) } + code.contains("_") -> { val parts = code.split("_") Locale(parts[0], parts.getOrElse(1) { "" }) } - else -> Locale(code) + + else -> { + Locale(code) + } } /** diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt index 13d14ac0..75ccc585 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt @@ -92,18 +92,17 @@ class ChatMessageMapperTest { assertNull(createEntityWithToolStatus(null).toUiModel().toolStatus) } - private fun createEntityWithRole(role: String): ChatMessageEntity { - return ChatMessageEntity( + private fun createEntityWithRole(role: String): ChatMessageEntity = + ChatMessageEntity( id = "id", sessionId = "session", role = role, content = "content", timestamp = 0L, ) - } - private fun createEntityWithToolStatus(status: String?): ChatMessageEntity { - return ChatMessageEntity( + private fun createEntityWithToolStatus(status: String?): ChatMessageEntity = + ChatMessageEntity( id = "id", sessionId = "session", role = "TOOL", @@ -111,7 +110,6 @@ class ChatMessageMapperTest { timestamp = 0L, toolStatus = status, ) - } @Test fun entityToUiModelCarriesReasoningText() { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/CallExtTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/CallExtTest.kt index fc0f62ec..bc0f1427 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/CallExtTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/CallExtTest.kt @@ -22,7 +22,8 @@ class CallExtTest { val call = mockk() val request = Request.Builder().url("http://localhost").build() val response = - Response.Builder() + Response + .Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(200) diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt index 02487eee..2d7ac3c4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt @@ -26,9 +26,7 @@ class CookieManagerTest { return jar } - private fun ep(host: String): ServerEndpoint { - return ServerEndpoint.fromLegacy(host, 9119) - } + private fun ep(host: String): ServerEndpoint = ServerEndpoint.fromLegacy(host, 9119) private fun httpsEp( host: String, 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 938a9b55..43b87ac8 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 @@ -53,7 +53,8 @@ class HermesWsClientTest { every { AuthManager.serverStore } returns mockk().also { every { it.getLatestState() } returns - com.m57.hermescontrol.data.config.ServerStoreState() + com.m57.hermescontrol.data.config + .ServerStoreState() } // Issue #470: clients are built through OkHttpProvider, which now @@ -496,7 +497,8 @@ class HermesWsClientTest { every { AuthManager.serverStore } returns mockk().also { every { it.getLatestState() } returns - com.m57.hermescontrol.data.config.ServerStoreState(wsAuthParam = "ticket") + com.m57.hermescontrol.data.config + .ServerStoreState(wsAuthParam = "ticket") } // No bare-name session cookie present (the prefixed one is server-side). every { AuthManager.getSessionCookie() } returns null 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 938065cf..e073cefe 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 @@ -2032,6 +2032,9 @@ class ChatViewModelTest { advanceUntilIdle() assertNotNull(vm.uiState.value.openError) - assertTrue(vm.uiState.value.openError!!.contains("missing.pdf")) + assertTrue( + vm.uiState.value.openError!! + .contains("missing.pdf"), + ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 2f7ab71b..816492b0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -222,4 +222,26 @@ class MarkdownTextFeatureTest { val blocks = parseBlocks(md) assertTrue("should remain a paragraph", blocks.single() is MdBlock.Paragraph) } + + // 13. Attachment isGif check (issue #721) + @Test + fun testAttachment_isGif() { + val gifAttachment = + com.m57.hermescontrol.data.model.Attachment( + uri = "content://media/1.gif", + name = "test.gif", + mimeType = "image/gif", + ) + assertTrue(gifAttachment.isImage) + assertTrue(gifAttachment.isGif) + + val nonGifAttachment = + com.m57.hermescontrol.data.model.Attachment( + uri = "content://media/1.jpg", + name = "test.jpg", + mimeType = "image/jpeg", + ) + assertTrue(nonGifAttachment.isImage) + assertFalse(nonGifAttachment.isGif) + } } 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 d043f4fd..67c5f012 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 @@ -191,7 +191,9 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/help") advanceUntilIdle() - val last = vm.uiState.value.messages.lastOrNull() + val last = + vm.uiState.value.messages + .lastOrNull() assertEquals("⚠️ /help: session busy — /interrupt first", last?.content) } @@ -232,7 +234,9 @@ class SlashCommandDispatchRpcTest { assertEquals(sessionId, params["session_id"]) // And the slash.exec output must be surfaced to the user. - val last = vm.uiState.value.messages.lastOrNull() + val last = + vm.uiState.value.messages + .lastOrNull() assertEquals("STATUS: gateway reachable", last?.content) } @@ -254,7 +258,9 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/redraw") advanceUntilIdle() - val last = vm.uiState.value.messages.lastOrNull() + val last = + vm.uiState.value.messages + .lastOrNull() assertTrue( "expected a 'not supported on mobile' message, got: ${last?.content}", last?.content?.contains("not supported on mobile") == true, @@ -292,7 +298,9 @@ class SlashCommandDispatchRpcTest { advanceUntilIdle() // Both RPCs fail -> the secondary slash.exec error must surface. - val last = vm.uiState.value.messages.lastOrNull() + val last = + vm.uiState.value.messages + .lastOrNull() assertEquals("⚠️ /status: slash worker start failed: boom", last?.content) } @@ -327,6 +335,11 @@ class SlashCommandDispatchRpcTest { // must NOT append an assistant bubble — so the last message is still // the user's own command, and only one message was added. assertEquals(before + 1, vm.uiState.value.messages.size) - assertEquals("/status", vm.uiState.value.messages.lastOrNull()?.content) + assertEquals( + "/status", + vm.uiState.value.messages + .lastOrNull() + ?.content, + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 35eff81a..2b55d661 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,7 @@ sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher # Image loading coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }