diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml
index a8922937..d312b735 100644
--- a/maps-app/src/main/AndroidManifest.xml
+++ b/maps-app/src/main/AndroidManifest.xml
@@ -109,6 +109,9 @@
+
diff --git a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt
index 45925c82..2931e60f 100644
--- a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt
+++ b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt
@@ -116,6 +116,11 @@ sealed class ActivityGroup(
R.string.tile_overlay_activity_description,
TileOverlayActivity::class
),
+ Activity(
+ R.string.wms_tile_overlay_activity,
+ R.string.wms_tile_overlay_activity_description,
+ WmsTileOverlayActivity::class
+ ),
Activity(
R.string.ground_overlay_activity,
R.string.ground_overlay_activity_description,
diff --git a/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt
new file mode 100644
index 00000000..092479cb
--- /dev/null
+++ b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt
@@ -0,0 +1,112 @@
+/*
+ * 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.compose
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.compose.wms.WmsTileOverlay
+import androidx.core.net.toUri
+
+/**
+ * This activity demonstrates how to use [WmsTileOverlay] to display a Web Map Service (WMS)
+ * layer on a map.
+ */
+class WmsTileOverlayActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ val center = LatLng(39.50, -98.35) // Center of US
+ val cameraPositionState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(center, 4f)
+ }
+ var mapType by remember { mutableStateOf(MapType.NORMAL) }
+ var overlayVisible by remember { mutableStateOf(true) }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap(
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState,
+ properties = MapProperties(mapType = mapType)
+ ) {
+ // Example: USGS National Map Shaded Relief (WMS)
+ WmsTileOverlay(
+ urlFormatter = { xMin, yMin, xMax, yMax, _ ->
+ "https://basemap.nationalmap.gov/arcgis/services/USGSShadedReliefOnly/MapServer/WmsServer".toUri()
+ .buildUpon()
+ .appendQueryParameter("SERVICE", "WMS")
+ .appendQueryParameter("VERSION", "1.1.1")
+ .appendQueryParameter("REQUEST", "GetMap")
+ .appendQueryParameter("FORMAT", "image/png")
+ .appendQueryParameter("TRANSPARENT", "true")
+ .appendQueryParameter("LAYERS", "0")
+ .appendQueryParameter("SRS", "EPSG:3857")
+ .appendQueryParameter("WIDTH", "256")
+ .appendQueryParameter("HEIGHT", "256")
+ .appendQueryParameter("STYLES", "")
+ .appendQueryParameter("BBOX", "$xMin,$yMin,$xMax,$yMax")
+ .build()
+ .toString()
+ },
+ transparency = 0.5f,
+ visible = overlayVisible
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Button(
+ onClick = {
+ mapType = if (mapType == MapType.NONE) MapType.NORMAL else MapType.NONE
+ }
+ ) {
+ Text(if (mapType == MapType.NONE) "Show Base Map" else "Hide Base Map")
+ }
+
+ Button(
+ onClick = {
+ overlayVisible = !overlayVisible
+ }
+ ) {
+ Text(if (overlayVisible) "Hide WMS Overlay" else "Show WMS Overlay")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/maps-app/src/main/res/values/strings.xml b/maps-app/src/main/res/values/strings.xml
index 2a30105c..1147de14 100644
--- a/maps-app/src/main/res/values/strings.xml
+++ b/maps-app/src/main/res/values/strings.xml
@@ -75,6 +75,9 @@
Tile Overlay
Adding a tile overlay to the map.
+ WMS Tile Overlay
+ Adding a WMS (EPSG:3857) tile overlay to the map.
+
Ground Overlay
Adding a ground overlay to the map.
diff --git a/maps-compose-utils/build.gradle.kts b/maps-compose-utils/build.gradle.kts
index d9b9b437..b3d56458 100644
--- a/maps-compose-utils/build.gradle.kts
+++ b/maps-compose-utils/build.gradle.kts
@@ -85,4 +85,6 @@ dependencies {
implementation(libs.kotlin)
implementation(libs.kotlinx.coroutines.android)
api(libs.maps.ktx.utils)
+
+ testImplementation(libs.test.junit)
}
diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt
new file mode 100644
index 00000000..52f8bcbf
--- /dev/null
+++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.compose.wms
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.google.android.gms.maps.model.TileOverlay
+import com.google.maps.android.compose.TileOverlay
+import com.google.maps.android.compose.TileOverlayState
+import com.google.maps.android.compose.rememberTileOverlayState
+
+/**
+ * A Composable that displays a Web Map Service (WMS) layer using the EPSG:3857 projection.
+ *
+ * @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates.
+ * @param state the [TileOverlayState] to be used to control the tile overlay.
+ * @param fadeIn boolean indicating whether the tiles should fade in.
+ * @param transparency the transparency of the tile overlay.
+ * @param visible the visibility of the tile overlay.
+ * @param zIndex the z-index of the tile overlay.
+ * @param onClick a lambda invoked when the tile overlay is clicked.
+ * @param tileWidth the width of the tiles in pixels (default 256).
+ * @param tileHeight the height of the tiles in pixels (default 256).
+ */
+@Composable
+public fun WmsTileOverlay(
+ urlFormatter: (xMin: Double, yMin: Double, xMax: Double, yMax: Double, zoom: Int) -> String,
+ state: TileOverlayState = rememberTileOverlayState(),
+ fadeIn: Boolean = true,
+ transparency: Float = 0f,
+ visible: Boolean = true,
+ zIndex: Float = 0f,
+ onClick: (TileOverlay) -> Unit = {},
+ tileWidth: Int = 256,
+ tileHeight: Int = 256
+) {
+ val tileProvider = remember(urlFormatter, tileWidth, tileHeight) {
+ WmsUrlTileProvider(
+ width = tileWidth,
+ height = tileHeight,
+ urlFormatter = urlFormatter
+ )
+ }
+ TileOverlay(
+ tileProvider = tileProvider,
+ state = state,
+ fadeIn = fadeIn,
+ transparency = transparency,
+ visible = visible,
+ zIndex = zIndex,
+ onClick = onClick
+ )
+}
diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt
new file mode 100644
index 00000000..e6d01182
--- /dev/null
+++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.compose.wms
+
+import com.google.android.gms.maps.model.UrlTileProvider
+import java.net.MalformedURLException
+import java.net.URL
+import kotlin.math.PI
+import kotlin.math.pow
+
+/**
+ * A [UrlTileProvider] for Web Map Service (WMS) layers that use the EPSG:3857 (Web Mercator)
+ * projection.
+ *
+ * @param width the width of the tile in pixels.
+ * @param height the height of the tile in pixels.
+ * @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates
+ * (xMin, yMin, xMax, yMax) and zoom level.
+ */
+public class WmsUrlTileProvider(
+ width: Int = 256,
+ height: Int = 256,
+ private val urlFormatter: (
+ xMin: Double,
+ yMin: Double,
+ xMax: Double,
+ yMax: Double,
+ zoom: Int
+ ) -> String
+) : UrlTileProvider(width, height) {
+
+ override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
+ val bbox = getBoundingBox(x, y, zoom)
+ val urlString = urlFormatter(bbox[0], bbox[1], bbox[2], bbox[3], zoom)
+ return try {
+ URL(urlString)
+ } catch (e: MalformedURLException) {
+ null
+ }
+ }
+
+ private companion object {
+ /**
+ * The maximum extent of the Web Mercator projection (EPSG:3857) in meters.
+ * This is the distance from the origin (0,0) to the edge of the world map.
+ * Calculated as semi-major axis of Earth (6378137.0) * PI.
+ */
+ private const val WORLD_EXTENT = (6378137.0) * PI
+
+ /**
+ * The total width/height of the world map in meters.
+ */
+ private const val WORLD_SIZE_METERS = 2 * WORLD_EXTENT
+ }
+
+ /**
+ * Calculates the bounding box for the given tile in EPSG:3857 coordinates.
+ *
+ * @return an array containing [xMin, yMin, xMax, yMax] in meters.
+ */
+ internal fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
+ // 1. Calculate how many tiles exist in each dimension at this zoom level (2^zoom).
+ val tilesPerDimension = 1 shl zoom
+
+ // 2. Divide the total world span by the number of tiles to find the metric size of one tile.
+ val tileSizeMeters = WORLD_SIZE_METERS / tilesPerDimension.toDouble()
+
+ // 3. X-axis: Starts at the far left (-WORLD_EXTENT) and moves East.
+ val xMin = -WORLD_EXTENT + (x * tileSizeMeters)
+ val xMax = -WORLD_EXTENT + ((x + 1) * tileSizeMeters)
+
+ // 4. Y-axis: Google Maps/TMS starts at the Top (y=0 is North) and moves South.
+ // WMS Bounding Box expects yMin to be the southern-most latitude and yMax to be the northern-most.
+ // Therefore, we subtract the tile distance from the northern-most edge (+WORLD_EXTENT).
+ val yMax = WORLD_EXTENT - (y * tileSizeMeters)
+ val yMin = WORLD_EXTENT - ((y + 1) * tileSizeMeters)
+
+ return doubleArrayOf(xMin, yMin, xMax, yMax)
+ }
+}
diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt
new file mode 100644
index 00000000..55ddadee
--- /dev/null
+++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.compose.wms
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Test
+
+public class WmsUrlTileProviderTest {
+
+ private val worldSize: Double = 6378137.0 * kotlin.math.PI
+
+ @Test
+ public fun testGetBoundingBoxZoom0() {
+ val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
+ val bbox = provider.getBoundingBox(0, 0, 0)
+
+ // Zoom 0, Tile 0,0 should cover the entire world
+ val expected = doubleArrayOf(-worldSize, -worldSize, worldSize, worldSize)
+ assertArrayEquals(expected, bbox, 0.001)
+ }
+
+ @Test
+ public fun testGetBoundingBoxZoom1() {
+ val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
+
+ // Zoom 1, Tile 0,0 (Top Left)
+ val bbox00 = provider.getBoundingBox(0, 0, 1)
+ val expected00 = doubleArrayOf(-worldSize, 0.0, 0.0, worldSize)
+ assertArrayEquals(expected00, bbox00, 0.001)
+
+ // Zoom 1, Tile 1,1 (Bottom Right)
+ val bbox11 = provider.getBoundingBox(1, 1, 1)
+ val expected11 = doubleArrayOf(0.0, -worldSize, worldSize, 0.0)
+ assertArrayEquals(expected11, bbox11, 0.001)
+ }
+
+ @Test
+ public fun testGetBoundingBoxSpecificTile() {
+ val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
+
+ // Zoom 2, Tile 1,1
+ // Num tiles = 4x4. Tile size = 2 * worldSize / 4 = worldSize / 2
+ // xMin = -worldSize + 1 * (worldSize/2) = -worldSize/2
+ // xMax = -worldSize + 2 * (worldSize/2) = 0
+ // yMax = worldSize - 1 * (worldSize/2) = worldSize/2
+ // yMin = worldSize - 2 * (worldSize/2) = 0
+ val bbox = provider.getBoundingBox(1, 1, 2)
+ val expected = doubleArrayOf(-worldSize / 2, 0.0, 0.0, worldSize / 2)
+ assertArrayEquals(expected, bbox, 0.001)
+ }
+}