diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 9255eff44b..d1651b7960 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -23,6 +23,8 @@ import java.io.IOException import java.nio.ByteBuffer import java.util.concurrent.ConcurrentHashMap +private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024 + private class RemoteRequest(val cancellationSignal: CancellationSignal) class RemoteImagesImpl(context: Context) : RemoteImageApi { @@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher { private val onComplete: () -> Unit, ) : UrlRequest.Callback() { private var buffer: NativeByteBuffer? = null - private var wrapped: ByteBuffer? = null private var error: Exception? = null override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) { @@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher { } try { + // Content-Length is a size hint only. With Content-Encoding (gzip/br/...), + // Cronet auto-decompresses and writes decompressed bytes to our buffer, which + // may exceed the wire/compressed Content-Length. Always use the growable + // buffer path so we can't overflow. val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0 - if (contentLength > 0) { - buffer = NativeByteBuffer(contentLength + 1) - wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1) - request.read(wrapped) - } else { - buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE) - request.read(buffer!!.wrapRemaining()) - } + // Cap the up-front alloc: Content-Length is untrusted and can be huge or near + // Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over. + val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE + buffer = NativeByteBuffer(initialSize) + request.read(buffer!!.wrapRemaining()) } catch (e: Exception) { error = e return request.cancel() @@ -263,14 +265,18 @@ private class CronetImageFetcher : ImageFetcher { byteBuffer: ByteBuffer ) { try { - val buf = if (wrapped == null) { - buffer!!.run { - advance(byteBuffer.position()) - ensureHeadroom() - wrapRemaining() - } + val b = buffer!! + b.advance(byteBuffer.position()) + // Reuse the caller-supplied ByteBuffer as long as we don't need to grow. + // It already points at our native memory with position advanced past the + // written bytes — Cronet can keep writing into the remaining tail. + // Only when the buffer is full do we grow (which may realloc + move the + // native pointer) and need a fresh wrap. + val buf = if (b.offset == b.capacity) { + b.ensureHeadroom() + b.wrapRemaining() } else { - wrapped + byteBuffer } request.read(buf) } catch (e: Exception) { @@ -280,7 +286,6 @@ private class CronetImageFetcher : ImageFetcher { } override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { - wrapped?.let { buffer!!.advance(it.position()) } onSuccess(buffer!!) onComplete() }