Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
package com.google.maps.android.data.parser.kml

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.google.maps.android.data.renderer.SafeBitmapDecoder
import com.google.maps.android.data.renderer.SafeBitmapDecoder.takeIfWithinAllocationLimit
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.IOException
Expand All @@ -35,7 +36,7 @@ public interface ImageDecoder {
* Default implementation using Android's BitmapFactory.
*/
public class AndroidImageDecoder public constructor() : ImageDecoder {
public override fun decode(bytes: ByteArray): Bitmap? = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
public override fun decode(bytes: ByteArray): Bitmap? = SafeBitmapDecoder.decode(bytes)
}

/**
Expand Down Expand Up @@ -101,6 +102,7 @@ public class KmzParser @JvmOverloads public constructor(
*/
public fun parse(inputStream: InputStream): Kml {
val images = mutableMapOf<String, Bitmap>()
var decodedImageBytes = 0L
var kml: Kml? = null
val zipInputStream = ZipInputStream(BufferedInputStream(inputStream))
val countingStream = CountingInputStream(zipInputStream, maxKmzUncompressedTotalSize)
Expand Down Expand Up @@ -130,9 +132,20 @@ public class KmzParser @JvmOverloads public constructor(
} else {
// Try to decode as image
val bytes = countingStream.readBytes()
val bitmap = imageDecoder.decode(bytes)
val bitmap = imageDecoder.decode(bytes)?.takeIfWithinAllocationLimit()
if (bitmap != null) {
images[name] = bitmap
val bitmapBytes = bitmap.allocationByteCount.toLong()
if (bitmapBytes > MAX_DECODED_IMAGE_TOTAL_BYTES - decodedImageBytes) {
bitmap.recycle()
throw IOException(
"KMZ decoded image memory exceeds limit of $MAX_DECODED_IMAGE_TOTAL_BYTES bytes.",
)
}
decodedImageBytes += bitmapBytes
images.put(name, bitmap)?.let { replaced ->
decodedImageBytes -= replaced.allocationByteCount.toLong()
replaced.recycle()
}
}
}
// Drain any unread bytes through countingStream before closing entry to ensure
Expand All @@ -141,15 +154,19 @@ public class KmzParser @JvmOverloads public constructor(
zipInputStream.closeEntry()
entry = zipInputStream.nextEntry
}
if (kml == null) {
throw IllegalArgumentException("No KML file found in KMZ stream")
}

return kml.copy(images = images)
} catch (exception: Exception) {
images.values.forEach { bitmap ->
if (!bitmap.isRecycled) bitmap.recycle()
}
throw exception
} finally {
zipInputStream.close()
}

if (kml == null) {
throw IllegalArgumentException("No KML file found in KMZ stream")
}

return kml.copy(images = images)
}

