diff --git a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt index e542fb6a53..0b855ed3d2 100644 --- a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt +++ b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt @@ -117,6 +117,7 @@ class GoogleMapImpl(context: Context, var options: GoogleMapOptions) : AbstractG var groundId = 0L var tileId = 0L + val pendingTileOverlays = mutableSetOf() var storedMapType: Int = options.mapType var mapStyle: MapStyleOptions? = null @@ -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 { @@ -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() @@ -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) diff --git a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/TileOverlay.kt b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/TileOverlay.kt index 0c8f5c68cf..59cfc6fa70 100644 --- a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/TileOverlay.kt +++ b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/TileOverlay.kt @@ -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(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 @@ -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 @@ -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 diff --git a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/TileProviderServer.kt b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/TileProviderServer.kt new file mode 100644 index 0000000000..8e7becc877 --- /dev/null +++ b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/TileProviderServer.kt @@ -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://{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() + 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 //// 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 + } + } +}