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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user