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 @@ -117,6 +117,7 @@ class GoogleMapImpl(context: Context, var options: GoogleMapOptions) : AbstractG

var groundId = 0L
var tileId = 0L
val pendingTileOverlays = mutableSetOf<TileOverlayImpl>()

var storedMapType: Int = options.mapType
var mapStyle: MapStyleOptions? = null
Expand Down Expand Up @@ -352,8 +353,16 @@ class GoogleMapImpl(context: Context, var options: GoogleMapOptions) : AbstractG
}

override fun addTileOverlay(options: TileOverlayOptions): ITileOverlayDelegate? {
Log.d(TAG, "unimplemented Method: addTileOverlay")
return TileOverlayImpl(this, "t${tileId++}", options)
val tileOverlay = TileOverlayImpl(this, "t${tileId++}", options)
synchronized(this) {
val style = map?.style
if (style != null && style.isFullyLoaded) {
tileOverlay.update(style)
} else {
pendingTileOverlays.add(tileOverlay)
}
}
return tileOverlay
}

override fun addCircle(options: CircleOptions): ICircleDelegate {
Expand Down Expand Up @@ -780,6 +789,9 @@ class GoogleMapImpl(context: Context, var options: GoogleMapOptions) : AbstractG
pendingMarkers.forEach { it.update(symbolManager) }
pendingMarkers.clear()

pendingTileOverlays.forEach { overlay -> overlay.update(it) }
pendingTileOverlays.clear()

pendingBitmaps.forEach { map -> it.addImage(map.key, map.value) }
pendingBitmaps.clear()

Expand Down Expand Up @@ -867,6 +879,7 @@ class GoogleMapImpl(context: Context, var options: GoogleMapOptions) : AbstractG
symbolManager = null
currentInfoWindow?.close()
pendingMarkers.clear()
pendingTileOverlays.clear()
markers.clear()
BitmapDescriptorFactoryImpl.unregisterMap(map)
view.removeView(mapView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,99 @@ package org.microg.gms.maps.mapbox.model
import android.os.Parcel
import android.util.Log
import com.google.android.gms.maps.model.TileOverlayOptions
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.internal.ITileOverlayDelegate
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.style.layers.PropertyFactory
import com.mapbox.mapboxsdk.style.layers.RasterLayer
import com.mapbox.mapboxsdk.style.sources.RasterSource
import com.mapbox.mapboxsdk.style.sources.TileSet
import org.microg.gms.maps.mapbox.GoogleMapImpl
import org.microg.gms.utils.warnOnTransactionIssues

private const val TILE_SIZE = 256

class TileOverlayImpl(private val map: GoogleMapImpl, private val id: String, options: TileOverlayOptions) : ITileOverlayDelegate.Stub() {
private val sourceId = "tileoverlay-source-$id"
private val layerId = "tileoverlay-layer-$id"
private val tileProvider: TileProvider? = try {
options.tileProvider
} catch (e: Exception) {
Log.w(TAG, "No tile provider for overlay $id", e)
null
}

private var zIndex = options.zIndex
private var visible = options.isVisible
private var fadeIn = options.fadeIn
private var transparency = options.transparency
private var added = false
private var serverToken: String? = null

/** Called on the map thread once the style is ready. Adds the raster source + layer. */
fun update(style: Style) {
val provider = tileProvider ?: return
try {
val token = serverToken ?: TileProviderServer.register(provider).also { serverToken = it }
if (style.getSource(sourceId) == null) {
val tileSet = TileSet("2.2.0", TileProviderServer.tileUrl(token)).apply {
minZoom = 0f
maxZoom = 22f
}
style.addSource(RasterSource(sourceId, tileSet, TILE_SIZE))
}
if (style.getLayer(layerId) == null) {
val layer = RasterLayer(layerId, sourceId)
layer.setProperties(
PropertyFactory.rasterOpacity(currentOpacity()),
PropertyFactory.rasterFadeDuration(if (fadeIn) 300f else 0f)
)
style.addLayer(layer)
}
added = true
} catch (e: Exception) {
Log.w(TAG, "Failed to add tile overlay $id", e)
}
}

private fun currentOpacity(): Float = if (visible) (1f - transparency).coerceIn(0f, 1f) else 0f

private fun applyOpacity() {
if (!added) return
map.map?.getStyle { style ->
try {
style.getLayerAs<RasterLayer>(layerId)?.setProperties(PropertyFactory.rasterOpacity(currentOpacity()))
} catch (e: Exception) {
Log.w(TAG, "Failed to update tile overlay $id opacity", e)
}
}
}

override fun remove() {
Log.d(TAG, "Not yet implemented: remove")
serverToken?.let { TileProviderServer.unregister(it) }
serverToken = null
map.map?.getStyle { style ->
try {
style.removeLayer(layerId)
style.removeSource(sourceId)
} catch (e: Exception) {
Log.w(TAG, "Failed to remove tile overlay $id", e)
}
}
added = false
}

override fun clearTileCache() {
Log.d(TAG, "Not yet implemented: clearTileCache")
map.map?.getStyle { style ->
try {
if (style.getLayer(layerId) != null) style.removeLayer(layerId)
if (style.getSource(sourceId) != null) style.removeSource(sourceId)
added = false
update(style)
} catch (e: Exception) {
Log.w(TAG, "Failed to clear tile cache for overlay $id", e)
}
}
}

override fun getId(): String = id
Expand All @@ -36,6 +113,7 @@ class TileOverlayImpl(private val map: GoogleMapImpl, private val id: String, op

override fun setVisible(visible: Boolean) {
this.visible = visible
applyOpacity()
}

override fun isVisible(): Boolean = visible
Expand All @@ -52,6 +130,7 @@ class TileOverlayImpl(private val map: GoogleMapImpl, private val id: String, op

override fun setTransparency(transparency: Float) {
this.transparency = transparency
applyOpacity()
}

override fun getTransparency(): Float = transparency
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/

package org.microg.gms.maps.mapbox.model

import android.util.Log
import com.google.android.gms.maps.model.TileProvider
import java.io.OutputStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong

/**
* Bridges the Google Maps [TileProvider] callback API (which hands back tile bitmaps for a given
* x/y/zoom) to MapLibre, whose raster sources can only fetch tiles from a URL. A tiny loopback HTTP
* server serves each registered tile provider under `http://127.0.0.1:<port>/<overlayId>/{z}/{x}/{y}`,
* so a [com.mapbox.mapboxsdk.style.sources.RasterSource] can render provider tiles (e.g. the Google
* Photos photo-density heatmap) without any remote service.
*/
internal object TileProviderServer {
private const val TAG = "GmsTileServer"

private val providers = ConcurrentHashMap<String, TileProvider>()
private val tokenCounter = AtomicLong(0)
private val executor = Executors.newCachedThreadPool { r -> Thread(r, "GmsTileServer").apply { isDaemon = true } }

@Volatile
private var serverSocket: ServerSocket? = null

var port: Int = -1
private set

@Synchronized
private fun ensureStarted() {
if (serverSocket != null) return
val socket = ServerSocket(0, 16, InetAddress.getByName("127.0.0.1"))
port = socket.localPort
serverSocket = socket
Thread({
while (!socket.isClosed) {
try {
val client = socket.accept()
executor.execute { handle(client) }
} catch (e: Exception) {
if (!socket.isClosed) Log.w(TAG, "accept failed", e)
}
}
}, "GmsTileServer-accept").apply { isDaemon = true }.start()
Log.d(TAG, "Tile provider server listening on 127.0.0.1:$port")
}

/** Register [provider] and return a globally-unique token used to build its tile URL. */
fun register(provider: TileProvider): String {
ensureStarted()
val token = tokenCounter.incrementAndGet().toString()
providers[token] = provider
return token
}

fun unregister(token: String) {
providers.remove(token)
}

/** Build the MapLibre raster tile URL template for a registered provider [token]. */
fun tileUrl(token: String): String = "http://127.0.0.1:$port/$token/{z}/{x}/{y}"

private fun handle(socket: Socket) {
try {
socket.use {
val input = it.getInputStream().bufferedReader()
val requestLine = input.readLine() ?: return
// Drain the rest of the request headers.
while (true) {
val line = input.readLine()
if (line == null || line.isEmpty()) break
}
val output = it.getOutputStream()
// requestLine: "GET /<id>/<z>/<x>/<y> HTTP/1.1"
val target = requestLine.split(' ').getOrNull(1)
val segments = target?.trim('/')?.split('/')
if (segments == null || segments.size < 4) {
writeStatus(output, 400)
return
}
val id = segments[0]
val z = segments[1].toIntOrNull()
val x = segments[2].toIntOrNull()
val y = segments[3].substringBefore('.').toIntOrNull()
val provider = providers[id]
if (provider == null || z == null || x == null || y == null) {
writeStatus(output, 404)
return
}
val tile = try {
provider.getTile(x, y, z)
} catch (e: Exception) {
Log.w(TAG, "getTile($x,$y,$z) failed", e)
null
}
val data = tile?.data
if (tile == null || tile === TileProvider.NO_TILE || data == null) {
writeStatus(output, 404)
return
}
output.write(
("HTTP/1.1 200 OK\r\n" +
"Content-Type: image/png\r\n" +
"Content-Length: ${data.size}\r\n" +
"Cache-Control: max-age=86400\r\n" +
"Connection: close\r\n\r\n").toByteArray()
)
output.write(data)
output.flush()
}
} catch (e: Exception) {
// Client hang-ups are routine while panning; keep them quiet.
Log.v(TAG, "request failed: ${e.message}")
}
}

private fun writeStatus(output: OutputStream, code: Int) {
try {
output.write(("HTTP/1.1 $code Status\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").toByteArray())
output.flush()
} catch (e: Exception) {
// ignore
}
}
}