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 @@ -85,9 +85,11 @@ public class GeoJsonLineStringStyle :
visible(mPolylineOptions.isVisible)
width(mPolylineOptions.width)
zIndex(mPolylineOptions.zIndex)
pattern(getPattern())
startCap(getStartCap())
endCap(getEndCap())
// Read from mPolylineOptions: inside apply, getPattern() and the cap getters would
// resolve to the new PolylineOptions instead of this style.
pattern(mPolylineOptions.pattern)
startCap(mPolylineOptions.startCap)
endCap(mPolylineOptions.endCap)
}

override fun toString(): String =
Expand Down
42 changes: 32 additions & 10 deletions data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package com.google.maps.android.data.kml

import android.content.Context
import android.graphics.Color
import android.util.Log
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
Expand All @@ -37,6 +38,8 @@ import java.io.IOException
import java.io.InputStream
import java.nio.charset.StandardCharsets

private const val LOG_TAG = "KmlLayer"

@Deprecated("Use the new platform-agnostic data layer and renderer instead.")
public class KmlLayer : Layer {
private val mPlacemarks = mutableListOf<KmlPlacemark>()
Expand Down Expand Up @@ -131,8 +134,16 @@ public class KmlLayer : Layer {

// Parse Document
kmlObj.document?.let { doc ->
val styles = doc.styles.associate { it.id!! to toLegacyStyle(it) }
val styleMaps = doc.styleMaps.associate { it.id!! to (it.pairs.firstOrNull { p -> p.key == "normal" }?.styleUrl ?: "") }
// Shared styles and style maps can only be referenced by id, so ones without an id
// are skipped rather than failing the whole layer.
val styles = doc.styles.mapNotNull { style -> style.id?.let { it to toLegacyStyle(style) } }.toMap()
val styleMaps =
doc.styleMaps
.mapNotNull { styleMap ->
styleMap.id?.let { id ->
id to (styleMap.pairs.firstOrNull { p -> p.key == "normal" }?.styleUrl ?: "")
}
}.toMap()

val placemarksMap = HashMap<KmlPlacemark, Any?>()
doc.placemarks.forEach { p ->
Expand All @@ -146,7 +157,7 @@ public class KmlLayer : Layer {

val groundOverlaysMap = HashMap<KmlGroundOverlay, com.google.android.gms.maps.model.GroundOverlay?>()
doc.groundOverlays.forEach { g ->
groundOverlaysMap[toLegacyGroundOverlay(g)] = null
toLegacyGroundOverlay(g)?.let { groundOverlaysMap[it] = null }
}

val properties = HashMap<String, String>()
Expand All @@ -171,7 +182,7 @@ public class KmlLayer : Layer {
if (kmlObj.document == null) {
kmlObj.placemark?.let { mPlacemarks.add(toLegacyPlacemark(it, emptyMap(), emptyMap())) }
kmlObj.folder?.let { mContainers.add(toLegacyContainer(it, emptyMap(), emptyMap())) }
kmlObj.groundOverlay?.let { mGroundOverlays.add(toLegacyGroundOverlay(it)) }
kmlObj.groundOverlay?.let { overlay -> toLegacyGroundOverlay(overlay)?.let { mGroundOverlays.add(it) } }
}
}

Expand Down Expand Up @@ -213,7 +224,9 @@ public class KmlLayer : Layer {
}

val styleUrl = placemark.styleUrl?.substringAfter("#") ?: ""
val resolvedStyleUrl = styleMaps[styleUrl] ?: styleUrl
// Style map entries keep the '#' of their normal styleUrl, while shared styles are keyed
// by bare id.
val resolvedStyleUrl = styleMaps[styleUrl]?.substringAfter("#") ?: styleUrl
val inlineStyle = placemark.style?.let { toLegacyStyle(it) } ?: styles[resolvedStyleUrl]

return KmlPlacemark(geometry, resolvedStyleUrl, inlineStyle, properties)
Expand All @@ -240,7 +253,7 @@ public class KmlLayer : Layer {

val groundOverlaysMap = HashMap<KmlGroundOverlay, com.google.android.gms.maps.model.GroundOverlay?>()
folder.groundOverlays.forEach { g ->
groundOverlaysMap[toLegacyGroundOverlay(g)] = null
toLegacyGroundOverlay(g)?.let { groundOverlaysMap[it] = null }
}

return KmlContainer(
Expand All @@ -254,14 +267,23 @@ public class KmlLayer : Layer {
)
}

private fun toLegacyGroundOverlay(groundOverlay: com.google.maps.android.data.parser.kml.GroundOverlay): KmlGroundOverlay {
/**
* Returns null for overlays without a LatLonBox, such as those positioned with gx:LatLonQuad,
* which a ground overlay on the map cannot represent.
*/
private fun toLegacyGroundOverlay(groundOverlay: com.google.maps.android.data.parser.kml.GroundOverlay): KmlGroundOverlay? {
val latLonBox = groundOverlay.latLonBox
if (latLonBox == null) {
Log.w(LOG_TAG, "Skipping GroundOverlay ${groundOverlay.name ?: ""} without a LatLonBox")
return null
}
val properties = mutableMapOf<String, String>()
groundOverlay.name?.let { properties["name"] = it }

val bounds =
LatLngBounds(
LatLng(groundOverlay.latLonBox!!.south, groundOverlay.latLonBox.west),
LatLng(groundOverlay.latLonBox.north, groundOverlay.latLonBox.east),
LatLng(latLonBox.south, latLonBox.west),
LatLng(latLonBox.north, latLonBox.east),
)

return KmlGroundOverlay(
Expand All @@ -270,7 +292,7 @@ public class KmlLayer : Layer {
drawOrder = groundOverlay.drawOrder?.toFloat() ?: 0f,
visibility = if (groundOverlay.visibility) 1 else 0,
properties = properties,
rotation = groundOverlay.latLonBox.rotation?.toFloat() ?: 0f,
rotation = latLonBox.rotation?.toFloat() ?: 0f,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public class KmlStyle : Style() {
private var mPolyRandomColorMode = false
internal var mMarkerColor = 0f

// The ARGB color behind mMarkerColor, which is only a hue. KML's default color is white.
private var mMarkerArgb = Color.WHITE

public fun setInfoWindowText(text: String) {
mBalloonOptions["text"] = text
}
Expand Down Expand Up @@ -89,6 +92,7 @@ public class KmlStyle : Style() {

public fun setMarkerColor(color: String) {
val integerColor = Color.parseColor("#" + convertColor(color))
mMarkerArgb = integerColor
mMarkerColor = getHueValue(integerColor)
mMarkerOptions.icon(BitmapDescriptorFactory.defaultMarker(mMarkerColor))
mStylesSet.add("markerColor")
Expand Down Expand Up @@ -149,7 +153,7 @@ public class KmlStyle : Style() {
newMarkerOption.rotation(mMarkerOptions.rotation)
newMarkerOption.anchor(mMarkerOptions.anchorU, mMarkerOptions.anchorV)
if (mIconRandomColorMode) {
val hue = getHueValue(computeRandomColor(mMarkerColor.toInt()))
val hue = getHueValue(computeRandomColor(mMarkerArgb))
mMarkerOptions.icon(BitmapDescriptorFactory.defaultMarker(hue))
}
newMarkerOption.icon(mMarkerOptions.icon)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/*
* 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.geojson

import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.Dash
import com.google.android.gms.maps.model.Gap
import com.google.android.gms.maps.model.JointType
import com.google.android.gms.maps.model.RoundCap
import com.google.android.gms.maps.model.SquareCap
import com.google.common.truth.Truth.assertThat
import io.mockk.mockk
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Observable

/**
* The GeoJSON styles notify their observers on every change (GeoJsonLayer redraws features from
* those notifications) and convert to the Maps SDK options used to draw each geometry.
*/
@Suppress("DEPRECATION")
@RunWith(RobolectricTestRunner::class)
class GeoJsonStylesTest {
private fun Observable.countNotifications(): () -> Int {
var count = 0
addObserver { _, _ -> count++ }
return { count }
}

@Test
fun `polygon style setters notify observers once each`() {
val style = GeoJsonPolygonStyle()
val notifications = style.countNotifications()

style.fillColor = 0x7F00FF00
style.setGeodesic(true)
style.setStrokeColor(0xFFFF0000.toInt())
style.setStrokeJointType(JointType.ROUND)
style.setStrokePattern(listOf(Dash(10f), Gap(5f)))
style.setStrokeWidth(3f)
style.setZIndex(2f)
style.setVisible(false)
style.setClickable(false)

assertThat(notifications()).isEqualTo(9)
}

@Test
fun `polygon options carry every polygon style property`() {
val style = GeoJsonPolygonStyle()
style.fillColor = 0x7F00FF00
style.setGeodesic(true)
style.setStrokeColor(0xFFFF0000.toInt())
style.setStrokeJointType(JointType.BEVEL)
style.setStrokePattern(listOf(Dash(10f), Gap(5f)))
style.setStrokeWidth(3f)
style.setZIndex(2f)
style.setVisible(false)
style.setClickable(false)

val options = style.toPolygonOptions()

assertThat(options.fillColor).isEqualTo(0x7F00FF00)
assertThat(options.isGeodesic).isTrue()
assertThat(options.strokeColor).isEqualTo(0xFFFF0000.toInt())
assertThat(options.strokeJointType).isEqualTo(JointType.BEVEL)
assertThat(options.strokePattern).containsExactly(Dash(10f), Gap(5f)).inOrder()
assertThat(options.strokeWidth).isEqualTo(3f)
assertThat(options.zIndex).isEqualTo(2f)
assertThat(options.isVisible).isFalse()
assertThat(options.isClickable).isFalse()
assertThat(style.toString()).contains("fill color=${0x7F00FF00}")
}

@Test
fun `polygon style applies to polygons and collections`() {
assertThat(GeoJsonPolygonStyle().getGeometryType())
.asList()
.containsExactly("Polygon", "MultiPolygon", "GeometryCollection")
}

@Test
fun `polygon style defaults are clickable and visible`() {
val style = GeoJsonPolygonStyle()

assertThat(style.isClickable()).isTrue()
assertThat(style.isVisible()).isTrue()
assertThat(style.isGeodesic()).isFalse()
}

@Test
fun `line string style setters notify observers once each`() {
val style = GeoJsonLineStringStyle()
val notifications = style.countNotifications()

style.color = 0xFF0000FF.toInt()
style.setClickable(false)
style.setGeodesic(true)
style.setWidth(6f)
style.setZIndex(1f)
style.setVisible(false)
style.setPattern(listOf(Dash(4f)))
style.setStartCap(RoundCap())
style.setEndCap(SquareCap())

assertThat(notifications()).isEqualTo(9)
}

@Test
fun `polyline options carry the line string style properties`() {
val style = GeoJsonLineStringStyle()
style.color = 0xFF0000FF.toInt()
style.setClickable(false)
style.setGeodesic(true)
style.setWidth(6f)
style.setZIndex(1f)
style.setVisible(false)

val options = style.toPolylineOptions()

assertThat(options.color).isEqualTo(0xFF0000FF.toInt())
assertThat(options.isClickable).isFalse()
assertThat(options.isGeodesic).isTrue()
assertThat(options.width).isEqualTo(6f)
assertThat(options.zIndex).isEqualTo(1f)
assertThat(options.isVisible).isFalse()
assertThat(style.toString()).contains("width=6.0")
}

/**
* Regression test: toPolylineOptions used to call getPattern() and the cap getters inside
* `PolylineOptions().apply { }`, which resolved to the new options instead of the style.
*/
@Test
fun `polyline options carry the pattern and caps`() {
val style = GeoJsonLineStringStyle()
style.setPattern(listOf(Dash(4f), Gap(2f)))
style.setStartCap(RoundCap())
style.setEndCap(SquareCap())

val options = style.toPolylineOptions()

assertThat(options.startCap).isInstanceOf(RoundCap::class.java)
assertThat(options.endCap).isInstanceOf(SquareCap::class.java)
assertThat(options.pattern).containsExactly(Dash(4f), Gap(2f)).inOrder()
}

@Test
fun `line string style applies to lines and collections`() {
assertThat(GeoJsonLineStringStyle().getGeometryType())
.asList()
.containsExactly("LineString", "MultiLineString", "GeometryCollection")
}

@Test
fun `point style setters notify observers once each`() {
val style = GeoJsonPointStyle()
val notifications = style.countNotifications()

style.setAlpha(0.5f)
style.setAnchor(0.2f, 0.8f)
style.setDraggable(true)
style.setFlat(true)
style.setIcon(mockk<BitmapDescriptor>())
style.setInfoWindowAnchor(0.5f, 0.1f)
style.setRotation(30f)
style.setSnippet("snippet")
style.setTitle("title")
style.setVisible(false)
style.setZIndex(3f)

assertThat(notifications()).isEqualTo(11)
}

@Test
fun `marker options carry every point style property`() {
val icon = mockk<BitmapDescriptor>()
val style = GeoJsonPointStyle()
style.setAlpha(0.5f)
style.setAnchor(0.2f, 0.8f)
style.setDraggable(true)
style.setFlat(true)
style.setIcon(icon)
style.setInfoWindowAnchor(0.5f, 0.1f)
style.setRotation(30f)
style.setSnippet("snippet")
style.setTitle("title")
style.setVisible(false)
style.setZIndex(3f)

val options = style.toMarkerOptions()

assertThat(options.alpha).isEqualTo(0.5f)
assertThat(options.anchorU).isEqualTo(0.2f)
assertThat(options.anchorV).isEqualTo(0.8f)
assertThat(options.isDraggable).isTrue()
assertThat(options.isFlat).isTrue()
assertThat(options.icon).isSameInstanceAs(icon)
assertThat(options.infoWindowAnchorU).isEqualTo(0.5f)
assertThat(options.infoWindowAnchorV).isEqualTo(0.1f)
assertThat(options.rotation).isEqualTo(30f)
assertThat(options.snippet).isEqualTo("snippet")
assertThat(options.title).isEqualTo("title")
assertThat(options.isVisible).isFalse()
assertThat(options.zIndex).isEqualTo(3f)
assertThat(style.toString()).contains("title=title")
}

@Test
fun `point style applies to points and collections`() {
assertThat(GeoJsonPointStyle().getGeometryType())
.asList()
.containsExactly("Point", "MultiPoint", "GeometryCollection")
}
}
Loading
Loading