core: add CustomVectorSource for binary tile data delivery (#4377)

Co-authored-by: Vladyslav Odobesku <VladyslavOdobesku@intellias.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Vladyslav Odobesku
2026-08-04 19:23:41 +03:00
committed by GitHub
parent 25e4f6acbf
commit 9ef0f1c790
29 changed files with 1307 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ dependencies {
implementation(libs.okhttp3)
implementation(libs.timber)
implementation(libs.interpolator)
implementation(libs.kotlinxCoroutinesCore)
testImplementation(libs.junit)
testImplementation(libs.mockito)

View File

@@ -177,6 +177,8 @@ add_library(maplibre SHARED
${PROJECT_SOURCE_DIR}/style/position.hpp
${PROJECT_SOURCE_DIR}/style/sources/custom_geometry_source.cpp
${PROJECT_SOURCE_DIR}/style/sources/custom_geometry_source.hpp
${PROJECT_SOURCE_DIR}/style/sources/custom_vector_source.cpp
${PROJECT_SOURCE_DIR}/style/sources/custom_vector_source.hpp
${PROJECT_SOURCE_DIR}/style/sources/geojson_source.cpp
${PROJECT_SOURCE_DIR}/style/sources/geojson_source.hpp
${PROJECT_SOURCE_DIR}/style/sources/image_source.cpp

View File

@@ -0,0 +1,137 @@
#include "custom_vector_source.hpp"
#include "attach_env.hpp"
#include <mbgl/style/sources/custom_vector_source.hpp>
#include <string>
namespace mbgl {
namespace android {
CustomVectorSource::CustomVectorSource(jni::JNIEnv& env,
const jni::String& sourceId,
jni::jint minZoom,
jni::jint maxZoom)
: Source(env,
std::make_unique<mbgl::style::CustomVectorSource>(
jni::Make<std::string>(env, sourceId),
mbgl::style::CustomVectorSource::Options{
std::bind(&CustomVectorSource::fetchTile, this, std::placeholders::_1),
std::bind(&CustomVectorSource::cancelTile, this, std::placeholders::_1),
{static_cast<uint8_t>(minZoom), static_cast<uint8_t>(maxZoom)}})) {}
CustomVectorSource::~CustomVectorSource() {
onRemovedFromMap();
}
void CustomVectorSource::fetchTile(const mbgl::CanonicalTileID& tileID) {
android::UniqueEnv _env = android::AttachEnv();
static auto& javaClass = jni::Class<CustomVectorSource>::Singleton(*_env);
static auto fetchTile = javaClass.GetMethod<void(jni::jint, jni::jint, jni::jint)>(*_env, "fetchTile");
if (!javaPeer) {
return;
}
auto peer = jni::Cast(*_env, javaClass, javaPeer);
peer.Call(*_env, fetchTile, (int)tileID.z, (int)tileID.x, (int)tileID.y);
}
void CustomVectorSource::cancelTile(const mbgl::CanonicalTileID& tileID) {
android::UniqueEnv _env = android::AttachEnv();
static auto& javaClass = jni::Class<CustomVectorSource>::Singleton(*_env);
static auto cancelTile = javaClass.GetMethod<void(jni::jint, jni::jint, jni::jint)>(*_env, "cancelTile");
if (!javaPeer) {
return;
}
auto peer = jni::Cast(*_env, javaClass, javaPeer);
peer.Call(*_env, cancelTile, (int)tileID.z, (int)tileID.x, (int)tileID.y);
}
void CustomVectorSource::onAddedToMap() {
android::UniqueEnv _env = android::AttachEnv();
static auto& javaClass = jni::Class<CustomVectorSource>::Singleton(*_env);
static auto onAddedToMap = javaClass.GetMethod<void()>(*_env, "onAddedToMap");
assert(javaPeer);
auto peer = jni::Cast(*_env, javaClass, javaPeer);
peer.Call(*_env, onAddedToMap);
}
void CustomVectorSource::onRemovedFromMap() {
android::UniqueEnv _env = android::AttachEnv();
static auto& javaClass = jni::Class<CustomVectorSource>::Singleton(*_env);
static auto onRemovedFromMap = javaClass.GetMethod<void()>(*_env, "onRemovedFromMap");
if (javaPeer) {
auto peer = jni::Cast(*_env, javaClass, javaPeer);
peer.Call(*_env, onRemovedFromMap);
}
}
void CustomVectorSource::setTileData(
jni::JNIEnv& env, jni::jint z, jni::jint x, jni::jint y, const jni::Array<jni::jbyte>& jData, jni::jint format) {
std::shared_ptr<std::string> data;
jsize length = jData.Length(env);
if (length > 0) {
data = std::make_shared<std::string>(length, char());
jni::GetArrayRegion(env, *jData, 0, length, reinterpret_cast<jbyte*>(&(*data)[0]));
}
source.as<mbgl::style::CustomVectorSource>()->setTileData(
CanonicalTileID(z, x, y), data, static_cast<mbgl::style::TileDataFormat>(format));
}
void CustomVectorSource::setTileError(
jni::JNIEnv& env, jni::jint z, jni::jint x, jni::jint y, const jni::String& message) {
auto msg = jni::Make<std::string>(env, message);
source.as<mbgl::style::CustomVectorSource>()->setTileError(CanonicalTileID(z, x, y),
std::make_exception_ptr(std::runtime_error(msg)));
}
void CustomVectorSource::invalidateTile(jni::JNIEnv&, jni::jint z, jni::jint x, jni::jint y) {
source.as<mbgl::style::CustomVectorSource>()->invalidateTile(CanonicalTileID(z, x, y));
}
void CustomVectorSource::addToMap(JNIEnv& env,
const jni::Object<Source>& obj,
mbgl::Map& map,
AndroidRendererFrontend& frontend) {
Source::addToMap(env, obj, map, frontend);
onAddedToMap();
}
bool CustomVectorSource::removeFromMap(JNIEnv& env, const jni::Object<Source>& source, mbgl::Map& map) {
bool successfullyRemoved = Source::removeFromMap(env, source, map);
if (successfullyRemoved) {
onRemovedFromMap();
}
return successfullyRemoved;
}
void CustomVectorSource::registerNative(jni::JNIEnv& env) {
static auto& javaClass = jni::Class<CustomVectorSource>::Singleton(env);
#define METHOD(MethodPtr, name) jni::MakeNativePeerMethod<decltype(MethodPtr), (MethodPtr)>(name)
jni::RegisterNativePeer<CustomVectorSource>(
env,
javaClass,
"nativePtr",
jni::MakePeer<CustomVectorSource, const jni::String&, jni::jint, jni::jint>,
"initialize",
"finalize",
METHOD(&CustomVectorSource::setTileData, "nativeSetTileData"),
METHOD(&CustomVectorSource::setTileError, "nativeSetTileError"),
METHOD(&CustomVectorSource::invalidateTile, "nativeInvalidateTile"));
}
} // namespace android
} // namespace mbgl

View File

@@ -0,0 +1,37 @@
#pragma once
#include "source.hpp"
#include <mbgl/style/sources/custom_vector_source.hpp>
#include <mbgl/tile/tile_id.hpp>
#include <jni/jni.hpp>
namespace mbgl {
namespace android {
class CustomVectorSource : public Source {
public:
using SuperTag = Source;
static constexpr auto Name() { return "org/maplibre/android/style/sources/CustomVectorSource"; }
static void registerNative(jni::JNIEnv&);
CustomVectorSource(jni::JNIEnv&, const jni::String&, jni::jint minZoom, jni::jint maxZoom);
~CustomVectorSource();
bool removeFromMap(JNIEnv&, const jni::Object<Source>&, mbgl::Map&) override;
void addToMap(JNIEnv&, const jni::Object<Source>&, mbgl::Map&, AndroidRendererFrontend&) override;
void fetchTile(const mbgl::CanonicalTileID& tileID);
void cancelTile(const mbgl::CanonicalTileID& tileID);
void onAddedToMap();
void onRemovedFromMap();
private:
void setTileData(
jni::JNIEnv&, jni::jint z, jni::jint x, jni::jint y, const jni::Array<jni::jbyte>& data, jni::jint format);
void setTileError(jni::JNIEnv&, jni::jint z, jni::jint x, jni::jint y, const jni::String& message);
void invalidateTile(jni::JNIEnv&, jni::jint z, jni::jint x, jni::jint y);
};
} // namespace android
} // namespace mbgl

View File

@@ -17,6 +17,7 @@
#include <string>
// Core Sources
#include <mbgl/style/sources/custom_vector_source.hpp>
#include <mbgl/style/sources/geojson_source.hpp>
#include <mbgl/style/sources/image_source.hpp>
#include <mbgl/style/sources/raster_source.hpp>
@@ -29,6 +30,7 @@
#include "unknown_source.hpp"
#include "vector_source.hpp"
#include "custom_geometry_source.hpp"
#include "custom_vector_source.hpp"
#include "raster_dem_source.hpp"
namespace mbgl {
@@ -319,6 +321,7 @@ void Source::registerNative(jni::JNIEnv& env) {
UnknownSource::registerNative(env);
VectorSource::registerNative(env);
CustomGeometrySource::registerNative(env);
CustomVectorSource::registerNative(env);
RasterDEMSource::registerNative(env);
}
} // namespace android

View File

@@ -0,0 +1,89 @@
package org.maplibre.android.style.sources
import androidx.annotation.Keep
import androidx.annotation.UiThread
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
@UiThread
class CustomVectorSource(
id: String,
private val provider: CustomVectorTileProvider,
private val scope: CoroutineScope,
minZoom: Int = 0,
maxZoom: Int = 18
) : Source() {
private val activeJobs = ConcurrentHashMap<TileID, Job>()
init {
initialize(id, minZoom, maxZoom)
}
fun setTileData(z: Int, x: Int, y: Int, data: TileData) {
nativeSetTileData(z, x, y, data.bytes, data.formatId)
}
fun invalidateTile(z: Int, x: Int, y: Int) {
nativeInvalidateTile(z, x, y)
}
@Keep
private fun fetchTile(z: Int, x: Int, y: Int) {
val tileId = TileID(z, x, y)
activeJobs.remove(tileId)?.cancel()
val job = scope.launch(start = CoroutineStart.LAZY) {
try {
val tileData = provider.fetchTile(z, x, y)
nativeSetTileData(z, x, y, tileData.bytes, tileData.formatId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
nativeSetTileError(z, x, y, e.message ?: "Tile fetch failed")
} finally {
val self = coroutineContext[Job]!!
activeJobs.remove(tileId, self)
}
}
activeJobs[tileId] = job
job.start()
}
@Keep
private fun cancelTile(z: Int, x: Int, y: Int) {
activeJobs.remove(TileID(z, x, y))?.cancel()
}
@Keep
private fun onAddedToMap() { /* no-op: scope is user-provided */ }
@Keep
private fun onRemovedFromMap() {
activeJobs.values.forEach { it.cancel() }
activeJobs.clear()
}
@Keep
private external fun initialize(sourceId: String, minZoom: Int, maxZoom: Int)
@Keep
private external fun nativeSetTileData(z: Int, x: Int, y: Int, data: ByteArray, format: Int)
@Keep
private external fun nativeSetTileError(z: Int, x: Int, y: Int, message: String)
@Keep
private external fun nativeInvalidateTile(z: Int, x: Int, y: Int)
@Keep
@Throws(Throwable::class)
protected external fun finalize()
internal data class TileID(val z: Int, val x: Int, val y: Int)
}

View File

@@ -0,0 +1,5 @@
package org.maplibre.android.style.sources
interface CustomVectorTileProvider {
suspend fun fetchTile(z: Int, x: Int, y: Int): TileData
}

View File

@@ -0,0 +1,17 @@
package org.maplibre.android.style.sources
sealed class TileData {
class Mvt(val data: ByteArray) : TileData()
internal val formatId: Int get() = when (this) {
is Mvt -> FORMAT_MVT
}
internal val bytes: ByteArray get() = when (this) {
is Mvt -> data
}
companion object {
internal const val FORMAT_MVT = 0
}
}

View File

@@ -746,6 +746,18 @@
android:name="android.support.PARENT_ACTIVITY"
android:value=".activity.FeatureOverviewActivity" />
</activity>
<activity
android:name=".activity.style.CustomVectorSourceActivity"
android:description="@string/description_custom_vector_source"
android:exported="true"
android:label="@string/activity_custom_vector_source">
<meta-data
android:name="@string/category"
android:value="@string/category_style" />
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activity.FeatureOverviewActivity" />
</activity>
<activity
android:name=".activity.style.NoStyleActivity"
android:description="@string/description_no_style"

View File

@@ -0,0 +1,238 @@
package org.maplibre.android.testapp.activity.style
import android.graphics.Color
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.OnMapReadyCallback
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.sources.CustomVectorSource
import org.maplibre.android.style.sources.TileData
import org.maplibre.android.style.sources.CustomVectorTileProvider
import org.maplibre.android.testapp.R
import org.maplibre.android.testapp.styles.TestStyles
import java.io.ByteArrayOutputStream
/**
* Demo activity that uses CustomVectorSource with a fake MVT tile provider.
* Generates a diagonal cross pattern per tile as synthetic MVT data.
* Toggle the source on/off with the FAB.
*/
class CustomVectorSourceActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var mapView: MapView
private var maplibreMap: MapLibreMap? = null
private var sourceActive = false
private val tileScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_custom_vector_source)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync(this)
findViewById<FloatingActionButton>(R.id.fabToggle).setOnClickListener {
toggleSource()
}
}
override fun onMapReady(map: MapLibreMap) {
maplibreMap = map
map.setStyle(Style.Builder().fromUri(TestStyles.DEMOTILES))
}
private fun toggleSource() {
val map = maplibreMap ?: return
val style = map.style ?: return
if (sourceActive) {
style.removeLayer(LAYER_ID)
style.removeSource(SOURCE_ID)
sourceActive = false
Toast.makeText(this, "CustomVectorSource removed", Toast.LENGTH_SHORT).show()
} else {
val source = CustomVectorSource(SOURCE_ID, DiagonalTileProvider(), tileScope, minZoom = 0, maxZoom = 14)
val layer = LineLayer(LAYER_ID, SOURCE_ID).apply {
setSourceLayer(SOURCE_LAYER_NAME)
setProperties(
PropertyFactory.lineColor(Color.RED),
PropertyFactory.lineWidth(2f),
PropertyFactory.lineOpacity(0.8f)
)
}
style.addSource(source)
style.addLayer(layer)
sourceActive = true
Toast.makeText(this, "CustomVectorSource added", Toast.LENGTH_SHORT).show()
}
}
/**
* A fake tile provider that generates MVT tiles containing diagonal lines.
* Simulates async work with a small delay.
*/
private class DiagonalTileProvider : CustomVectorTileProvider {
override suspend fun fetchTile(z: Int, x: Int, y: Int): TileData {
// Simulate network latency
delay(50)
val mvtBytes = buildDiagonalMvtTile(z, x, y)
return TileData.Mvt(mvtBytes)
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { tileScope.cancel(); super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
companion object {
const val SOURCE_ID = "custom-vector-source"
const val LAYER_ID = "custom-vector-layer"
const val SOURCE_LAYER_NAME = "diagonal"
private const val EXTENT = 4096
/**
* Builds a minimal MVT tile with two diagonal lines forming an X.
* MVT protobuf encoding done by hand (no protobuf library needed).
*/
private fun buildDiagonalMvtTile(z: Int, x: Int, y: Int): ByteArray {
// Geometry: two lines forming an X across the tile
// Line 1: (0,0) -> (4096,4096)
// Line 2: (4096,0) -> (0,4096)
val geom1 = encodeMvtGeometry(
listOf(Pair(0, 0), Pair(EXTENT, EXTENT))
)
val geom2 = encodeMvtGeometry(
listOf(Pair(EXTENT, 0), Pair(0, EXTENT))
)
// Also add a border rectangle
val border = encodeMvtGeometry(
listOf(Pair(0, 0), Pair(EXTENT, 0), Pair(EXTENT, EXTENT), Pair(0, EXTENT), Pair(0, 0))
)
val feature1 = encodeMvtFeature(id = 1, geometry = geom1, geomType = 2)
val feature2 = encodeMvtFeature(id = 2, geometry = geom2, geomType = 2)
val feature3 = encodeMvtFeature(id = 3, geometry = border, geomType = 2)
val layer = encodeMvtLayer(
name = SOURCE_LAYER_NAME,
extent = EXTENT,
features = listOf(feature1, feature2, feature3)
)
// Tile: repeated Layer layers = 3;
val tile = ByteArrayOutputStream()
writeTag(tile, 3, 2) // field 3, wire type 2 (length-delimited)
writeBytes(tile, layer)
return tile.toByteArray()
}
private fun encodeMvtGeometry(points: List<Pair<Int, Int>>): ByteArray {
val commands = mutableListOf<Int>()
for (i in points.indices) {
if (i == 0) {
// MoveTo command: id=1, count=1
commands.add(commandInteger(1, 1))
commands.add(zigzag(points[0].first))
commands.add(zigzag(points[0].second))
} else {
if (i == 1) {
// LineTo command: id=2, count=remaining points
commands.add(commandInteger(2, points.size - 1))
}
val dx = points[i].first - points[i - 1].first
val dy = points[i].second - points[i - 1].second
commands.add(zigzag(dx))
commands.add(zigzag(dy))
}
}
return commands.flatMap { encodeVarint(it) }.toByteArray()
}
private fun encodeMvtFeature(id: Long, geometry: ByteArray, geomType: Int): ByteArray {
val out = ByteArrayOutputStream()
// optional uint64 id = 1
writeTag(out, 1, 0)
writeVarint(out, id)
// optional GeomType type = 3
writeTag(out, 3, 0)
writeVarint(out, geomType.toLong())
// repeated uint32 geometry = 4 (packed)
writeTag(out, 4, 2)
writeBytes(out, geometry)
return out.toByteArray()
}
private fun encodeMvtLayer(name: String, extent: Int, features: List<ByteArray>): ByteArray {
val out = ByteArrayOutputStream()
// required uint32 version = 15
writeTag(out, 15, 0)
writeVarint(out, 2)
// required string name = 1
writeTag(out, 1, 2)
writeBytes(out, name.toByteArray(Charsets.UTF_8))
// repeated Feature features = 2
for (feature in features) {
writeTag(out, 2, 2)
writeBytes(out, feature)
}
// optional uint32 extent = 5
writeTag(out, 5, 0)
writeVarint(out, extent.toLong())
return out.toByteArray()
}
private fun commandInteger(id: Int, count: Int): Int = (id and 0x7) or (count shl 3)
private fun zigzag(value: Int): Int = (value shl 1) xor (value shr 31)
private fun encodeVarint(value: Int): List<Byte> {
val bytes = mutableListOf<Byte>()
var v = value.toLong() and 0xFFFFFFFFL
while (v > 0x7F) {
bytes.add(((v and 0x7F) or 0x80).toByte())
v = v ushr 7
}
bytes.add((v and 0x7F).toByte())
return bytes
}
private fun writeTag(out: ByteArrayOutputStream, fieldNumber: Int, wireType: Int) {
writeVarint(out, ((fieldNumber shl 3) or wireType).toLong())
}
private fun writeVarint(out: ByteArrayOutputStream, value: Long) {
var v = value
while (v > 0x7F) {
out.write(((v and 0x7F) or 0x80).toInt())
v = v ushr 7
}
out.write((v and 0x7F).toInt())
}
private fun writeBytes(out: ByteArrayOutputStream, data: ByteArray) {
writeVarint(out, data.size.toLong())
out.write(data)
}
private fun List<Byte>.toByteArray(): ByteArray = ByteArray(size) { this[it] }
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.maplibre.android.maps.MapView
android:id="@id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:maplibre_cameraTargetLat="48.85"
app:maplibre_cameraTargetLng="2.35"
app:maplibre_cameraZoom="4"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:layout_margin="16dp"
android:src="@android:drawable/ic_input_add"
android:contentDescription="Toggle custom source"/>
</RelativeLayout>

View File

@@ -83,6 +83,7 @@
<string name="description_surfaceview_resize">Resize a map rendered on a SurfaceView</string>
<string name="description_overlay">Blend an overlay on a map</string>
<string name="description_grid_source">Example Custom Geometry Source</string>
<string name="description_custom_vector_source">CustomVectorSource with synthetic MVT tiles</string>
<string name="description_no_style">Load a map without providing a style URI or JSON</string>
<string name="description_vector_tile">Load MVT data</string>
<string name="description_pmtiles">Load PMTiles data</string>

View File

@@ -79,6 +79,7 @@
<string name="activity_surfaceview_resize">SurfaceView resize</string>
<string name="activity_overlay">Map overlay</string>
<string name="activity_grid_source">Grid Source</string>
<string name="activity_custom_vector_source">Custom Vector Source</string>
<string name="activity_no_style">No Style URI/JSON</string>
<string name="activity_vector_tile">Vector Tile source</string>
<string name="activity_pmtiles">PMTiles</string>

View File

@@ -219,6 +219,7 @@ OfflineRegionStatus OfflineDownload::getStatus() const {
case SourceType::Video:
case SourceType::Annotations:
case SourceType::CustomVector:
case SourceType::CustomMVTVector:
break;
}
}
@@ -333,6 +334,7 @@ void OfflineDownload::activateDownload() {
case SourceType::Video:
case SourceType::Annotations:
case SourceType::CustomVector:
case SourceType::CustomMVTVector:
break;
}
}