Skip to main content

Writing an extension

The SDK does not need to contain every format. A data source it has no factory for can live in its own library, be written in Kotlin or Swift, and still be a first-class facade object that specs and layers point at.

That is deliberate. GDAL is the example everyone reaches for — it would add tens of megabytes, a proj.db, four Android cross-builds and five iOS slices to a map SDK that most apps use for tiles. As an extension it costs the SDK nothing and costs an app only what it asks for.

The reference extension

massif-maps/gdal-extension reads GeoTIFF and OGR vector files this way, and doubles as a worked example of cross-building a minimal GDAL.

How it works​

Three SDK pieces, all of which already exist:

TileDataSource and VectorDataSource are SWIG directorsso a binding can subclass them and override loadTile / loadElements
TileData takes a BinaryDataso an override can return bytes it decoded itself
MassifInterop.adopt(kind, id, source)registers the instance under a facade id, so specs and layers can reach it

An extension is therefore an ordinary Android or iOS library. It ships whatever native code it needs, and the SDK never links against it.

A tile source, in Kotlin​

class GeoTiffSource(private val path: String) : TileDataSource(0, 18) {

override fun loadTile(tile: MapTile): TileData? {
// Your own decode. The SDK asked for one tile of its grid; hand back an image.
val png = NativeGdal.renderTile(path, tile.zoom, tile.x, tile.y) ?: return null
return TileData(BinaryData(png))
}
}

loadTile runs on the SDK's tile threads, not the UI thread, and several at once — it has to be thread-safe. Each call crosses JNI, which is irrelevant next to a decode but would not be next to a memory lookup.

Then register it and use it like any other source:

val source = GeoTiffSource("/sdcard/dem.tif")
MassifInterop.adopt("source", "dem", source)

map.addLayer("dem", Spec.of("raster").set("source", "dem"))

A vector source​

VectorDataSource is the same shape — override loadElements(cullState) and return a VectorData of VectorElements you built. Because the elements are yours, so is the styling: pick a MarkerStyle or LineStyle per feature in Kotlin, where the rules are easier to read than in any expression language.

class ShapefileSource(projection: Projection) : VectorDataSource(projection) {
override fun loadElements(cullState: CullState): VectorData {
val elements = VectorElementVector()
for (feature in readFeatures(cullState.viewState)) {
elements.add(Marker(feature.pos, if (feature.big) bigStyle else smallStyle))
}
return VectorData(elements)
}
}

A tile source in C or C++​

An extension whose real work is native — GDAL, a proprietary decoder — does not have to bounce through the binding language. mm_source_create_custom registers a source implemented as a plain C function pointer:

#include <MassifApiC.h>

static int load_tile(void* user, int z, int x, int y, mm_tile_sink sink, void* sink_data) {
unsigned char rgba[256 * 256 * 4];
if (!render_tile(user, z, x, y, rgba)) {
return MM_OK; /* no such tile - a hole, not an error */
}
/* Copied inside this call, so the stack buffer above is fine and there is nothing to free. */
sink(sink_data, rgba, sizeof(rgba), MM_TILE_RGBA8, 256, 256);
return MM_OK;
}

mm_tile_source source = { 0, 14, load_tile, close_dataset, dataset };
mm_handle handle;
mm_source_create_custom(mm_context_default(), "dem", &source, &handle);

The id then works exactly as an adopted one does — Spec.of("raster").set("source", "dem").

MM_TILE_RGBA8 hands over decoded pixels, so nothing is encoded only to be decoded again — the Kotlin path gets the same with TileData(pixels, width, height) (premultiplied RGBA8; such tiles are not persistently cached). Use MM_TILE_ENCODED when what you have really is a file, including vector tile protobuf for a "vector" layer.

Link against libmassif.so for the mm_* symbols only — they are the exported surface, and the extension's own .so is otherwise independent of the SDK.

A layer you draw yourself​

mm_layer_create_custom registers a layer whose drawing is a C function. It is called on the GL thread with plain GL ES 3.0, after every layer and billboard of the frame, with the depth test off: a screen-space overlay for a heatmap, an annotation or a HUD. For meshes and volumes there is a scene mode, below.

static void render(void* user, const mm_render_params* p) {
Heat* heat = user;
if (!heat->program && !heat_init(heat)) return; /* create GL objects here, not before */
glBindFramebuffer(GL_FRAMEBUFFER, p->framebuffer); /* never 0: with a post-process effect the scene is offscreen */
heat_draw(heat, p->mvp_local, p->opacity);
/* restore every piece of GL state you changed - see the header */
}

mm_custom_layer layer = {
.size = sizeof(layer), .mode = MM_CUSTOM_LAYER_OVERLAY,
.on_removed = release_gl, .on_context_lost = forget_gl,
.render = render, .destroy = free, .user_data = heat,
};
mm_handle handle;
mm_layer_create_custom(mm_context_default(), "heat", &layer, &handle);
/* then add the handle to the map's layers like any other layer */

The rules that matter, all in MassifApiC.h:

  • Restore the GL state you touch — program, VAO, buffers, active texture and its binding, blend, depth, cull face, viewport, scissor. A leak corrupts the next layer, and looks like an SDK bug.
  • Draw into framebuffer, not into 0.
  • Use mvp_local for vertex transforms and subtract origin in double first; a float matrix at world scale jitters.
  • Opacity is yours to apply — it arrives in the params.
  • Do not ask for a frame every frame. Use mm_layer_request_redraw when something changed; it is safe from any thread.
  • MM_CUSTOM_LAYER_SCENE instead draws inside the 3D scene, depth tested against the terrain and the tiles. Append MM_CUSTOM_LAYER_GLSL_DEPTH to your vertex shader and finish it with gl_Position = mm_applyDepth(gl_Position), uploading depth_layer_offset and depth_shift; without it you z-fight or punch through the terrain.
  • Clicks: give hit_test (a ray in, a distance and your own feature id out) and on_click (return non-zero to consume it). Both run on the input thread, concurrently with render.
  • on_removed runs on the GL thread with the context current, so delete your GL objects there; on_context_lost means they are already gone, so do not.

This is a draw hook, not a tile pipeline: to supply pixels use a custom source above, to shade them a custom raster layer.

What an extension cannot do​

  • Register a spec type. The spec table is generated at build time, so {"type": "geotiff"} in JSON is not available. Construct the source and adopt it, or use mm_source_create_custom; specs then reference it by id or handle.
  • Subclass an SDK class from C++. The SDK compiles into one library with -fvisibility=hidden and an Android link that exports only Java_*, CSharp_*, SWIG* and mm_*, so another native library cannot derive from massif::TileDataSource however many headers it has. That is what mm_source_create_custom and mm_layer_create_custom exist for — a function pointer instead of a vtable.
  • Supply vector ELEMENTS from C. The C ABI covers tile sources. A VectorDataSource producing VectorElements is a binding-language subclass, as above.
  • Avoid thread safety. Every one of these — the two overrides and the C loader — is called concurrently from tile and culling threads.

Why this rather than a build profile​

An earlier attempt put GDAL in the SDK: a gdal build profile, a full+gdal published variant, a CI job cross-building GDAL for four ABIs, and a plan to vendor GDAL and PROJ into libs-external. It worked on paper and made every SDK build carry a GIS dependency's problems — proj.db's nine megabytes, five iOS slices, a driver set nobody agreed on.

The extension does the same job with no SDK change at all. That is the pattern for anything with a heavy native dependency: if the SDK does not need it to draw a map, it belongs in an extension.