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
This commit is contained in:
M57 authored and GitHub committed 2026-07-27 18:32:45 +03:00
1 parent c7f7d1dd45
commit 5c1492a80e
32 files changed
+370 -118

No files matched your search

+1
View File
@@ -166,6 +166,7 @@ dependencies {
// Image loading // Image loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.gif)
// Encrypted storage // Encrypted storage
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
@@ -1,8 +1,11 @@
package com.m57.hermescontrol package com.m57.hermescontrol
import android.app.Application import android.app.Application
import android.os.Build
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
@@ -28,6 +31,12 @@ class HermesControlApp :
ImageLoader ImageLoader
.Builder(this) .Builder(this)
.okHttpClient(OkHttpProvider.base) .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() .build()
} }
@@ -105,10 +105,12 @@ class ServerStoreMigration(
profile.copy( profile.copy(
baseUrl = baseUrl =
profile.baseUrl profile.baseUrl
?: ServerEndpoint.fromLegacy( ?: ServerEndpoint
profile.host, .fromLegacy(
profile.port, profile.host,
).baseUrl.toString(), profile.port,
).baseUrl
.toString(),
) )
}, },
selectedProfileId = selectedProfileId, selectedProfileId = selectedProfileId,
@@ -19,19 +19,23 @@ class ServerUrlMigration : DataMigration<ServerStoreState> {
state.copy( state.copy(
baseUrl = baseUrl =
normalizeExisting(state.baseUrl) normalizeExisting(state.baseUrl)
?: ServerEndpoint.fromLegacy( ?: ServerEndpoint
state.host, .fromLegacy(
state.port, state.host,
).baseUrl.toString(), state.port,
).baseUrl
.toString(),
connectionProfiles = connectionProfiles =
state.connectionProfiles.map { profile -> state.connectionProfiles.map { profile ->
profile.copy( profile.copy(
baseUrl = baseUrl =
normalizeExisting(profile.baseUrl) normalizeExisting(profile.baseUrl)
?: ServerEndpoint.fromLegacy( ?: ServerEndpoint
profile.host, .fromLegacy(
profile.port, profile.host,
).baseUrl.toString(), profile.port,
).baseUrl
.toString(),
) )
}, },
) )
@@ -427,10 +427,12 @@ object AuthManager {
fun setBaseUrl(baseUrl: String) { fun setBaseUrl(baseUrl: String) {
val normalized = val normalized =
ServerEndpoint.parse( ServerEndpoint
baseUrl, .parse(
CleartextPolicy.ALLOW_WITH_WARNING, baseUrl,
).baseUrl.toString() CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
val selectedId = val selectedId =
getSelectedProfileId() ?: run { getSelectedProfileId() ?: run {
ensureDefaultSelected() ensureDefaultSelected()
@@ -28,6 +28,13 @@ data class Attachment(
val isImage: Boolean val isImage: Boolean
get() = mimeType.startsWith("image/") 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 val isGateway: Boolean
get() = source == AttachmentSource.GATEWAY get() = source == AttachmentSource.GATEWAY
@@ -81,8 +81,7 @@ object CookieManager {
.expiresAt( .expiresAt(
System.currentTimeMillis() + System.currentTimeMillis() +
10L * 365 * 24 * 60 * 60 * 1000, 10L * 365 * 24 * 60 * 60 * 1000,
) ).hostOnlyDomain(endpoint.baseUrl.host)
.hostOnlyDomain(endpoint.baseUrl.host)
.path(endpoint.baseUrl.encodedPath) .path(endpoint.baseUrl.encodedPath)
.httpOnly() .httpOnly()
if (endpoint.baseUrl.isHttps) builder.secure() if (endpoint.baseUrl.isHttps) builder.secure()
@@ -149,7 +149,12 @@ class ServerEndpoint private constructor(
val converted = val converted =
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull() replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
?: return "<invalid-websocket-url>" ?: return "<invalid-websocket-url>"
val redacted = converted.newBuilder().query(null).build().toString() val redacted =
converted
.newBuilder()
.query(null)
.build()
.toString()
return replaceScheme(redacted, socketScheme) return replaceScheme(redacted, socketScheme)
} }
@@ -54,17 +54,39 @@ object BillingRepository {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun anyToJsonElement(value: Any?): JsonElement = private fun anyToJsonElement(value: Any?): JsonElement =
when (value) { when (value) {
null -> JsonNull null -> {
is JsonElement -> value JsonNull
is Map<*, *> -> }
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject( JsonObject(
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) }, (value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
) )
is List<*> -> JsonArray(value.map { anyToJsonElement(it) }) }
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value) is List<*> -> {
is Number -> JsonPrimitive(value) JsonArray(value.map { anyToJsonElement(it) })
else -> JsonPrimitive(value.toString()) }
is String -> {
JsonPrimitive(value)
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
} }
suspend fun getSubscriptionState(): SubscriptionStateResponse? { suspend fun getSubscriptionState(): SubscriptionStateResponse? {
@@ -39,7 +39,9 @@ data class AchievementsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class AchievementsViewModel : ViewModel(), ToastHost { class AchievementsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(AchievementsUiState()) private val _uiState = MutableStateFlow(AchievementsUiState())
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow() val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
@@ -210,12 +210,12 @@ private fun AnalyticsContent(
ModelRow(model = model) ModelRow(model = model)
} }
} else if ((usage?.by_model ?: emptyList()).isNotEmpty()) { } else if ((usage?.by_model ?: emptyList()).isNotEmpty()) {
itemsIndexed(usage?.by_model ?: emptyList(), key = { itemsIndexed(
index, usage?.by_model ?: emptyList(),
it, key = { index, it ->
-> "usage_${it.model}_$index"
"usage_${it.model}_$index" },
}) { _, entry -> ) { _, entry ->
ModelEntryRow(entry = entry) ModelEntryRow(entry = entry)
} }
} else { } else {
@@ -232,12 +232,12 @@ private fun AnalyticsContent(
if (selectedSectionTab == 1) { if (selectedSectionTab == 1) {
if ((usage?.skills?.top_skills ?: emptyList()).isNotEmpty()) { if ((usage?.skills?.top_skills ?: emptyList()).isNotEmpty()) {
itemsIndexed(usage?.skills?.top_skills ?: emptyList(), key = { itemsIndexed(
index, usage?.skills?.top_skills ?: emptyList(),
it, key = { index, it ->
-> "skill_${it.skill}_$index"
"skill_${it.skill}_$index" },
}) { _, skill -> ) { _, skill ->
SkillRow(skill = skill) SkillRow(skill = skill)
} }
} else { } else {
@@ -162,11 +162,21 @@ class BillingViewModel : ViewModel() {
} }
val msg = val msg =
when { when {
resp.requires_action == true && resp.recovery_url != null -> resp.requires_action == true && resp.recovery_url != null -> {
"Action required — open: ${resp.recovery_url}" "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) } _uiState.update { it.copy(isActionInFlight = false, actionMessage = msg) }
load() load()
@@ -59,7 +59,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration 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.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
@@ -2301,24 +2299,20 @@ private fun InlineAttachment(
) { ) {
val clickable = Modifier.clickable { onOpen(attachment) } val clickable = Modifier.clickable { onOpen(attachment) }
if (attachment.isImage) { if (attachment.isImage) {
// Image attachment — show as a rounded thumbnail; tap opens the viewer. // Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation.
AsyncImage( com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
model = attachment.uri, model = attachment.uri,
contentDescription = attachment.name, contentDescription = attachment.name,
modifier = isGif = attachment.isGif,
Modifier onClick = {
.fillMaxWidth() onImageClick(
.clip(RoundedCornerShape(12.dp)) ImageViewerModel(
.clickable { model = attachment.uri,
onImageClick( name = attachment.name,
ImageViewerModel( mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType,
model = attachment.uri, ),
name = attachment.name, )
mimeType = attachment.mimeType, },
),
)
},
contentScale = ContentScale.FillWidth,
) )
} else { } else {
// Non-image file — show a card with file icon and name. Tapping fetches // Non-image file — show a card with file icon and name. Tapping fetches
@@ -33,7 +33,9 @@ object ImageBytesResolver {
) : Result ) : Result
/** Could not load the image; [message] is safe to surface to the user. */ /** 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( suspend fun resolve(
@@ -102,7 +104,8 @@ object ImageBytesResolver {
if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})") if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})")
val bytes = resp.body.bytes() val bytes = resp.body.bytes()
val mime = val mime =
resp.header("Content-Type") resp
.header("Content-Type")
?.substringBefore(";") ?.substringBefore(";")
?.ifBlank { fallbackMime } ?.ifBlank { fallbackMime }
?: fallbackMime ?: fallbackMime
@@ -96,8 +96,9 @@ fun ImageViewerDialog(
} }
} }
is ImageBytesResolver.Result.Error -> is ImageBytesResolver.Result.Error -> {
String.format(loadFailedFmt, resolved.message) String.format(loadFailedFmt, resolved.message)
}
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
isBusy = false isBusy = false
@@ -124,7 +125,9 @@ fun ImageViewerDialog(
) )
} }
is ImageBytesResolver.Result.Error -> null is ImageBytesResolver.Result.Error -> {
null
}
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
isBusy = false isBusy = false
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -269,35 +268,31 @@ fun MarkdownText(
} }
is MdBlock.Image -> { 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) } 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 =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .padding(vertical = 4.dp),
.clickable(
onClick = {
onImageClick(
ImageViewerModel(
model = block.uri,
name = block.alt,
mimeType = "image/*",
),
)
},
).padding(vertical = 4.dp),
) { ) {
coil.compose.AsyncImage( com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
model = model, model = model,
contentDescription = block.alt.ifBlank { null }, contentDescription = block.alt.ifBlank { null },
modifier = isGif = isGif,
Modifier onClick = {
.fillMaxWidth() onImageClick(
.clip(RoundedCornerShape(12.dp)), ImageViewerModel(
contentScale = androidx.compose.ui.layout.ContentScale.FillWidth, model = block.uri,
name = block.alt,
mimeType = if (isGif) "image/gif" else "image/*",
),
)
},
) )
} }
} }
@@ -200,8 +200,8 @@ fun ComposerToolbar(
* "xhigh", "max", "ultra", or null for model default. * "xhigh", "max", "ultra", or null for model default.
* @return Display string such as "None", "Low", "XHigh", "Ultra", etc. * @return Display string such as "None", "Low", "XHigh", "Ultra", etc.
*/ */
private fun buildReasoningLabel(level: String?): String { private fun buildReasoningLabel(level: String?): String =
return when (level) { when (level) {
null -> "Med" null -> "Med"
"none" -> "None" "none" -> "None"
"minimal" -> "Minimal" "minimal" -> "Minimal"
@@ -213,4 +213,3 @@ private fun buildReasoningLabel(level: String?): String {
"ultra" -> "Ultra" "ultra" -> "Ultra"
else -> level else -> level
} }
}
@@ -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<Animatable?>(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,
)
}
}
}
}
}
@@ -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( private fun copyToClipboard(
context: Context, context: Context,
@@ -207,7 +207,11 @@ private fun shimmerBrush(): Brush {
val base = MaterialTheme.colorScheme.surfaceVariant val base = MaterialTheme.colorScheme.surfaceVariant
val highlight = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) val highlight = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
val transition = rememberInfiniteTransition(label = "shimmer") 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( val offset by transition.animateFloat(
initialValue = -widthPx, initialValue = -widthPx,
targetValue = widthPx, targetValue = widthPx,
@@ -535,7 +535,9 @@ class ModelViewModel :
return@launch return@launch
} }
is NetworkResult.Success -> activeProfileNameResResult.data.active is NetworkResult.Success -> {
activeProfileNameResResult.data.active
}
} }
} }
@@ -24,7 +24,9 @@ data class ProcessesUiState(
val sessionId: String? = null, val sessionId: String? = null,
) )
class ProcessesViewModel : ViewModel(), ToastHost { class ProcessesViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ProcessesUiState()) private val _uiState = MutableStateFlow(ProcessesUiState())
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow() val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
@@ -53,7 +53,9 @@ data class SessionsUiState(
val isSearchMode: Boolean get() = searchQuery.isNotBlank() val isSearchMode: Boolean get() = searchQuery.isNotBlank()
} }
class SessionsViewModel : ViewModel(), ToastHost { class SessionsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(SessionsUiState()) private val _uiState = MutableStateFlow(SessionsUiState())
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
@@ -25,20 +25,28 @@ object LocaleContextWrapper {
*/ */
fun localeForCode(code: String): Locale = fun localeForCode(code: String): Locale =
when { when {
code.isEmpty() || code == SYSTEM_LANGUAGE -> Locale.getDefault() code.isEmpty() || code == SYSTEM_LANGUAGE -> {
Locale.getDefault()
}
code.contains("-r", ignoreCase = true) -> { code.contains("-r", ignoreCase = true) -> {
val parts = code.split("-r", ignoreCase = true) val parts = code.split("-r", ignoreCase = true)
Locale(parts[0], parts.getOrElse(1) { "" }) Locale(parts[0], parts.getOrElse(1) { "" })
} }
code.contains("-") -> { code.contains("-") -> {
val parts = code.split("-") val parts = code.split("-")
Locale(parts[0], parts.getOrElse(1) { "" }) Locale(parts[0], parts.getOrElse(1) { "" })
} }
code.contains("_") -> { code.contains("_") -> {
val parts = code.split("_") val parts = code.split("_")
Locale(parts[0], parts.getOrElse(1) { "" }) Locale(parts[0], parts.getOrElse(1) { "" })
} }
else -> Locale(code)
else -> {
Locale(code)
}
} }
/** /**
@@ -92,18 +92,17 @@ class ChatMessageMapperTest {
assertNull(createEntityWithToolStatus(null).toUiModel().toolStatus) assertNull(createEntityWithToolStatus(null).toUiModel().toolStatus)
} }
private fun createEntityWithRole(role: String): ChatMessageEntity { private fun createEntityWithRole(role: String): ChatMessageEntity =
return ChatMessageEntity( ChatMessageEntity(
id = "id", id = "id",
sessionId = "session", sessionId = "session",
role = role, role = role,
content = "content", content = "content",
timestamp = 0L, timestamp = 0L,
) )
}
private fun createEntityWithToolStatus(status: String?): ChatMessageEntity { private fun createEntityWithToolStatus(status: String?): ChatMessageEntity =
return ChatMessageEntity( ChatMessageEntity(
id = "id", id = "id",
sessionId = "session", sessionId = "session",
role = "TOOL", role = "TOOL",
@@ -111,7 +110,6 @@ class ChatMessageMapperTest {
timestamp = 0L, timestamp = 0L,
toolStatus = status, toolStatus = status,
) )
}
@Test @Test
fun entityToUiModelCarriesReasoningText() { fun entityToUiModelCarriesReasoningText() {
@@ -22,7 +22,8 @@ class CallExtTest {
val call = mockk<Call>() val call = mockk<Call>()
val request = Request.Builder().url("http://localhost").build() val request = Request.Builder().url("http://localhost").build()
val response = val response =
Response.Builder() Response
.Builder()
.request(request) .request(request)
.protocol(Protocol.HTTP_1_1) .protocol(Protocol.HTTP_1_1)
.code(200) .code(200)
@@ -26,9 +26,7 @@ class CookieManagerTest {
return jar return jar
} }
private fun ep(host: String): ServerEndpoint { private fun ep(host: String): ServerEndpoint = ServerEndpoint.fromLegacy(host, 9119)
return ServerEndpoint.fromLegacy(host, 9119)
}
private fun httpsEp( private fun httpsEp(
host: String, host: String,
@@ -53,7 +53,8 @@ class HermesWsClientTest {
every { AuthManager.serverStore } returns every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also { mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns 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 // Issue #470: clients are built through OkHttpProvider, which now
@@ -496,7 +497,8 @@ class HermesWsClientTest {
every { AuthManager.serverStore } returns every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also { mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns 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). // No bare-name session cookie present (the prefixed one is server-side).
every { AuthManager.getSessionCookie() } returns null every { AuthManager.getSessionCookie() } returns null
@@ -2032,6 +2032,9 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertNotNull(vm.uiState.value.openError) assertNotNull(vm.uiState.value.openError)
assertTrue(vm.uiState.value.openError!!.contains("missing.pdf")) assertTrue(
vm.uiState.value.openError!!
.contains("missing.pdf"),
)
} }
} }
@@ -222,4 +222,26 @@ class MarkdownTextFeatureTest {
val blocks = parseBlocks(md) val blocks = parseBlocks(md)
assertTrue("should remain a paragraph", blocks.single() is MdBlock.Paragraph) 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)
}
} }
@@ -191,7 +191,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/help") vm.sendMessage("/help")
advanceUntilIdle() advanceUntilIdle()
val last = vm.uiState.value.messages.lastOrNull() val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("⚠️ /help: session busy — /interrupt first", last?.content) assertEquals("⚠️ /help: session busy — /interrupt first", last?.content)
} }
@@ -232,7 +234,9 @@ class SlashCommandDispatchRpcTest {
assertEquals(sessionId, params["session_id"]) assertEquals(sessionId, params["session_id"])
// And the slash.exec output must be surfaced to the user. // 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) assertEquals("STATUS: gateway reachable", last?.content)
} }
@@ -254,7 +258,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/redraw") vm.sendMessage("/redraw")
advanceUntilIdle() advanceUntilIdle()
val last = vm.uiState.value.messages.lastOrNull() val last =
vm.uiState.value.messages
.lastOrNull()
assertTrue( assertTrue(
"expected a 'not supported on mobile' message, got: ${last?.content}", "expected a 'not supported on mobile' message, got: ${last?.content}",
last?.content?.contains("not supported on mobile") == true, last?.content?.contains("not supported on mobile") == true,
@@ -292,7 +298,9 @@ class SlashCommandDispatchRpcTest {
advanceUntilIdle() advanceUntilIdle()
// Both RPCs fail -> the secondary slash.exec error must surface. // 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) 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 // must NOT append an assistant bubble — so the last message is still
// the user's own command, and only one message was added. // the user's own command, and only one message was added.
assertEquals(before + 1, vm.uiState.value.messages.size) assertEquals(before + 1, vm.uiState.value.messages.size)
assertEquals("/status", vm.uiState.value.messages.lastOrNull()?.content) assertEquals(
"/status",
vm.uiState.value.messages
.lastOrNull()
?.content,
)
} }
} }
+1
View File
@@ -81,6 +81,7 @@ sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher
# Image loading # Image loading
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }