* feat(chat): full-screen image viewer (issue #723) Tap any chat image (markdown , inline attachment thumbnail, or bubble image) to open a full-screen viewer: - pinch-zoom + pan via native detectTransformGestures - downswipe-to-dismiss at min zoom - Save -> device Downloads (MediaStore), Share -> Android share sheet (both fully device-local; never sent back to the Hermes server) Plumbing: onImageClick(ImageViewerModel) flows MarkdownText -> ChatBubble/InlineAttachment -> ChatMessageList -> ChatScreen (state + ImageViewerDialog). ImageBytesResolver reuses the existing authed OkHttpProvider path for gateway http(s) sources; data:/content:// handled locally. No new dependencies. Closes #723 * fix(chat): resolve strings at composable scope to satisfy LocalContextGetResourceValueCall lint Lint forbids reading resource *values* from LocalContext.current inside coroutine lambdas. Hoist all viewer strings via stringResource() at composable scope and reuse them in onSave/onShare. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
a1155e8015
commit
f45a68b0db
11 files changed
+651
-4
No files matched your search
@@ -0,0 +1,90 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Base64
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ImageBytesResolverTest {
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// resolve() runs on Dispatchers.IO; bind the main dispatcher for tests.
|
||||
Dispatchers.setMain(StandardTestDispatcher())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extensionForMime maps known types`() {
|
||||
assertEquals("png", ImageBytesResolver.extensionForMime("image/png"))
|
||||
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg"))
|
||||
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG"))
|
||||
assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif"))
|
||||
assertEquals("webp", ImageBytesResolver.extensionForMime("image/webp"))
|
||||
assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic"))
|
||||
assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml"))
|
||||
assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data URL base64 decodes to bytes with mime and extension`() =
|
||||
runTest {
|
||||
val raw = "hello-image".toByteArray()
|
||||
val b64 = Base64.getEncoder().encodeToString(raw)
|
||||
val model = "data:image/png;base64,$b64"
|
||||
|
||||
val result = ImageBytesResolver.resolve(context, model, "image/*")
|
||||
|
||||
assertTrue(result is ImageBytesResolver.Result.Bytes)
|
||||
result as ImageBytesResolver.Result.Bytes
|
||||
assertArrayEquals(raw, result.bytes)
|
||||
assertEquals("image/png", result.mimeType)
|
||||
assertEquals("png", result.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data URL falls back to fallbackMime when meta omitted`() =
|
||||
runTest {
|
||||
val raw = byteArrayOf(1, 2, 3, 4)
|
||||
val b64 = Base64.getEncoder().encodeToString(raw)
|
||||
val model = "data:;base64,$b64"
|
||||
|
||||
val result = ImageBytesResolver.resolve(context, model, "image/webp")
|
||||
|
||||
assertTrue(result is ImageBytesResolver.Result.Bytes)
|
||||
result as ImageBytesResolver.Result.Bytes
|
||||
assertEquals("image/webp", result.mimeType)
|
||||
assertEquals("webp", result.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed data URL (no comma) returns Error`() =
|
||||
runTest {
|
||||
val result = ImageBytesResolver.resolve(context, "data:image/png;base64XXXX", "image/*")
|
||||
assertTrue(result is ImageBytesResolver.Result.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-base64 data URL returns Error`() =
|
||||
runTest {
|
||||
val result = ImageBytesResolver.resolve(context, "data:image/png,notbase64", "image/*")
|
||||
assertTrue(result is ImageBytesResolver.Result.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported model source returns Error`() =
|
||||
runTest {
|
||||
val result = ImageBytesResolver.resolve(context, "file:///sdcard/x.png", "image/*")
|
||||
assertTrue(result is ImageBytesResolver.Result.Error)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user