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
implementation(libs.coil.compose)
implementation(libs.coil.gif)
// Encrypted storage
implementation(libs.androidx.security.crypto)
@@ -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()
}
@@ -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,
@@ -19,19 +19,23 @@ class ServerUrlMigration : DataMigration<ServerStoreState> {
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(),
)
},
)
@@ -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()
@@ -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
@@ -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()
@@ -149,7 +149,12 @@ class ServerEndpoint private constructor(
val converted =
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
?: return "<invalid-websocket-url>"
val redacted = converted.newBuilder().query(null).build().toString()
val redacted =
converted
.newBuilder()
.query(null)
.build()
.toString()
return replaceScheme(redacted, socketScheme)
}
@@ -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<String, Any?>).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? {
@@ -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<AchievementsUiState> = _uiState.asStateFlow()
@@ -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 {
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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/*",
),
)
},
)
}
}
@@ -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
}
}
@@ -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(
context: Context,
@@ -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,
@@ -535,7 +535,9 @@ class ModelViewModel :
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,
)
class ProcessesViewModel : ViewModel(), ToastHost {
class ProcessesViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ProcessesUiState())
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
@@ -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<SessionsUiState> = _uiState.asStateFlow()
@@ -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)
}
}
/**
@@ -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() {
@@ -22,7 +22,8 @@ class CallExtTest {
val call = mockk<Call>()
val request = Request.Builder().url("http://localhost").build()
val response =
Response.Builder()
Response
.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
@@ -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,
@@ -53,7 +53,8 @@ class HermesWsClientTest {
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().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<com.m57.hermescontrol.data.config.ServerStore>().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
@@ -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"),
)
}
}
@@ -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)
}
}
@@ -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,
)
}
}
+1
View File
@@ -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" }