fix: stream gateway downloads to disk instead of slurping into RAM (#914)

* fix: stream gateway downloads to disk instead of slurping into RAM

Opening or saving a ~100MB MEDIA file OOMs the app: fetch() read the
whole response body into a single ByteArray (peak heap ~2x file size)
before writing it to cache. A 102MB docx (~97MiB) passed the server's
100MiB cap but blew the phone heap (192MiB growth limit) on both open
and save.

fetch() now streams the body to a cache file in 64KiB chunks and
GatewayFile carries the written file instead of bytes; save copies the
cache file to the destination chunked. Cancellation-safe (partial file
deleted, CancellationException never swallowed), UUID-named cache
files, 24h sweep.

Closes #913

* fix: stub app.cacheDir/applicationContext in attachment tests

The streaming fetch builds File(cacheDir, ...) from the application
context's cacheDir; the affected tests never stubbed it, so the mock
returned a bogus File (null path) and java.io.File's constructor NPE'd
before fetch was even called. Stub cacheDir + applicationContext
(returning app itself, matching the existing success-path test) in the
five open/save tests. copyChunked is also stubbed to copy for real in
the save-writes test since mockkObject mocks the whole object.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-15 23:05:10 +03:00
1 parent dadcc3e588
commit b653903fa8
4 files changed
+225 -55

No files matched your search

@@ -5,6 +5,12 @@ import com.m57.hermescontrol.data.config.ServerStoreState
import com.m57.hermescontrol.data.local.AuthManager
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -101,4 +107,50 @@ class GatewayFileClientTest {
assertEquals("report.pdf", GatewayFileClient.parseFilename("attachment; filename=\"report.pdf\""))
assertEquals("a b.png", GatewayFileClient.parseFilename("inline; filename*=UTF-8''a%20b.png"))
}
@Test
fun `copyChunked copies input to output byte-for-byte`() =
runTest {
// >64 KiB so the copy exercises multiple chunks (buffer is 64 KiB).
val payload = ByteArray(200_000) { (it % 251).toByte() }
val output = java.io.ByteArrayOutputStream()
GatewayFileClient.copyChunked(java.io.ByteArrayInputStream(payload), output)
assertTrue(output.toByteArray().contentEquals(payload))
}
@Test
fun `copyChunked aborts mid-copy when the job is cancelled`() =
runTest {
// Input that parks on its 2nd chunk read until the gate opens, so
// the test can cancel the job *while* the copy is in flight.
val gate = CompletableDeferred<Unit>()
val blockingInput =
object : java.io.InputStream() {
var reads = 0
override fun read(): Int = 1
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
reads++
if (reads == 2) kotlinx.coroutines.runBlocking { gate.await() }
return if (reads <= 3) 1 else -1
}
}
val output = java.io.ByteArrayOutputStream()
val job =
CoroutineScope(Dispatchers.Default).launch {
GatewayFileClient.copyChunked(blockingInput, output)
}
runCurrent()
job.cancel()
gate.complete(Unit)
job.join()
// If copyChunked swallowed the cancellation, the job would have
// completed normally; a cancelled completion proves propagation.
assertTrue(job.isCancelled)
}
}
@@ -3396,10 +3396,10 @@ class ChatViewModelTest {
every { app.cacheDir } returns cacheDir
mockkObject(GatewayFileClient)
val bytes = "hello".toByteArray()
val file = java.io.File(cacheDir, "note.txt").apply { writeBytes("hello".toByteArray()) }
coEvery {
GatewayFileClient.fetch(any())
} returns GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", bytes))
GatewayFileClient.fetch(any(), any())
} returns GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file))
val intentSlot = slot<Intent>()
// android.jar stubs Intent ctor/setters to throw "not mocked" in unit tests;
@@ -3430,7 +3430,7 @@ class ChatViewModelTest {
advanceUntilIdle()
// fetch was invoked (proves we entered the IO launch)
coVerify { GatewayFileClient.fetch(any()) }
coVerify { GatewayFileClient.fetch(any(), any()) }
// ACTION_VIEW intent delivered, with the right type + grant flag.
verify { app.startActivity(any()) }
verify { intentSlot.captured.setDataAndType(any(), eq("text/plain")) }
@@ -3443,9 +3443,24 @@ class ChatViewModelTest {
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 cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
val file =
java.io.File(System.getProperty("java.io.tmpdir"), "note_${System.nanoTime()}.txt")
.apply { writeText("downloaded") }
coEvery { GatewayFileClient.fetch("/tmp/note.txt", any()) } returns
GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file))
// copyChunked is mocked with the object — restore real copy so the
// saved document actually receives the bytes.
coEvery { GatewayFileClient.copyChunked(any(), any()) } coAnswers {
firstArg<java.io.InputStream>().copyTo(secondArg<java.io.OutputStream>())
}
val resolver = mockk<android.content.ContentResolver>()
val output = java.io.ByteArrayOutputStream()
val destination = mockk<android.net.Uri>()
@@ -3465,7 +3480,7 @@ class ChatViewModelTest {
vm.saveAttachment(attachment, destination)
advanceUntilIdle()
assertArrayEquals(bytes, output.toByteArray())
assertArrayEquals("downloaded".toByteArray(), output.toByteArray())
assertNull(vm.uiState.value.savingAttachmentPath)
assertEquals("Saved note.txt", vm.uiState.value.openError)
}
@@ -3474,11 +3489,19 @@ class ChatViewModelTest {
fun `saveAttachment never deletes the selected document when download fails`() =
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
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")
GatewayFileClient.fetch("/tmp/missing.pdf", any())
} returns GatewayFileResult.NotFound
val (viewModel, _) = createViewModelWithSession()
@@ -3504,12 +3527,27 @@ class ChatViewModelTest {
fun `saveAttachment never deletes the selected document when writing fails`() =
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
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)))
coEvery { GatewayFileClient.fetch("/tmp/report.pdf", any()) } returns
GatewayFileResult.Success(
GatewayFile(
"report.pdf",
"application/pdf",
java.io.File(System.getProperty("java.io.tmpdir"), "report_${System.nanoTime()}.pdf")
.apply { writeBytes(byteArrayOf(1)) },
),
)
val viewModel = createViewModel()
viewModel.saveAttachment(
@@ -3532,9 +3570,17 @@ class ChatViewModelTest {
fun `saveAttachment ignores overlapping saves`() =
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
val firstResult = CompletableDeferred<GatewayFileResult>()
val fetchedPaths = mutableListOf<String>()
coEvery { GatewayFileClient.fetch(capture(fetchedPaths)) } coAnswers { firstResult.await() }
coEvery { GatewayFileClient.fetch(capture(fetchedPaths), any()) } coAnswers { firstResult.await() }
val resolver = mockk<android.content.ContentResolver>(relaxed = true)
every { app.contentResolver } returns resolver
val viewModel = createViewModel()
@@ -3566,8 +3612,16 @@ class ChatViewModelTest {
fun `openAttachment GATEWAY not-found surfaces openError`() =
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_open_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
coEvery {
GatewayFileClient.fetch(any())
GatewayFileClient.fetch(any(), any())
} returns GatewayFileResult.NotFound
val vm = createViewModel()