/**
Expand All @@ -166,6 +183,7 @@ public class KmzParser @JvmOverloads public constructor(

public companion object {
private const val DRAIN_BUFFER_SIZE = 8192
private const val MAX_DECODED_IMAGE_TOTAL_BYTES = 32L * 1024 * 1024
public val SUPPORTED_EXTENSIONS: Set<String> = setOf("kmz")

public fun canParse(header: String): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.data.renderer

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.ByteArrayOutputStream
import java.io.InputStream

/**
* Central security boundary for bitmap data originating in KML, GeoJSON, or KMZ input.
*
* Encoded byte limits do not bound bitmap memory: a small, highly compressible image can require
* several bytes per pixel after decoding. Always inspect dimensions before allocating pixels, then
* verify the actual allocation because row stride and decoder configuration can exceed estimates.
*/
internal object SafeBitmapDecoder {
internal const val MAX_ENCODED_BYTES: Int = 10 * 1024 * 1024
internal const val MAX_DECODED_BYTES: Long = 16L * 1024 * 1024

private const val ARGB_8888_BYTES_PER_PIXEL = 4L
private const val READ_BUFFER_SIZE = 8192

internal fun decode(bytes: ByteArray): Bitmap? {
if (bytes.isEmpty() || bytes.size > MAX_ENCODED_BYTES) return null

val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (!dimensionsFitBudget(bounds.outWidth, bounds.outHeight)) return null

val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
return bitmap.takeIfWithinAllocationLimit()
}

internal fun decode(input: InputStream): Bitmap? {
val encoded = readBounded(input) ?: return null
return decode(encoded)
}

internal fun Bitmap.takeIfWithinAllocationLimit(): Bitmap? {
if (allocationByteCount.toLong() <= MAX_DECODED_BYTES) return this
recycle()
return null
}

internal fun Bitmap.allocationKilobytes(): Int = maxOf(1, (allocationByteCount + 1023) / 1024)

internal fun dimensionsFitBudget(width: Int, height: Int): Boolean {
if (width <= 0 || height <= 0) return false
val pixelCount = width.toLong() * height.toLong()
return pixelCount <= MAX_DECODED_BYTES / ARGB_8888_BYTES_PER_PIXEL
}

private fun readBounded(input: InputStream): ByteArray? {
val output = ByteArrayOutputStream(READ_BUFFER_SIZE)
val buffer = ByteArray(READ_BUFFER_SIZE)
var total = 0
while (true) {
val remainingWithSentinel = MAX_ENCODED_BYTES - total + 1
val read = input.read(buffer, 0, minOf(buffer.size, remainingWithSentinel))
if (read == -1) break
if (read == 0) continue
total += read
if (total > MAX_ENCODED_BYTES) return null
output.write(buffer, 0, read)
}
return output.toByteArray()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@
package com.google.maps.android.data.renderer

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import com.google.maps.android.data.kml.DefaultKmlUrlSanitizer
import com.google.maps.android.data.kml.KmlUrlSanitizer
import com.google.maps.android.data.renderer.SafeBitmapDecoder.allocationKilobytes
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
Expand All @@ -46,6 +48,7 @@ public open class UrlIconProvider @JvmOverloads public constructor(
private val memoryCache: LruCache<String, Bitmap>
private val inFlight = ConcurrentHashMap<String, Deferred<Bitmap?>>()
private val downloadScope = CoroutineScope(SupervisorJob() + dispatcher)
private val downloadSemaphore = Semaphore(MAX_CONCURRENT_DOWNLOADS)

init {
// Get max available VM memory, exceeding this amount will throw an
Expand All @@ -61,7 +64,7 @@ public open class UrlIconProvider @JvmOverloads public constructor(
override fun sizeOf(key: String, bitmap: Bitmap): Int {
// The cache size will be measured in kilobytes rather than
// number of items.
return bitmap.byteCount / 1024
return bitmap.allocationKilobytes()
}
}
}
Expand All @@ -76,15 +79,19 @@ public open class UrlIconProvider @JvmOverloads public constructor(
synchronized(inFlight) {
inFlight.getOrPut(url) {
downloadScope.async {
try {
val bitmap = loadBitmapFromUrl(url)
if (bitmap != null) {
memoryCache.put(url, bitmap)
downloadSemaphore.withPermit {
try {
val bitmap = loadBitmapFromUrl(url)?.let {
SafeBitmapDecoder.run { it.takeIfWithinAllocationLimit() }
}
if (bitmap != null) {
memoryCache.put(url, bitmap)
}
bitmap
} finally {
// Always remove from in-flight map when done, success or failure
inFlight.remove(url)
}
bitmap
} finally {
// Always remove from in-flight map when done, success or failure
inFlight.remove(url)
}
}
}
Expand Down Expand Up @@ -120,7 +127,7 @@ public open class UrlIconProvider @JvmOverloads public constructor(
when {
code in 200..299 -> {
return@withContext connection.inputStream.use {
BitmapFactory.decodeStream(it)
SafeBitmapDecoder.decode(it)
}
}

Expand Down Expand Up @@ -151,5 +158,8 @@ public open class UrlIconProvider @JvmOverloads public constructor(
private companion object {
/** Maximum number of redirects to follow before giving up. */
private const val MAX_REDIRECTS = 5

/** Limits simultaneous response buffering and bitmap allocation for distinct URLs. */
private const val MAX_CONCURRENT_DOWNLOADS = 2
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ package com.google.maps.android.data.parser.kml

import android.graphics.Bitmap
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
Expand Down Expand Up @@ -144,6 +144,49 @@ class KmzParserTest {
)
}

@Test
fun `parse rejects cumulative decoded bitmap memory above limit`() {
val bitmapBytes = 16 * 1024 * 1024
val bitmaps = List(3) {
mockk<Bitmap>(relaxed = true) {
every { allocationByteCount } returns bitmapBytes
}
}
var nextBitmap = 0
val decoder =
object : ImageDecoder {
override fun decode(bytes: ByteArray): Bitmap = bitmaps[nextBitmap++]
}
val kmzStream =
createKmzStream(
"doc.kml" to "<kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document/></kml>".toByteArray(),
"one.png" to byteArrayOf(1),
"two.png" to byteArrayOf(2),
"three.png" to byteArrayOf(3),
)

assertThrows(IOException::class.java) {
KmzParser(decoder).parse(kmzStream)
}
verify(exactly = 1) { bitmaps[0].recycle() }
verify(exactly = 1) { bitmaps[1].recycle() }
verify(exactly = 1) { bitmaps[2].recycle() }
}

@Test
fun `parse recycles decoded images when KMZ has no KML entry`() {
val bitmap = mockk<Bitmap>(relaxed = true)
val decoder =
object : ImageDecoder {
override fun decode(bytes: ByteArray): Bitmap = bitmap
}

assertThrows(IllegalArgumentException::class.java) {
KmzParser(decoder).parse(createKmzStream("image.png" to byteArrayOf(1)))
}
verify(exactly = 1) { bitmap.recycle() }
}

private fun createKmzStream(vararg entries: kotlin.Pair<String, ByteArray>): ByteArrayInputStream {
val baos = ByteArrayOutputStream()
val zos = ZipOutputStream(baos)
Expand Down
Loading
Loading