feat(chat): save delivered files with native picker (#803)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-04 20:47:55 +03:00
1 parent 509a018434
commit 19c053b3fa
9 files changed
+363 -41

No files matched your search

@@ -0,0 +1,28 @@
package com.m57.hermescontrol.ui.chat
import android.app.Activity
import android.net.Uri
import io.mockk.mockk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class AttachmentSavePolicyTest {
@Test
fun `save result accepts a destination only for RESULT_OK`() {
val destination = mockk<Uri>()
assertSame(destination, acceptedSaveDestination(Activity.RESULT_OK, destination))
assertNull(acceptedSaveDestination(Activity.RESULT_CANCELED, destination))
assertNull(acceptedSaveDestination(Activity.RESULT_OK, null))
}
@Test
fun `picker cannot start while picker or save is pending`() {
assertTrue(canStartAttachmentSave(null, null))
assertFalse(canStartAttachmentSave("/tmp/picker.pdf", null))
assertFalse(canStartAttachmentSave(null, "/tmp/saving.pdf"))
}
}
@@ -49,6 +49,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -2882,6 +2883,129 @@ class ChatViewModelTest {
cacheDir.deleteRecursively()
}
@Test
fun `saveAttachment writes gateway file to selected document`() =
runTest {
mockkObject(GatewayFileClient)
val bytes = "downloaded".toByteArray()
coEvery { GatewayFileClient.fetch("/tmp/note.txt") } returns
GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", bytes))
val resolver = mockk<android.content.ContentResolver>()
val output = java.io.ByteArrayOutputStream()
val destination = mockk<android.net.Uri>()
every { app.contentResolver } returns resolver
every { resolver.openOutputStream(destination, "wt") } returns output
val vm = createViewModel()
val attachment =
Attachment(
uri = "unused",
name = "note.txt",
mimeType = "text/plain",
gatewayUrl = "https://gw/api/files/download?path=%2Ftmp%2Fnote.txt&token=t",
source = AttachmentSource.GATEWAY,
)
vm.saveAttachment(attachment, destination)
advanceUntilIdle()
assertArrayEquals(bytes, output.toByteArray())
assertNull(vm.uiState.value.savingAttachmentPath)
assertEquals("Saved note.txt", vm.uiState.value.openError)
}
@Test
fun `saveAttachment never deletes the selected document when download fails`() =
runTest {
mockkObject(GatewayFileClient)
val resolver = mockk<android.content.ContentResolver>(relaxed = true)
val destination = mockk<android.net.Uri>()
every { app.contentResolver } returns resolver
coEvery {
GatewayFileClient.fetch("/tmp/missing.pdf")
} returns GatewayFileResult.NotFound
val (viewModel, _) = createViewModelWithSession()
viewModel.saveAttachment(
attachment =
Attachment(
uri = "gateway:/tmp/missing.pdf",
name = "missing.pdf",
mimeType = "application/pdf",
size = 0,
source = AttachmentSource.GATEWAY,
gatewayUrl = "https://host/files/download?path=%2Ftmp%2Fmissing.pdf",
),
destination = destination,
)
advanceUntilIdle()
verify(exactly = 0) { resolver.delete(any(), any(), any()) }
assertNull(viewModel.uiState.value.savingAttachmentPath)
}
@Test
fun `saveAttachment never deletes the selected document when writing fails`() =
runTest {
mockkObject(GatewayFileClient)
val resolver = mockk<android.content.ContentResolver>(relaxed = true)
val destination = mockk<android.net.Uri>()
every { app.contentResolver } returns resolver
every { resolver.openOutputStream(destination, "wt") } throws IllegalStateException("write failed")
coEvery { GatewayFileClient.fetch("/tmp/report.pdf") } returns
GatewayFileResult.Success(GatewayFile("report.pdf", "application/pdf", byteArrayOf(1)))
val viewModel = createViewModel()
viewModel.saveAttachment(
Attachment(
uri = "gateway:/tmp/report.pdf",
name = "report.pdf",
mimeType = "application/pdf",
source = AttachmentSource.GATEWAY,
),
destination,
)
advanceUntilIdle()
verify(exactly = 0) { resolver.delete(any(), any(), any()) }
assertNull(viewModel.uiState.value.savingAttachmentPath)
assertTrue(viewModel.uiState.value.openError.orEmpty().startsWith("Could not save"))
}
@Test
fun `saveAttachment ignores overlapping saves`() =
runTest {
mockkObject(GatewayFileClient)
val firstResult = CompletableDeferred<GatewayFileResult>()
val fetchedPaths = mutableListOf<String>()
coEvery { GatewayFileClient.fetch(capture(fetchedPaths)) } coAnswers { firstResult.await() }
val resolver = mockk<android.content.ContentResolver>(relaxed = true)
every { app.contentResolver } returns resolver
val viewModel = createViewModel()
viewModel.saveAttachment(
Attachment("gateway:/tmp/first.pdf", "first.pdf", "application/pdf", source = AttachmentSource.GATEWAY),
mockk(),
)
runCurrent()
viewModel.saveAttachment(
Attachment(
"gateway:/tmp/second.pdf",
"second.pdf",
"application/pdf",
source = AttachmentSource.GATEWAY,
),
mockk(),
)
assertEquals("/tmp/first.pdf", viewModel.uiState.value.savingAttachmentPath)
assertEquals(listOf("/tmp/first.pdf"), fetchedPaths)
firstResult.complete(GatewayFileResult.NotFound)
advanceUntilIdle()
assertNull(viewModel.uiState.value.savingAttachmentPath)
}
@Test
fun `openAttachment GATEWAY not-found surfaces openError`() =
runTest {
@@ -27,6 +27,7 @@ class ImageBytesResolverTest {
@Test
fun `extensionForMime maps known types`() {
assertEquals("png", ImageBytesResolver.extensionForMime("image/png"))
assertEquals("png", ImageBytesResolver.extensionForMime(" image/png; charset=binary"))
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg"))
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG"))
assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif"))