From 7c9ac6baa69dc9306092c52205e48b24f873a460 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 1 Oct 2026 16:54:00 -0700 Subject: [PATCH 1/3] Render local raster tiles without HERE network fetch --- .../raster/HereRasterLayerOverlayRenderer.kt | 155 +++++++++++++++--- 1 file changed, 136 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt index 19b2b7b..8e49238 100644 --- a/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt @@ -5,6 +5,9 @@ import com.here.sdk.mapview.MapLayer import com.here.sdk.mapview.MapLayerBuilder import com.here.sdk.mapview.datasource.RasterDataSource import com.here.sdk.mapview.datasource.RasterDataSourceConfiguration +import com.here.sdk.mapview.datasource.RasterTileSource +import com.here.sdk.mapview.datasource.TileKey +import com.here.sdk.mapview.datasource.TileSource import com.here.sdk.mapview.datasource.TileUrlProviderCallback import com.here.sdk.mapview.datasource.TileUrlProviderFactory import com.here.sdk.mapview.datasource.TilingScheme @@ -20,6 +23,8 @@ import com.mapconductor.here.HereViewHolder import java.io.ByteArrayOutputStream import java.net.HttpURLConnection import java.net.URL +import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas @@ -28,6 +33,10 @@ import android.util.Log import android.util.LruCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch class HereRasterLayerOverlayRenderer( private val holder: HereViewHolder, @@ -83,30 +92,46 @@ class HereRasterLayerOverlayRenderer( } val urlProvider = tileSpec.provider val storageLevels = tileSpec.storageLevels - val dataSourceProvider = - RasterDataSourceConfiguration.Provider( - urlProvider, - TilingScheme.QUAD_TREE_MERCATOR, - storageLevels, - ) - dataSourceProvider.hasAlphaChannel = true - val cache = - RasterDataSourceConfiguration.Cache( - holder.mapView.context.cacheDir.absolutePath, - ) - val config = - RasterDataSourceConfiguration( - tileSpec.sourceName, - dataSourceProvider, - cache, - ) - val dataSource = RasterDataSource(holder.mapView.mapContext, config) + // Tiles this process draws are handed over in process. HERE fetches + // through its own network stack and, while the device reports no + // connectivity, does not fetch at all -- not even from 127.0.0.1 -- + // so a URL-fed source goes blank the moment the network is away. + // A tile source is asked for bytes and answered from the same + // providers, with no network in between. + val localTemplate = tileSpec.localTemplate + val dataSource = + if (localTemplate != null) { + RasterDataSource( + holder.mapView.mapContext, + tileSpec.sourceName, + LocalRasterTileSource(tileServer, localTemplate, storageLevels), + ) + } else { + val dataSourceProvider = + RasterDataSourceConfiguration.Provider( + urlProvider, + TilingScheme.QUAD_TREE_MERCATOR, + storageLevels, + ) + dataSourceProvider.hasAlphaChannel = true + val cache = + RasterDataSourceConfiguration.Cache( + holder.mapView.context.cacheDir.absolutePath, + ) + val config = + RasterDataSourceConfiguration( + tileSpec.sourceName, + dataSourceProvider, + cache, + ) + RasterDataSource(holder.mapView.mapContext, config) + } return try { val layer = MapLayerBuilder() .withName(tileSpec.layerName) - .withDataSource(config.name, MapContentType.RASTER_IMAGE) + .withDataSource(tileSpec.sourceName, MapContentType.RASTER_IMAGE) .forMap(holder.mapView.hereMap) .build() layer.setEnabled(state.visible) @@ -179,6 +204,12 @@ class HereRasterLayerOverlayRenderer( sourceName = "raster-source-${state.id}", layerName = "raster-layer-${state.id}", storageLevels = buildStorageLevels(source.minZoom, source.maxZoom), + // The proxy route is local by construction; a direct + // template is local when it is one of the server's own. + localTemplate = + template.takeIf { + it.startsWith(tileServer.baseUrl + "/") && (routeId != null || source.scheme != TileScheme.TMS) + }, ) } is RasterLayerSource.TileJson -> { @@ -235,9 +266,95 @@ class HereRasterLayerOverlayRenderer( val sourceName: String, val layerName: String, val storageLevels: List, + /** The XYZ template on this process's tile server, when the tiles come from there. */ + val localTemplate: String? = null, ) } +/** + * A HERE tile source answered from this process's tile server, with no HTTP + * hop and no dependence on the device's connectivity. + */ +private class LocalRasterTileSource( + private val tileServer: LocalTileServer, + private val template: String, + private val levels: List, +) : RasterTileSource { + override fun getDataVersion(tileKey: TileKey): TileSource.DataVersion = VERSION + + override fun addListener(listener: TileSource.Listener) {} + + override fun removeListener(listener: TileSource.Listener) {} + + override fun getTilingScheme(): TilingScheme = TilingScheme.QUAD_TREE_MERCATOR + + override fun getStorageLevels(): List = levels + + override fun loadTile( + tileKey: TileKey, + handler: RasterTileSource.LoadResultHandler, + ): TileSource.LoadTileRequestHandle { + // Exactly one answer per request, whatever happens. A request HERE + // cancels is still one it is waiting on: leave it unanswered and the + // slot is never freed, and after a few gestures HERE stops asking for + // tiles at all -- the map keeps the last picture and looks frozen. + val answered = AtomicBoolean(false) + fun answer(bytes: ByteArray?) { + if (!answered.compareAndSet(false, true)) return + if (bytes != null) handler.loaded(tileKey, bytes, METADATA) else handler.failed(tileKey) + } + // HERE counts rows from the south (Tokyo at z12 is row 2483, not + // 1612); the templates count from the north, as XYZ does. + val row = (1 shl tileKey.level) - 1 - tileKey.y + val url = + template + .replace("{z}", tileKey.level.toString()) + .replace("{x}", tileKey.x.toString()) + .replace("{y}", row.toString()) + val job = + scope.launch { + answer(runCatching { tileServer.renderLocalTile(url) { !isActive } }.getOrNull()) + } + // Cancelled, or the coroutine never ran: answered from a render + // thread rather than from whichever thread cancelled it. + job.invokeOnCompletion { if (!answered.get()) scope.launch { answer(null) } } + return TileSource.LoadTileRequestHandle { job.cancel() } + } + + /** + * Renders run here and nowhere else. + * + * HERE asks for a screenful at once and every one of those is a tile to + * draw. On `Dispatchers.Default` they take every thread the rest of the + * SDK shares -- camera callbacks, overlays, the vector renderer itself -- + * and the map stops answering gestures while it fills. This is the same + * width, and the same reasoning, as the tile server's own worker pool, + * which is what the HTTP path went through. + */ + private val scope = CoroutineScope(SupervisorJob() + renderDispatcher) + + private companion object { + @OptIn(ExperimentalCoroutinesApi::class) + val renderDispatcher = Dispatchers.Default.limitedParallelism(RENDER_WIDTH) + + /** Tile rendering is CPU-bound; more workers than cores adds contention, not throughput. */ + const val RENDER_WIDTH = 8 + + val VERSION = TileSource.DataVersion(0, 0) + + /** + * Tiles drawn here change only with their URL, so they are given a + * long life -- but a plausible one. A date near the end of `Long` + * does not survive the trip into the SDK: every tile came back + * already expired, the same one was asked for again every 400 ms, + * and after a pinch the map stopped asking for tiles at all. + */ + val METADATA = TileSource.TileMetadata(VERSION, Date(System.currentTimeMillis() + TEN_YEARS_MS)) + + const val TEN_YEARS_MS = 10L * 365 * 24 * 60 * 60 * 1000 + } +} + data class HereRasterLayerHandle( val dataSource: RasterDataSource, val layer: MapLayer, From cf2a264e30093b6fa8ddcbe5424adbdddcc459cb Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:52 -0700 Subject: [PATCH 2/3] feat: integrate vector style support and pending map fixes --- api/android-for-here.api | 22 +- .../java/com/mapconductor/here/HereMapView.kt | 12 + .../raster/HereRasterLayerOverlayRenderer.kt | 926 +++++++++--------- 3 files changed, 496 insertions(+), 464 deletions(-) diff --git a/api/android-for-here.api b/api/android-for-here.api index 49d14d6..ecca64e 100644 --- a/api/android-for-here.api +++ b/api/android-for-here.api @@ -172,7 +172,7 @@ public final class com.mapconductor.here.HereMapViewControllerStore extends com. public final void initSDK(android.content.Context); } public final class com.mapconductor.here.HereMapViewKt { - public static final void HereMapView(com.mapconductor.here.HereViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapProjection, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void HereMapView(com.mapconductor.here.HereViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapProjection, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.here.HereMapViewSaver extends com.mapconductor.compose.map.BaseMapViewSaver { public com.mapconductor.here.HereMapViewSaver(); @@ -400,11 +400,13 @@ final class com.mapconductor.here.raster.HereRasterLayerOverlayRenderer$TileSpec public final java.lang.String getSourceName(); public final java.lang.String getLayerName(); public final java.util.List getStorageLevels(); + public final java.lang.String getLocalTemplate(); public final com.here.sdk.mapview.datasource.TileUrlProviderCallback component1(); public final java.lang.String component2(); public final java.lang.String component3(); public final java.util.List component4(); - public final com.mapconductor.here.raster.HereRasterLayerOverlayRenderer$TileSpec copy(com.here.sdk.mapview.datasource.TileUrlProviderCallback, java.lang.String, java.lang.String, java.util.List); + public final java.lang.String component5(); + public final com.mapconductor.here.raster.HereRasterLayerOverlayRenderer$TileSpec copy(com.here.sdk.mapview.datasource.TileUrlProviderCallback, java.lang.String, java.lang.String, java.util.List, java.lang.String); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); @@ -417,6 +419,22 @@ final class com.mapconductor.here.raster.HereRasterTileProxyProvider implements } public final class com.mapconductor.here.raster.HereRasterTileProxyProvider$Companion { } +final class com.mapconductor.here.raster.LocalRasterTileSource implements com.here.sdk.mapview.datasource.RasterTileSource { + public static final int RENDER_WIDTH; + public static final long TEN_YEARS_MS; + public com.mapconductor.here.raster.LocalRasterTileSource(com.mapconductor.core.tileserver.LocalTileServer, java.lang.String, java.util.List); + public com.here.sdk.mapview.datasource.TileSource$DataVersion getDataVersion(com.here.sdk.mapview.datasource.TileKey); + public void addListener(com.here.sdk.mapview.datasource.TileSource$Listener); + public void removeListener(com.here.sdk.mapview.datasource.TileSource$Listener); + public com.here.sdk.mapview.datasource.TilingScheme getTilingScheme(); + public java.util.List getStorageLevels(); + public com.here.sdk.mapview.datasource.TileSource$LoadTileRequestHandle loadTile(com.here.sdk.mapview.datasource.TileKey, com.here.sdk.mapview.datasource.RasterTileSource$LoadResultHandler); +} +final class com.mapconductor.here.raster.LocalRasterTileSource$Companion { + public final kotlinx.coroutines.CoroutineDispatcher getRenderDispatcher(); + public final com.here.sdk.mapview.datasource.TileSource$DataVersion getVERSION(); + public final com.here.sdk.mapview.datasource.TileSource$TileMetadata getMETADATA(); +} public final class com.mapconductor.here.zoom.ZoomAltitudeConverter extends com.mapconductor.core.zoom.AbstractZoomAltitudeConverter { public static final com.mapconductor.here.zoom.ZoomAltitudeConverter$Companion Companion; public static final double HERE_ZOOM_TO_GOOGLE_ZOOM_AT_EQUATOR; diff --git a/src/main/java/com/mapconductor/here/HereMapView.kt b/src/main/java/com/mapconductor/here/HereMapView.kt index fe90bd8..4fcd793 100644 --- a/src/main/java/com/mapconductor/here/HereMapView.kt +++ b/src/main/java/com/mapconductor/here/HereMapView.kt @@ -22,6 +22,7 @@ import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapProjection +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.marker.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerOverlayRendererInterface import com.mapconductor.core.marker.MarkerRenderingStrategyInterface @@ -63,6 +64,15 @@ fun HereMapView( onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, projection: MapProjection = MapProjection.Globe, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable HereViewScope.() -> Unit)? = null, ) { // Warmup the tile server early to reduce latency for raster layers @@ -254,6 +264,8 @@ fun HereMapView( // Pass content if it needs to be rendered within the overlay providers in MapViewBase, // or handle it here if it's specific to HereMapView structure before calling MapViewBase. // For now, assuming content relates to overlay definitions. + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, // This might need adjustment based on how overlays are handled ) } diff --git a/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt index 8e49238..a73bf25 100644 --- a/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt @@ -1,462 +1,464 @@ -package com.mapconductor.here.raster - -import com.here.sdk.mapview.MapContentType -import com.here.sdk.mapview.MapLayer -import com.here.sdk.mapview.MapLayerBuilder -import com.here.sdk.mapview.datasource.RasterDataSource -import com.here.sdk.mapview.datasource.RasterDataSourceConfiguration -import com.here.sdk.mapview.datasource.RasterTileSource -import com.here.sdk.mapview.datasource.TileKey -import com.here.sdk.mapview.datasource.TileSource -import com.here.sdk.mapview.datasource.TileUrlProviderCallback -import com.here.sdk.mapview.datasource.TileUrlProviderFactory -import com.here.sdk.mapview.datasource.TilingScheme -import com.mapconductor.core.raster.RasterLayerEntityInterface -import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface -import com.mapconductor.core.raster.RasterLayerSource -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.core.raster.TileScheme -import com.mapconductor.core.tileserver.LocalTileServer -import com.mapconductor.core.tileserver.TileProviderInterface -import com.mapconductor.core.tileserver.TileRequest -import com.mapconductor.here.HereViewHolder -import java.io.ByteArrayOutputStream -import java.net.HttpURLConnection -import java.net.URL -import java.util.Date -import java.util.concurrent.atomic.AtomicBoolean -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Paint -import android.util.Log -import android.util.LruCache -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -class HereRasterLayerOverlayRenderer( - private val holder: HereViewHolder, - private val tileServer: LocalTileServer, - override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), -) : RasterLayerOverlayRendererInterface { - override suspend fun onAdd( - data: List, - ): List = - data.map { params -> - addLayer(params.state) - } - - override suspend fun onChange( - data: List>, - ): List = - data.map { params -> - val prev = params.prev - val next = params.current.state - if (prev.state.source != next.source || prev.state.opacity != next.opacity) { - removeLayer(prev) - addLayer(next) - } else { - updateLayer(prev.layer, next) - prev.layer - } - } - - override suspend fun onRemove(data: List>) { - data.forEach { entity -> - removeLayer(entity) - } - } - - override suspend fun onPostProcess() {} - - private fun addLayer(state: RasterLayerState): HereRasterLayerHandle? { - val routeId = - if (needsOpacityProxy(state)) { - "here-raster-" + safeId(state.id) - } else { - null - } - if (routeId != null) { - tileServer.register(routeId, HereRasterTileProxyProvider(state)) - } - - val tileSpec = resolveTileSpec(state, routeId) - if (tileSpec == null) { - routeId?.let { tileServer.unregister(it) } - Log.e("HereRasterLayer", "resolveTileSpec returned null!") - return null - } - val urlProvider = tileSpec.provider - val storageLevels = tileSpec.storageLevels - // Tiles this process draws are handed over in process. HERE fetches - // through its own network stack and, while the device reports no - // connectivity, does not fetch at all -- not even from 127.0.0.1 -- - // so a URL-fed source goes blank the moment the network is away. - // A tile source is asked for bytes and answered from the same - // providers, with no network in between. - val localTemplate = tileSpec.localTemplate - val dataSource = - if (localTemplate != null) { - RasterDataSource( - holder.mapView.mapContext, - tileSpec.sourceName, - LocalRasterTileSource(tileServer, localTemplate, storageLevels), - ) - } else { - val dataSourceProvider = - RasterDataSourceConfiguration.Provider( - urlProvider, - TilingScheme.QUAD_TREE_MERCATOR, - storageLevels, - ) - dataSourceProvider.hasAlphaChannel = true - val cache = - RasterDataSourceConfiguration.Cache( - holder.mapView.context.cacheDir.absolutePath, - ) - val config = - RasterDataSourceConfiguration( - tileSpec.sourceName, - dataSourceProvider, - cache, - ) - RasterDataSource(holder.mapView.mapContext, config) - } - - return try { - val layer = - MapLayerBuilder() - .withName(tileSpec.layerName) - .withDataSource(tileSpec.sourceName, MapContentType.RASTER_IMAGE) - .forMap(holder.mapView.hereMap) - .build() - layer.setEnabled(state.visible) - HereRasterLayerHandle( - dataSource = dataSource, - layer = layer, - sourceName = tileSpec.sourceName, - layerName = tileSpec.layerName, - routeId = routeId, - ) - } catch (e: MapLayerBuilder.InstantiationException) { - routeId?.let { tileServer.unregister(it) } - dataSource.destroy() - Log.e("HereRasterLayer", "Failed to create raster layer: ${e.message}", e) - null - } - } - - private fun updateLayer( - handle: HereRasterLayerHandle, - state: RasterLayerState, - ) { - handle.layer.setEnabled(state.visible) - } - - private fun removeLayer(entity: RasterLayerEntityInterface) { - val handle = entity.layer - handle.routeId?.let { tileServer.unregister(it) } - handle.layer.destroy() - handle.dataSource.destroy() - } - - private fun resolveTileSpec( - state: RasterLayerState, - routeId: String?, - ): TileSpec? = - when (val source = state.source) { - is RasterLayerSource.UrlTemplate -> { - val template = - if (routeId == null) { - source.template - } else { - tileServer.urlTemplateWithQueryCacheKey( - routeId, - source.tileSize, - state.fingerPrint().hashCode().toString(), - ) - } - val provider = - if (routeId == null && source.scheme == TileScheme.TMS) { - TileUrlProviderCallback { x, y, zoom -> - val max = 1 shl zoom - val tmsY = max - 1 - y - template - .replace("{x}", x.toString()) - .replace("{y}", tmsY.toString()) - .replace("{z}", zoom.toString()) - } - } else { - TileUrlProviderFactory.fromXyzUrlTemplate(template) - ?: TileUrlProviderCallback { x, y, zoom -> - template - .replace("{x}", x.toString()) - .replace("{y}", y.toString()) - .replace("{z}", zoom.toString()) - } - } - TileSpec( - provider = provider, - sourceName = "raster-source-${state.id}", - layerName = "raster-layer-${state.id}", - storageLevels = buildStorageLevels(source.minZoom, source.maxZoom), - // The proxy route is local by construction; a direct - // template is local when it is one of the server's own. - localTemplate = - template.takeIf { - it.startsWith(tileServer.baseUrl + "/") && (routeId != null || source.scheme != TileScheme.TMS) - }, - ) - } - is RasterLayerSource.TileJson -> { - Log.w("HereRasterLayer", "HERE SDK does not support TileJson raster sources.") - null - } - is RasterLayerSource.ArcGisService -> { - val template = - if (routeId == null) { - val base = source.serviceUrl.trimEnd('/') - "$base/tile/{z}/{y}/{x}" - } else { - tileServer.urlTemplateWithQueryCacheKey( - routeId, - RasterLayerSource.DEFAULT_TILE_SIZE, - state.fingerPrint().hashCode().toString(), - ) - } - val provider = - TileUrlProviderFactory.fromXyzUrlTemplate(template) - ?: return null - TileSpec( - provider = provider, - sourceName = "raster-source-${state.id}", - layerName = "raster-layer-${state.id}", - storageLevels = buildStorageLevels(null, null), - ) - } - } - - private fun buildStorageLevels( - minZoom: Int?, - maxZoom: Int?, - ): List { - val min = minZoom ?: 0 - val max = maxZoom ?: 20 - return (min..max).toList() - } - - private fun needsOpacityProxy(state: RasterLayerState): Boolean = state.opacity.coerceIn(0.0f, 1.0f) < 0.999f - - private fun safeId(id: String): String = - id - .map { ch -> - when { - ch.isLetterOrDigit() -> ch - ch == '-' || ch == '_' || ch == '.' -> ch - else -> '_' - } - }.joinToString("") - - private data class TileSpec( - val provider: TileUrlProviderCallback, - val sourceName: String, - val layerName: String, - val storageLevels: List, - /** The XYZ template on this process's tile server, when the tiles come from there. */ - val localTemplate: String? = null, - ) -} - -/** - * A HERE tile source answered from this process's tile server, with no HTTP - * hop and no dependence on the device's connectivity. - */ -private class LocalRasterTileSource( - private val tileServer: LocalTileServer, - private val template: String, - private val levels: List, -) : RasterTileSource { - override fun getDataVersion(tileKey: TileKey): TileSource.DataVersion = VERSION - - override fun addListener(listener: TileSource.Listener) {} - - override fun removeListener(listener: TileSource.Listener) {} - - override fun getTilingScheme(): TilingScheme = TilingScheme.QUAD_TREE_MERCATOR - - override fun getStorageLevels(): List = levels - - override fun loadTile( - tileKey: TileKey, - handler: RasterTileSource.LoadResultHandler, - ): TileSource.LoadTileRequestHandle { - // Exactly one answer per request, whatever happens. A request HERE - // cancels is still one it is waiting on: leave it unanswered and the - // slot is never freed, and after a few gestures HERE stops asking for - // tiles at all -- the map keeps the last picture and looks frozen. - val answered = AtomicBoolean(false) - fun answer(bytes: ByteArray?) { - if (!answered.compareAndSet(false, true)) return - if (bytes != null) handler.loaded(tileKey, bytes, METADATA) else handler.failed(tileKey) - } - // HERE counts rows from the south (Tokyo at z12 is row 2483, not - // 1612); the templates count from the north, as XYZ does. - val row = (1 shl tileKey.level) - 1 - tileKey.y - val url = - template - .replace("{z}", tileKey.level.toString()) - .replace("{x}", tileKey.x.toString()) - .replace("{y}", row.toString()) - val job = - scope.launch { - answer(runCatching { tileServer.renderLocalTile(url) { !isActive } }.getOrNull()) - } - // Cancelled, or the coroutine never ran: answered from a render - // thread rather than from whichever thread cancelled it. - job.invokeOnCompletion { if (!answered.get()) scope.launch { answer(null) } } - return TileSource.LoadTileRequestHandle { job.cancel() } - } - - /** - * Renders run here and nowhere else. - * - * HERE asks for a screenful at once and every one of those is a tile to - * draw. On `Dispatchers.Default` they take every thread the rest of the - * SDK shares -- camera callbacks, overlays, the vector renderer itself -- - * and the map stops answering gestures while it fills. This is the same - * width, and the same reasoning, as the tile server's own worker pool, - * which is what the HTTP path went through. - */ - private val scope = CoroutineScope(SupervisorJob() + renderDispatcher) - - private companion object { - @OptIn(ExperimentalCoroutinesApi::class) - val renderDispatcher = Dispatchers.Default.limitedParallelism(RENDER_WIDTH) - - /** Tile rendering is CPU-bound; more workers than cores adds contention, not throughput. */ - const val RENDER_WIDTH = 8 - - val VERSION = TileSource.DataVersion(0, 0) - - /** - * Tiles drawn here change only with their URL, so they are given a - * long life -- but a plausible one. A date near the end of `Long` - * does not survive the trip into the SDK: every tile came back - * already expired, the same one was asked for again every 400 ms, - * and after a pinch the map stopped asking for tiles at all. - */ - val METADATA = TileSource.TileMetadata(VERSION, Date(System.currentTimeMillis() + TEN_YEARS_MS)) - - const val TEN_YEARS_MS = 10L * 365 * 24 * 60 * 60 * 1000 - } -} - -data class HereRasterLayerHandle( - val dataSource: RasterDataSource, - val layer: MapLayer, - val sourceName: String, - val layerName: String, - val routeId: String?, -) - -private class HereRasterTileProxyProvider( - private val state: RasterLayerState, -) : TileProviderInterface { - private val fetchCacheLock = Any() - private val fetchCache = - object : LruCache(DEFAULT_FETCH_CACHE_SIZE_BYTES) { - override fun sizeOf( - key: String, - value: ByteArray, - ): Int = value.size - } - - override fun renderTile(request: TileRequest): ByteArray? { - val url = resolveUrl(request) ?: return null - val bytes = fetch(url) ?: return null - return applyOpacity(bytes, state.opacity) - } - - private fun resolveUrl(request: TileRequest): String? = - when (val source = state.source) { - is RasterLayerSource.UrlTemplate -> { - val y = - if (source.scheme == TileScheme.TMS) { - (1 shl request.z) - 1 - request.y - } else { - request.y - } - source.template - .replace("{x}", request.x.toString()) - .replace("{y}", y.toString()) - .replace("{z}", request.z.toString()) - } - is RasterLayerSource.ArcGisService -> - "${source.serviceUrl.trimEnd('/')}/tile/${request.z}/${request.y}/${request.x}" - is RasterLayerSource.TileJson -> null - } - - private fun fetch(url: String): ByteArray? { - synchronized(fetchCacheLock) { - fetchCache.get(url)?.let { return it } - } - - val connection = - (URL(url).openConnection() as HttpURLConnection).apply { - connectTimeout = 5000 - readTimeout = 10000 - requestMethod = "GET" - setRequestProperty("User-Agent", state.userAgent) - state.extraHeaders?.forEach { (key, value) -> - setRequestProperty(key, value) - } - } - return try { - if (connection.responseCode !in 200..299) { - return null - } - val bytes = connection.inputStream.use { it.readBytes() } - synchronized(fetchCacheLock) { - fetchCache.put(url, bytes) - } - bytes - } catch (_: Exception) { - null - } finally { - connection.disconnect() - } - } - - private fun applyOpacity( - bytes: ByteArray, - opacity: Float, - ): ByteArray? { - val safeOpacity = opacity.coerceIn(0.0f, 1.0f) - if (safeOpacity >= 0.999f) { - return bytes - } - - val source = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null - val output = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(output) - val paint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - alpha = (safeOpacity * 255.0f).toInt().coerceIn(0, 255) - } - canvas.drawBitmap(source, 0.0f, 0.0f, paint) - - val stream = ByteArrayOutputStream() - output.compress(Bitmap.CompressFormat.PNG, 100, stream) - output.recycle() - source.recycle() - return stream.toByteArray() - } - - companion object { - private const val DEFAULT_FETCH_CACHE_SIZE_BYTES = 16 * 1024 * 1024 - } -} +package com.mapconductor.here.raster + +import com.here.sdk.mapview.MapContentType +import com.here.sdk.mapview.MapLayer +import com.here.sdk.mapview.MapLayerBuilder +import com.here.sdk.mapview.datasource.RasterDataSource +import com.here.sdk.mapview.datasource.RasterDataSourceConfiguration +import com.here.sdk.mapview.datasource.RasterTileSource +import com.here.sdk.mapview.datasource.TileKey +import com.here.sdk.mapview.datasource.TileSource +import com.here.sdk.mapview.datasource.TileUrlProviderCallback +import com.here.sdk.mapview.datasource.TileUrlProviderFactory +import com.here.sdk.mapview.datasource.TilingScheme +import com.mapconductor.core.raster.RasterLayerEntityInterface +import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.raster.TileScheme +import com.mapconductor.core.tileserver.LocalTileServer +import com.mapconductor.core.tileserver.TileProviderInterface +import com.mapconductor.core.tileserver.TileRequest +import com.mapconductor.here.HereViewHolder +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.util.Log +import android.util.LruCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class HereRasterLayerOverlayRenderer( + private val holder: HereViewHolder, + private val tileServer: LocalTileServer, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), +) : RasterLayerOverlayRendererInterface { + override suspend fun onAdd( + data: List, + ): List = + data.map { params -> + addLayer(params.state) + } + + override suspend fun onChange( + data: List>, + ): List = + data.map { params -> + val prev = params.prev + val next = params.current.state + if (prev.state.source != next.source || prev.state.opacity != next.opacity) { + removeLayer(prev) + addLayer(next) + } else { + updateLayer(prev.layer, next) + prev.layer + } + } + + override suspend fun onRemove(data: List>) { + data.forEach { entity -> + removeLayer(entity) + } + } + + override suspend fun onPostProcess() {} + + private fun addLayer(state: RasterLayerState): HereRasterLayerHandle? { + val routeId = + if (needsOpacityProxy(state)) { + "here-raster-" + safeId(state.id) + } else { + null + } + if (routeId != null) { + tileServer.register(routeId, HereRasterTileProxyProvider(state)) + } + + val tileSpec = resolveTileSpec(state, routeId) + if (tileSpec == null) { + routeId?.let { tileServer.unregister(it) } + Log.e("HereRasterLayer", "resolveTileSpec returned null!") + return null + } + val urlProvider = tileSpec.provider + val storageLevels = tileSpec.storageLevels + // Tiles this process draws are handed over in process. HERE fetches + // through its own network stack and, while the device reports no + // connectivity, does not fetch at all -- not even from 127.0.0.1 -- + // so a URL-fed source goes blank the moment the network is away. + // A tile source is asked for bytes and answered from the same + // providers, with no network in between. + val localTemplate = tileSpec.localTemplate + val dataSource = + if (localTemplate != null) { + RasterDataSource( + holder.mapView.mapContext, + tileSpec.sourceName, + LocalRasterTileSource(tileServer, localTemplate, storageLevels), + ) + } else { + val dataSourceProvider = + RasterDataSourceConfiguration.Provider( + urlProvider, + TilingScheme.QUAD_TREE_MERCATOR, + storageLevels, + ) + dataSourceProvider.hasAlphaChannel = true + val cache = + RasterDataSourceConfiguration.Cache( + holder.mapView.context.cacheDir.absolutePath, + ) + val config = + RasterDataSourceConfiguration( + tileSpec.sourceName, + dataSourceProvider, + cache, + ) + RasterDataSource(holder.mapView.mapContext, config) + } + + return try { + val layer = + MapLayerBuilder() + .withName(tileSpec.layerName) + .withDataSource(tileSpec.sourceName, MapContentType.RASTER_IMAGE) + .forMap(holder.mapView.hereMap) + .build() + layer.setEnabled(state.visible) + HereRasterLayerHandle( + dataSource = dataSource, + layer = layer, + sourceName = tileSpec.sourceName, + layerName = tileSpec.layerName, + routeId = routeId, + ) + } catch (e: MapLayerBuilder.InstantiationException) { + routeId?.let { tileServer.unregister(it) } + dataSource.destroy() + Log.e("HereRasterLayer", "Failed to create raster layer: ${e.message}", e) + null + } + } + + private fun updateLayer( + handle: HereRasterLayerHandle, + state: RasterLayerState, + ) { + handle.layer.setEnabled(state.visible) + } + + private fun removeLayer(entity: RasterLayerEntityInterface) { + val handle = entity.layer + handle.routeId?.let { tileServer.unregister(it) } + handle.layer.destroy() + handle.dataSource.destroy() + } + + private fun resolveTileSpec( + state: RasterLayerState, + routeId: String?, + ): TileSpec? = + when (val source = state.source) { + is RasterLayerSource.UrlTemplate -> { + val template = + if (routeId == null) { + source.template + } else { + tileServer.urlTemplateWithQueryCacheKey( + routeId, + source.tileSize, + state.fingerPrint().hashCode().toString(), + ) + } + val provider = + if (routeId == null && source.scheme == TileScheme.TMS) { + TileUrlProviderCallback { x, y, zoom -> + val max = 1 shl zoom + val tmsY = max - 1 - y + template + .replace("{x}", x.toString()) + .replace("{y}", tmsY.toString()) + .replace("{z}", zoom.toString()) + } + } else { + TileUrlProviderFactory.fromXyzUrlTemplate(template) + ?: TileUrlProviderCallback { x, y, zoom -> + template + .replace("{x}", x.toString()) + .replace("{y}", y.toString()) + .replace("{z}", zoom.toString()) + } + } + TileSpec( + provider = provider, + sourceName = "raster-source-${state.id}", + layerName = "raster-layer-${state.id}", + storageLevels = buildStorageLevels(source.minZoom, source.maxZoom), + // The proxy route is local by construction; a direct + // template is local when it is one of the server's own. + localTemplate = + template.takeIf { + it.startsWith(tileServer.baseUrl + "/") && + (routeId != null || source.scheme != TileScheme.TMS) + }, + ) + } + is RasterLayerSource.TileJson -> { + Log.w("HereRasterLayer", "HERE SDK does not support TileJson raster sources.") + null + } + is RasterLayerSource.ArcGisService -> { + val template = + if (routeId == null) { + val base = source.serviceUrl.trimEnd('/') + "$base/tile/{z}/{y}/{x}" + } else { + tileServer.urlTemplateWithQueryCacheKey( + routeId, + RasterLayerSource.DEFAULT_TILE_SIZE, + state.fingerPrint().hashCode().toString(), + ) + } + val provider = + TileUrlProviderFactory.fromXyzUrlTemplate(template) + ?: return null + TileSpec( + provider = provider, + sourceName = "raster-source-${state.id}", + layerName = "raster-layer-${state.id}", + storageLevels = buildStorageLevels(null, null), + ) + } + } + + private fun buildStorageLevels( + minZoom: Int?, + maxZoom: Int?, + ): List { + val min = minZoom ?: 0 + val max = maxZoom ?: 20 + return (min..max).toList() + } + + private fun needsOpacityProxy(state: RasterLayerState): Boolean = state.opacity.coerceIn(0.0f, 1.0f) < 0.999f + + private fun safeId(id: String): String = + id + .map { ch -> + when { + ch.isLetterOrDigit() -> ch + ch == '-' || ch == '_' || ch == '.' -> ch + else -> '_' + } + }.joinToString("") + + private data class TileSpec( + val provider: TileUrlProviderCallback, + val sourceName: String, + val layerName: String, + val storageLevels: List, + /** The XYZ template on this process's tile server, when the tiles come from there. */ + val localTemplate: String? = null, + ) +} + +/** + * A HERE tile source answered from this process's tile server, with no HTTP + * hop and no dependence on the device's connectivity. + */ +private class LocalRasterTileSource( + private val tileServer: LocalTileServer, + private val template: String, + private val levels: List, +) : RasterTileSource { + override fun getDataVersion(tileKey: TileKey): TileSource.DataVersion = VERSION + + override fun addListener(listener: TileSource.Listener) {} + + override fun removeListener(listener: TileSource.Listener) {} + + override fun getTilingScheme(): TilingScheme = TilingScheme.QUAD_TREE_MERCATOR + + override fun getStorageLevels(): List = levels + + override fun loadTile( + tileKey: TileKey, + handler: RasterTileSource.LoadResultHandler, + ): TileSource.LoadTileRequestHandle { + // Exactly one answer per request, whatever happens. A request HERE + // cancels is still one it is waiting on: leave it unanswered and the + // slot is never freed, and after a few gestures HERE stops asking for + // tiles at all -- the map keeps the last picture and looks frozen. + val answered = AtomicBoolean(false) + + fun answer(bytes: ByteArray?) { + if (!answered.compareAndSet(false, true)) return + if (bytes != null) handler.loaded(tileKey, bytes, METADATA) else handler.failed(tileKey) + } + // HERE counts rows from the south (Tokyo at z12 is row 2483, not + // 1612); the templates count from the north, as XYZ does. + val row = (1 shl tileKey.level) - 1 - tileKey.y + val url = + template + .replace("{z}", tileKey.level.toString()) + .replace("{x}", tileKey.x.toString()) + .replace("{y}", row.toString()) + val job = + scope.launch { + answer(runCatching { tileServer.renderLocalTile(url) { !isActive } }.getOrNull()) + } + // Cancelled, or the coroutine never ran: answered from a render + // thread rather than from whichever thread cancelled it. + job.invokeOnCompletion { if (!answered.get()) scope.launch { answer(null) } } + return TileSource.LoadTileRequestHandle { job.cancel() } + } + + /** + * Renders run here and nowhere else. + * + * HERE asks for a screenful at once and every one of those is a tile to + * draw. On `Dispatchers.Default` they take every thread the rest of the + * SDK shares -- camera callbacks, overlays, the vector renderer itself -- + * and the map stops answering gestures while it fills. This is the same + * width, and the same reasoning, as the tile server's own worker pool, + * which is what the HTTP path went through. + */ + private val scope = CoroutineScope(SupervisorJob() + renderDispatcher) + + private companion object { + @OptIn(ExperimentalCoroutinesApi::class) + val renderDispatcher = Dispatchers.Default.limitedParallelism(RENDER_WIDTH) + + /** Tile rendering is CPU-bound; more workers than cores adds contention, not throughput. */ + const val RENDER_WIDTH = 8 + + val VERSION = TileSource.DataVersion(0, 0) + + /** + * Tiles drawn here change only with their URL, so they are given a + * long life -- but a plausible one. A date near the end of `Long` + * does not survive the trip into the SDK: every tile came back + * already expired, the same one was asked for again every 400 ms, + * and after a pinch the map stopped asking for tiles at all. + */ + val METADATA = TileSource.TileMetadata(VERSION, Date(System.currentTimeMillis() + TEN_YEARS_MS)) + + const val TEN_YEARS_MS = 10L * 365 * 24 * 60 * 60 * 1000 + } +} + +data class HereRasterLayerHandle( + val dataSource: RasterDataSource, + val layer: MapLayer, + val sourceName: String, + val layerName: String, + val routeId: String?, +) + +private class HereRasterTileProxyProvider( + private val state: RasterLayerState, +) : TileProviderInterface { + private val fetchCacheLock = Any() + private val fetchCache = + object : LruCache(DEFAULT_FETCH_CACHE_SIZE_BYTES) { + override fun sizeOf( + key: String, + value: ByteArray, + ): Int = value.size + } + + override fun renderTile(request: TileRequest): ByteArray? { + val url = resolveUrl(request) ?: return null + val bytes = fetch(url) ?: return null + return applyOpacity(bytes, state.opacity) + } + + private fun resolveUrl(request: TileRequest): String? = + when (val source = state.source) { + is RasterLayerSource.UrlTemplate -> { + val y = + if (source.scheme == TileScheme.TMS) { + (1 shl request.z) - 1 - request.y + } else { + request.y + } + source.template + .replace("{x}", request.x.toString()) + .replace("{y}", y.toString()) + .replace("{z}", request.z.toString()) + } + is RasterLayerSource.ArcGisService -> + "${source.serviceUrl.trimEnd('/')}/tile/${request.z}/${request.y}/${request.x}" + is RasterLayerSource.TileJson -> null + } + + private fun fetch(url: String): ByteArray? { + synchronized(fetchCacheLock) { + fetchCache.get(url)?.let { return it } + } + + val connection = + (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 5000 + readTimeout = 10000 + requestMethod = "GET" + setRequestProperty("User-Agent", state.userAgent) + state.extraHeaders?.forEach { (key, value) -> + setRequestProperty(key, value) + } + } + return try { + if (connection.responseCode !in 200..299) { + return null + } + val bytes = connection.inputStream.use { it.readBytes() } + synchronized(fetchCacheLock) { + fetchCache.put(url, bytes) + } + bytes + } catch (_: Exception) { + null + } finally { + connection.disconnect() + } + } + + private fun applyOpacity( + bytes: ByteArray, + opacity: Float, + ): ByteArray? { + val safeOpacity = opacity.coerceIn(0.0f, 1.0f) + if (safeOpacity >= 0.999f) { + return bytes + } + + val source = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + val output = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + alpha = (safeOpacity * 255.0f).toInt().coerceIn(0, 255) + } + canvas.drawBitmap(source, 0.0f, 0.0f, paint) + + val stream = ByteArrayOutputStream() + output.compress(Bitmap.CompressFormat.PNG, 100, stream) + output.recycle() + source.recycle() + return stream.toByteArray() + } + + companion object { + private const val DEFAULT_FETCH_CACHE_SIZE_BYTES = 16 * 1024 * 1024 + } +} From be73465d7e5789115147491da594c15f3c796dfe Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:06 -0700 Subject: [PATCH 3/3] Keep each file's line endings as they are on develop The checkpoint commit rewrote whole files between LF and CRLF, which buried its real changes under line-ending noise. Content is unchanged. --- .../raster/HereRasterLayerOverlayRenderer.kt | 928 +++++++++--------- 1 file changed, 464 insertions(+), 464 deletions(-) diff --git a/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt index a73bf25..f81b068 100644 --- a/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/here/raster/HereRasterLayerOverlayRenderer.kt @@ -1,464 +1,464 @@ -package com.mapconductor.here.raster - -import com.here.sdk.mapview.MapContentType -import com.here.sdk.mapview.MapLayer -import com.here.sdk.mapview.MapLayerBuilder -import com.here.sdk.mapview.datasource.RasterDataSource -import com.here.sdk.mapview.datasource.RasterDataSourceConfiguration -import com.here.sdk.mapview.datasource.RasterTileSource -import com.here.sdk.mapview.datasource.TileKey -import com.here.sdk.mapview.datasource.TileSource -import com.here.sdk.mapview.datasource.TileUrlProviderCallback -import com.here.sdk.mapview.datasource.TileUrlProviderFactory -import com.here.sdk.mapview.datasource.TilingScheme -import com.mapconductor.core.raster.RasterLayerEntityInterface -import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface -import com.mapconductor.core.raster.RasterLayerSource -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.core.raster.TileScheme -import com.mapconductor.core.tileserver.LocalTileServer -import com.mapconductor.core.tileserver.TileProviderInterface -import com.mapconductor.core.tileserver.TileRequest -import com.mapconductor.here.HereViewHolder -import java.io.ByteArrayOutputStream -import java.net.HttpURLConnection -import java.net.URL -import java.util.Date -import java.util.concurrent.atomic.AtomicBoolean -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Paint -import android.util.Log -import android.util.LruCache -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -class HereRasterLayerOverlayRenderer( - private val holder: HereViewHolder, - private val tileServer: LocalTileServer, - override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), -) : RasterLayerOverlayRendererInterface { - override suspend fun onAdd( - data: List, - ): List = - data.map { params -> - addLayer(params.state) - } - - override suspend fun onChange( - data: List>, - ): List = - data.map { params -> - val prev = params.prev - val next = params.current.state - if (prev.state.source != next.source || prev.state.opacity != next.opacity) { - removeLayer(prev) - addLayer(next) - } else { - updateLayer(prev.layer, next) - prev.layer - } - } - - override suspend fun onRemove(data: List>) { - data.forEach { entity -> - removeLayer(entity) - } - } - - override suspend fun onPostProcess() {} - - private fun addLayer(state: RasterLayerState): HereRasterLayerHandle? { - val routeId = - if (needsOpacityProxy(state)) { - "here-raster-" + safeId(state.id) - } else { - null - } - if (routeId != null) { - tileServer.register(routeId, HereRasterTileProxyProvider(state)) - } - - val tileSpec = resolveTileSpec(state, routeId) - if (tileSpec == null) { - routeId?.let { tileServer.unregister(it) } - Log.e("HereRasterLayer", "resolveTileSpec returned null!") - return null - } - val urlProvider = tileSpec.provider - val storageLevels = tileSpec.storageLevels - // Tiles this process draws are handed over in process. HERE fetches - // through its own network stack and, while the device reports no - // connectivity, does not fetch at all -- not even from 127.0.0.1 -- - // so a URL-fed source goes blank the moment the network is away. - // A tile source is asked for bytes and answered from the same - // providers, with no network in between. - val localTemplate = tileSpec.localTemplate - val dataSource = - if (localTemplate != null) { - RasterDataSource( - holder.mapView.mapContext, - tileSpec.sourceName, - LocalRasterTileSource(tileServer, localTemplate, storageLevels), - ) - } else { - val dataSourceProvider = - RasterDataSourceConfiguration.Provider( - urlProvider, - TilingScheme.QUAD_TREE_MERCATOR, - storageLevels, - ) - dataSourceProvider.hasAlphaChannel = true - val cache = - RasterDataSourceConfiguration.Cache( - holder.mapView.context.cacheDir.absolutePath, - ) - val config = - RasterDataSourceConfiguration( - tileSpec.sourceName, - dataSourceProvider, - cache, - ) - RasterDataSource(holder.mapView.mapContext, config) - } - - return try { - val layer = - MapLayerBuilder() - .withName(tileSpec.layerName) - .withDataSource(tileSpec.sourceName, MapContentType.RASTER_IMAGE) - .forMap(holder.mapView.hereMap) - .build() - layer.setEnabled(state.visible) - HereRasterLayerHandle( - dataSource = dataSource, - layer = layer, - sourceName = tileSpec.sourceName, - layerName = tileSpec.layerName, - routeId = routeId, - ) - } catch (e: MapLayerBuilder.InstantiationException) { - routeId?.let { tileServer.unregister(it) } - dataSource.destroy() - Log.e("HereRasterLayer", "Failed to create raster layer: ${e.message}", e) - null - } - } - - private fun updateLayer( - handle: HereRasterLayerHandle, - state: RasterLayerState, - ) { - handle.layer.setEnabled(state.visible) - } - - private fun removeLayer(entity: RasterLayerEntityInterface) { - val handle = entity.layer - handle.routeId?.let { tileServer.unregister(it) } - handle.layer.destroy() - handle.dataSource.destroy() - } - - private fun resolveTileSpec( - state: RasterLayerState, - routeId: String?, - ): TileSpec? = - when (val source = state.source) { - is RasterLayerSource.UrlTemplate -> { - val template = - if (routeId == null) { - source.template - } else { - tileServer.urlTemplateWithQueryCacheKey( - routeId, - source.tileSize, - state.fingerPrint().hashCode().toString(), - ) - } - val provider = - if (routeId == null && source.scheme == TileScheme.TMS) { - TileUrlProviderCallback { x, y, zoom -> - val max = 1 shl zoom - val tmsY = max - 1 - y - template - .replace("{x}", x.toString()) - .replace("{y}", tmsY.toString()) - .replace("{z}", zoom.toString()) - } - } else { - TileUrlProviderFactory.fromXyzUrlTemplate(template) - ?: TileUrlProviderCallback { x, y, zoom -> - template - .replace("{x}", x.toString()) - .replace("{y}", y.toString()) - .replace("{z}", zoom.toString()) - } - } - TileSpec( - provider = provider, - sourceName = "raster-source-${state.id}", - layerName = "raster-layer-${state.id}", - storageLevels = buildStorageLevels(source.minZoom, source.maxZoom), - // The proxy route is local by construction; a direct - // template is local when it is one of the server's own. - localTemplate = - template.takeIf { - it.startsWith(tileServer.baseUrl + "/") && - (routeId != null || source.scheme != TileScheme.TMS) - }, - ) - } - is RasterLayerSource.TileJson -> { - Log.w("HereRasterLayer", "HERE SDK does not support TileJson raster sources.") - null - } - is RasterLayerSource.ArcGisService -> { - val template = - if (routeId == null) { - val base = source.serviceUrl.trimEnd('/') - "$base/tile/{z}/{y}/{x}" - } else { - tileServer.urlTemplateWithQueryCacheKey( - routeId, - RasterLayerSource.DEFAULT_TILE_SIZE, - state.fingerPrint().hashCode().toString(), - ) - } - val provider = - TileUrlProviderFactory.fromXyzUrlTemplate(template) - ?: return null - TileSpec( - provider = provider, - sourceName = "raster-source-${state.id}", - layerName = "raster-layer-${state.id}", - storageLevels = buildStorageLevels(null, null), - ) - } - } - - private fun buildStorageLevels( - minZoom: Int?, - maxZoom: Int?, - ): List { - val min = minZoom ?: 0 - val max = maxZoom ?: 20 - return (min..max).toList() - } - - private fun needsOpacityProxy(state: RasterLayerState): Boolean = state.opacity.coerceIn(0.0f, 1.0f) < 0.999f - - private fun safeId(id: String): String = - id - .map { ch -> - when { - ch.isLetterOrDigit() -> ch - ch == '-' || ch == '_' || ch == '.' -> ch - else -> '_' - } - }.joinToString("") - - private data class TileSpec( - val provider: TileUrlProviderCallback, - val sourceName: String, - val layerName: String, - val storageLevels: List, - /** The XYZ template on this process's tile server, when the tiles come from there. */ - val localTemplate: String? = null, - ) -} - -/** - * A HERE tile source answered from this process's tile server, with no HTTP - * hop and no dependence on the device's connectivity. - */ -private class LocalRasterTileSource( - private val tileServer: LocalTileServer, - private val template: String, - private val levels: List, -) : RasterTileSource { - override fun getDataVersion(tileKey: TileKey): TileSource.DataVersion = VERSION - - override fun addListener(listener: TileSource.Listener) {} - - override fun removeListener(listener: TileSource.Listener) {} - - override fun getTilingScheme(): TilingScheme = TilingScheme.QUAD_TREE_MERCATOR - - override fun getStorageLevels(): List = levels - - override fun loadTile( - tileKey: TileKey, - handler: RasterTileSource.LoadResultHandler, - ): TileSource.LoadTileRequestHandle { - // Exactly one answer per request, whatever happens. A request HERE - // cancels is still one it is waiting on: leave it unanswered and the - // slot is never freed, and after a few gestures HERE stops asking for - // tiles at all -- the map keeps the last picture and looks frozen. - val answered = AtomicBoolean(false) - - fun answer(bytes: ByteArray?) { - if (!answered.compareAndSet(false, true)) return - if (bytes != null) handler.loaded(tileKey, bytes, METADATA) else handler.failed(tileKey) - } - // HERE counts rows from the south (Tokyo at z12 is row 2483, not - // 1612); the templates count from the north, as XYZ does. - val row = (1 shl tileKey.level) - 1 - tileKey.y - val url = - template - .replace("{z}", tileKey.level.toString()) - .replace("{x}", tileKey.x.toString()) - .replace("{y}", row.toString()) - val job = - scope.launch { - answer(runCatching { tileServer.renderLocalTile(url) { !isActive } }.getOrNull()) - } - // Cancelled, or the coroutine never ran: answered from a render - // thread rather than from whichever thread cancelled it. - job.invokeOnCompletion { if (!answered.get()) scope.launch { answer(null) } } - return TileSource.LoadTileRequestHandle { job.cancel() } - } - - /** - * Renders run here and nowhere else. - * - * HERE asks for a screenful at once and every one of those is a tile to - * draw. On `Dispatchers.Default` they take every thread the rest of the - * SDK shares -- camera callbacks, overlays, the vector renderer itself -- - * and the map stops answering gestures while it fills. This is the same - * width, and the same reasoning, as the tile server's own worker pool, - * which is what the HTTP path went through. - */ - private val scope = CoroutineScope(SupervisorJob() + renderDispatcher) - - private companion object { - @OptIn(ExperimentalCoroutinesApi::class) - val renderDispatcher = Dispatchers.Default.limitedParallelism(RENDER_WIDTH) - - /** Tile rendering is CPU-bound; more workers than cores adds contention, not throughput. */ - const val RENDER_WIDTH = 8 - - val VERSION = TileSource.DataVersion(0, 0) - - /** - * Tiles drawn here change only with their URL, so they are given a - * long life -- but a plausible one. A date near the end of `Long` - * does not survive the trip into the SDK: every tile came back - * already expired, the same one was asked for again every 400 ms, - * and after a pinch the map stopped asking for tiles at all. - */ - val METADATA = TileSource.TileMetadata(VERSION, Date(System.currentTimeMillis() + TEN_YEARS_MS)) - - const val TEN_YEARS_MS = 10L * 365 * 24 * 60 * 60 * 1000 - } -} - -data class HereRasterLayerHandle( - val dataSource: RasterDataSource, - val layer: MapLayer, - val sourceName: String, - val layerName: String, - val routeId: String?, -) - -private class HereRasterTileProxyProvider( - private val state: RasterLayerState, -) : TileProviderInterface { - private val fetchCacheLock = Any() - private val fetchCache = - object : LruCache(DEFAULT_FETCH_CACHE_SIZE_BYTES) { - override fun sizeOf( - key: String, - value: ByteArray, - ): Int = value.size - } - - override fun renderTile(request: TileRequest): ByteArray? { - val url = resolveUrl(request) ?: return null - val bytes = fetch(url) ?: return null - return applyOpacity(bytes, state.opacity) - } - - private fun resolveUrl(request: TileRequest): String? = - when (val source = state.source) { - is RasterLayerSource.UrlTemplate -> { - val y = - if (source.scheme == TileScheme.TMS) { - (1 shl request.z) - 1 - request.y - } else { - request.y - } - source.template - .replace("{x}", request.x.toString()) - .replace("{y}", y.toString()) - .replace("{z}", request.z.toString()) - } - is RasterLayerSource.ArcGisService -> - "${source.serviceUrl.trimEnd('/')}/tile/${request.z}/${request.y}/${request.x}" - is RasterLayerSource.TileJson -> null - } - - private fun fetch(url: String): ByteArray? { - synchronized(fetchCacheLock) { - fetchCache.get(url)?.let { return it } - } - - val connection = - (URL(url).openConnection() as HttpURLConnection).apply { - connectTimeout = 5000 - readTimeout = 10000 - requestMethod = "GET" - setRequestProperty("User-Agent", state.userAgent) - state.extraHeaders?.forEach { (key, value) -> - setRequestProperty(key, value) - } - } - return try { - if (connection.responseCode !in 200..299) { - return null - } - val bytes = connection.inputStream.use { it.readBytes() } - synchronized(fetchCacheLock) { - fetchCache.put(url, bytes) - } - bytes - } catch (_: Exception) { - null - } finally { - connection.disconnect() - } - } - - private fun applyOpacity( - bytes: ByteArray, - opacity: Float, - ): ByteArray? { - val safeOpacity = opacity.coerceIn(0.0f, 1.0f) - if (safeOpacity >= 0.999f) { - return bytes - } - - val source = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null - val output = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(output) - val paint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - alpha = (safeOpacity * 255.0f).toInt().coerceIn(0, 255) - } - canvas.drawBitmap(source, 0.0f, 0.0f, paint) - - val stream = ByteArrayOutputStream() - output.compress(Bitmap.CompressFormat.PNG, 100, stream) - output.recycle() - source.recycle() - return stream.toByteArray() - } - - companion object { - private const val DEFAULT_FETCH_CACHE_SIZE_BYTES = 16 * 1024 * 1024 - } -} +package com.mapconductor.here.raster + +import com.here.sdk.mapview.MapContentType +import com.here.sdk.mapview.MapLayer +import com.here.sdk.mapview.MapLayerBuilder +import com.here.sdk.mapview.datasource.RasterDataSource +import com.here.sdk.mapview.datasource.RasterDataSourceConfiguration +import com.here.sdk.mapview.datasource.RasterTileSource +import com.here.sdk.mapview.datasource.TileKey +import com.here.sdk.mapview.datasource.TileSource +import com.here.sdk.mapview.datasource.TileUrlProviderCallback +import com.here.sdk.mapview.datasource.TileUrlProviderFactory +import com.here.sdk.mapview.datasource.TilingScheme +import com.mapconductor.core.raster.RasterLayerEntityInterface +import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.raster.TileScheme +import com.mapconductor.core.tileserver.LocalTileServer +import com.mapconductor.core.tileserver.TileProviderInterface +import com.mapconductor.core.tileserver.TileRequest +import com.mapconductor.here.HereViewHolder +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.util.Log +import android.util.LruCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class HereRasterLayerOverlayRenderer( + private val holder: HereViewHolder, + private val tileServer: LocalTileServer, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), +) : RasterLayerOverlayRendererInterface { + override suspend fun onAdd( + data: List, + ): List = + data.map { params -> + addLayer(params.state) + } + + override suspend fun onChange( + data: List>, + ): List = + data.map { params -> + val prev = params.prev + val next = params.current.state + if (prev.state.source != next.source || prev.state.opacity != next.opacity) { + removeLayer(prev) + addLayer(next) + } else { + updateLayer(prev.layer, next) + prev.layer + } + } + + override suspend fun onRemove(data: List>) { + data.forEach { entity -> + removeLayer(entity) + } + } + + override suspend fun onPostProcess() {} + + private fun addLayer(state: RasterLayerState): HereRasterLayerHandle? { + val routeId = + if (needsOpacityProxy(state)) { + "here-raster-" + safeId(state.id) + } else { + null + } + if (routeId != null) { + tileServer.register(routeId, HereRasterTileProxyProvider(state)) + } + + val tileSpec = resolveTileSpec(state, routeId) + if (tileSpec == null) { + routeId?.let { tileServer.unregister(it) } + Log.e("HereRasterLayer", "resolveTileSpec returned null!") + return null + } + val urlProvider = tileSpec.provider + val storageLevels = tileSpec.storageLevels + // Tiles this process draws are handed over in process. HERE fetches + // through its own network stack and, while the device reports no + // connectivity, does not fetch at all -- not even from 127.0.0.1 -- + // so a URL-fed source goes blank the moment the network is away. + // A tile source is asked for bytes and answered from the same + // providers, with no network in between. + val localTemplate = tileSpec.localTemplate + val dataSource = + if (localTemplate != null) { + RasterDataSource( + holder.mapView.mapContext, + tileSpec.sourceName, + LocalRasterTileSource(tileServer, localTemplate, storageLevels), + ) + } else { + val dataSourceProvider = + RasterDataSourceConfiguration.Provider( + urlProvider, + TilingScheme.QUAD_TREE_MERCATOR, + storageLevels, + ) + dataSourceProvider.hasAlphaChannel = true + val cache = + RasterDataSourceConfiguration.Cache( + holder.mapView.context.cacheDir.absolutePath, + ) + val config = + RasterDataSourceConfiguration( + tileSpec.sourceName, + dataSourceProvider, + cache, + ) + RasterDataSource(holder.mapView.mapContext, config) + } + + return try { + val layer = + MapLayerBuilder() + .withName(tileSpec.layerName) + .withDataSource(tileSpec.sourceName, MapContentType.RASTER_IMAGE) + .forMap(holder.mapView.hereMap) + .build() + layer.setEnabled(state.visible) + HereRasterLayerHandle( + dataSource = dataSource, + layer = layer, + sourceName = tileSpec.sourceName, + layerName = tileSpec.layerName, + routeId = routeId, + ) + } catch (e: MapLayerBuilder.InstantiationException) { + routeId?.let { tileServer.unregister(it) } + dataSource.destroy() + Log.e("HereRasterLayer", "Failed to create raster layer: ${e.message}", e) + null + } + } + + private fun updateLayer( + handle: HereRasterLayerHandle, + state: RasterLayerState, + ) { + handle.layer.setEnabled(state.visible) + } + + private fun removeLayer(entity: RasterLayerEntityInterface) { + val handle = entity.layer + handle.routeId?.let { tileServer.unregister(it) } + handle.layer.destroy() + handle.dataSource.destroy() + } + + private fun resolveTileSpec( + state: RasterLayerState, + routeId: String?, + ): TileSpec? = + when (val source = state.source) { + is RasterLayerSource.UrlTemplate -> { + val template = + if (routeId == null) { + source.template + } else { + tileServer.urlTemplateWithQueryCacheKey( + routeId, + source.tileSize, + state.fingerPrint().hashCode().toString(), + ) + } + val provider = + if (routeId == null && source.scheme == TileScheme.TMS) { + TileUrlProviderCallback { x, y, zoom -> + val max = 1 shl zoom + val tmsY = max - 1 - y + template + .replace("{x}", x.toString()) + .replace("{y}", tmsY.toString()) + .replace("{z}", zoom.toString()) + } + } else { + TileUrlProviderFactory.fromXyzUrlTemplate(template) + ?: TileUrlProviderCallback { x, y, zoom -> + template + .replace("{x}", x.toString()) + .replace("{y}", y.toString()) + .replace("{z}", zoom.toString()) + } + } + TileSpec( + provider = provider, + sourceName = "raster-source-${state.id}", + layerName = "raster-layer-${state.id}", + storageLevels = buildStorageLevels(source.minZoom, source.maxZoom), + // The proxy route is local by construction; a direct + // template is local when it is one of the server's own. + localTemplate = + template.takeIf { + it.startsWith(tileServer.baseUrl + "/") && + (routeId != null || source.scheme != TileScheme.TMS) + }, + ) + } + is RasterLayerSource.TileJson -> { + Log.w("HereRasterLayer", "HERE SDK does not support TileJson raster sources.") + null + } + is RasterLayerSource.ArcGisService -> { + val template = + if (routeId == null) { + val base = source.serviceUrl.trimEnd('/') + "$base/tile/{z}/{y}/{x}" + } else { + tileServer.urlTemplateWithQueryCacheKey( + routeId, + RasterLayerSource.DEFAULT_TILE_SIZE, + state.fingerPrint().hashCode().toString(), + ) + } + val provider = + TileUrlProviderFactory.fromXyzUrlTemplate(template) + ?: return null + TileSpec( + provider = provider, + sourceName = "raster-source-${state.id}", + layerName = "raster-layer-${state.id}", + storageLevels = buildStorageLevels(null, null), + ) + } + } + + private fun buildStorageLevels( + minZoom: Int?, + maxZoom: Int?, + ): List { + val min = minZoom ?: 0 + val max = maxZoom ?: 20 + return (min..max).toList() + } + + private fun needsOpacityProxy(state: RasterLayerState): Boolean = state.opacity.coerceIn(0.0f, 1.0f) < 0.999f + + private fun safeId(id: String): String = + id + .map { ch -> + when { + ch.isLetterOrDigit() -> ch + ch == '-' || ch == '_' || ch == '.' -> ch + else -> '_' + } + }.joinToString("") + + private data class TileSpec( + val provider: TileUrlProviderCallback, + val sourceName: String, + val layerName: String, + val storageLevels: List, + /** The XYZ template on this process's tile server, when the tiles come from there. */ + val localTemplate: String? = null, + ) +} + +/** + * A HERE tile source answered from this process's tile server, with no HTTP + * hop and no dependence on the device's connectivity. + */ +private class LocalRasterTileSource( + private val tileServer: LocalTileServer, + private val template: String, + private val levels: List, +) : RasterTileSource { + override fun getDataVersion(tileKey: TileKey): TileSource.DataVersion = VERSION + + override fun addListener(listener: TileSource.Listener) {} + + override fun removeListener(listener: TileSource.Listener) {} + + override fun getTilingScheme(): TilingScheme = TilingScheme.QUAD_TREE_MERCATOR + + override fun getStorageLevels(): List = levels + + override fun loadTile( + tileKey: TileKey, + handler: RasterTileSource.LoadResultHandler, + ): TileSource.LoadTileRequestHandle { + // Exactly one answer per request, whatever happens. A request HERE + // cancels is still one it is waiting on: leave it unanswered and the + // slot is never freed, and after a few gestures HERE stops asking for + // tiles at all -- the map keeps the last picture and looks frozen. + val answered = AtomicBoolean(false) + + fun answer(bytes: ByteArray?) { + if (!answered.compareAndSet(false, true)) return + if (bytes != null) handler.loaded(tileKey, bytes, METADATA) else handler.failed(tileKey) + } + // HERE counts rows from the south (Tokyo at z12 is row 2483, not + // 1612); the templates count from the north, as XYZ does. + val row = (1 shl tileKey.level) - 1 - tileKey.y + val url = + template + .replace("{z}", tileKey.level.toString()) + .replace("{x}", tileKey.x.toString()) + .replace("{y}", row.toString()) + val job = + scope.launch { + answer(runCatching { tileServer.renderLocalTile(url) { !isActive } }.getOrNull()) + } + // Cancelled, or the coroutine never ran: answered from a render + // thread rather than from whichever thread cancelled it. + job.invokeOnCompletion { if (!answered.get()) scope.launch { answer(null) } } + return TileSource.LoadTileRequestHandle { job.cancel() } + } + + /** + * Renders run here and nowhere else. + * + * HERE asks for a screenful at once and every one of those is a tile to + * draw. On `Dispatchers.Default` they take every thread the rest of the + * SDK shares -- camera callbacks, overlays, the vector renderer itself -- + * and the map stops answering gestures while it fills. This is the same + * width, and the same reasoning, as the tile server's own worker pool, + * which is what the HTTP path went through. + */ + private val scope = CoroutineScope(SupervisorJob() + renderDispatcher) + + private companion object { + @OptIn(ExperimentalCoroutinesApi::class) + val renderDispatcher = Dispatchers.Default.limitedParallelism(RENDER_WIDTH) + + /** Tile rendering is CPU-bound; more workers than cores adds contention, not throughput. */ + const val RENDER_WIDTH = 8 + + val VERSION = TileSource.DataVersion(0, 0) + + /** + * Tiles drawn here change only with their URL, so they are given a + * long life -- but a plausible one. A date near the end of `Long` + * does not survive the trip into the SDK: every tile came back + * already expired, the same one was asked for again every 400 ms, + * and after a pinch the map stopped asking for tiles at all. + */ + val METADATA = TileSource.TileMetadata(VERSION, Date(System.currentTimeMillis() + TEN_YEARS_MS)) + + const val TEN_YEARS_MS = 10L * 365 * 24 * 60 * 60 * 1000 + } +} + +data class HereRasterLayerHandle( + val dataSource: RasterDataSource, + val layer: MapLayer, + val sourceName: String, + val layerName: String, + val routeId: String?, +) + +private class HereRasterTileProxyProvider( + private val state: RasterLayerState, +) : TileProviderInterface { + private val fetchCacheLock = Any() + private val fetchCache = + object : LruCache(DEFAULT_FETCH_CACHE_SIZE_BYTES) { + override fun sizeOf( + key: String, + value: ByteArray, + ): Int = value.size + } + + override fun renderTile(request: TileRequest): ByteArray? { + val url = resolveUrl(request) ?: return null + val bytes = fetch(url) ?: return null + return applyOpacity(bytes, state.opacity) + } + + private fun resolveUrl(request: TileRequest): String? = + when (val source = state.source) { + is RasterLayerSource.UrlTemplate -> { + val y = + if (source.scheme == TileScheme.TMS) { + (1 shl request.z) - 1 - request.y + } else { + request.y + } + source.template + .replace("{x}", request.x.toString()) + .replace("{y}", y.toString()) + .replace("{z}", request.z.toString()) + } + is RasterLayerSource.ArcGisService -> + "${source.serviceUrl.trimEnd('/')}/tile/${request.z}/${request.y}/${request.x}" + is RasterLayerSource.TileJson -> null + } + + private fun fetch(url: String): ByteArray? { + synchronized(fetchCacheLock) { + fetchCache.get(url)?.let { return it } + } + + val connection = + (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 5000 + readTimeout = 10000 + requestMethod = "GET" + setRequestProperty("User-Agent", state.userAgent) + state.extraHeaders?.forEach { (key, value) -> + setRequestProperty(key, value) + } + } + return try { + if (connection.responseCode !in 200..299) { + return null + } + val bytes = connection.inputStream.use { it.readBytes() } + synchronized(fetchCacheLock) { + fetchCache.put(url, bytes) + } + bytes + } catch (_: Exception) { + null + } finally { + connection.disconnect() + } + } + + private fun applyOpacity( + bytes: ByteArray, + opacity: Float, + ): ByteArray? { + val safeOpacity = opacity.coerceIn(0.0f, 1.0f) + if (safeOpacity >= 0.999f) { + return bytes + } + + val source = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + val output = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + alpha = (safeOpacity * 255.0f).toInt().coerceIn(0, 255) + } + canvas.drawBitmap(source, 0.0f, 0.0f, paint) + + val stream = ByteArrayOutputStream() + output.compress(Bitmap.CompressFormat.PNG, 100, stream) + output.recycle() + source.recycle() + return stream.toByteArray() + } + + companion object { + private const val DEFAULT_FETCH_CACHE_SIZE_BYTES = 16 * 1024 * 1024 + } +}