Add feature state functionality to Android (#4219)

This commit is contained in:
Brandon Roeder
2026-07-13 13:53:52 -05:00
committed by GitHub
parent 764accc8f5
commit c58080b491
23 changed files with 797 additions and 7 deletions

View File

@@ -170,6 +170,28 @@ std::vector<Feature> AndroidRendererFrontend::querySourceFeatures(const std::str
return mapRenderer.actor().ask(&Renderer::querySourceFeatures, sourceID, options).get();
}
void AndroidRendererFrontend::setFeatureState(const std::string& sourceID,
const std::optional<std::string>& sourceLayerID,
const std::string& featureID,
const FeatureState& state) const {
mapRenderer.actor().invoke(&Renderer::setFeatureState, sourceID, sourceLayerID, featureID, state);
}
FeatureState AndroidRendererFrontend::getFeatureState(const std::string& sourceID,
const std::optional<std::string>& sourceLayerID,
const std::string& featureID) const {
auto getFeatureState = static_cast<FeatureState (Renderer::*)(
const std::string&, const std::optional<std::string>&, const std::string&) const>(&Renderer::getFeatureState);
return mapRenderer.actor().ask(getFeatureState, sourceID, sourceLayerID, featureID).get();
}
void AndroidRendererFrontend::removeFeatureState(const std::string& sourceID,
const std::optional<std::string>& sourceLayerID,
const std::optional<std::string>& featureID,
const std::optional<std::string>& stateKey) const {
mapRenderer.actor().invoke(&Renderer::removeFeatureState, sourceID, sourceLayerID, featureID, stateKey);
}
std::vector<Feature> AndroidRendererFrontend::queryRenderedFeatures(const ScreenBox& box,
const RenderedQueryOptions& options) const {
// Select the right overloaded method

View File

@@ -52,6 +52,17 @@ public:
std::vector<Feature> queryRenderedFeatures(const ScreenCoordinate&, const RenderedQueryOptions&) const;
std::vector<Feature> queryRenderedFeatures(const ScreenBox&, const RenderedQueryOptions&) const;
std::vector<Feature> querySourceFeatures(const std::string& sourceID, const SourceQueryOptions&) const;
void setFeatureState(const std::string& sourceID,
const std::optional<std::string>& sourceLayerID,
const std::string& featureID,
const FeatureState& state) const;
FeatureState getFeatureState(const std::string& sourceID,
const std::optional<std::string>& sourceLayerID,
const std::string& featureID) const;
void removeFeatureState(const std::string& sourceID,
const std::optional<std::string>& sourceLayerID,
const std::optional<std::string>& featureID,
const std::optional<std::string>& stateKey) const;
AnnotationIDs queryPointAnnotations(const ScreenBox& box, const std::chrono::milliseconds& timeout) const;
AnnotationIDs queryShapeAnnotations(const ScreenBox& box, const std::chrono::milliseconds& timeout) const;

View File

@@ -40,6 +40,7 @@
#include "conversion/collection.hpp"
#include "style/conversion/filter.hpp"
#include "geojson/feature.hpp"
#include "gson/json_object.hpp"
#include "rendering_stats.hpp"
#include "android_renderer_frontend.hpp"
@@ -1015,6 +1016,59 @@ jni::Local<jni::Array<jni::Object<geojson::Feature>>> NativeMapView::queryRender
return Feature::convert(env, rendererFrontend->queryRenderedFeatures(box, {layers, toFilter(env, jfilter)}));
}
void NativeMapView::setFeatureState(JNIEnv& env,
const jni::String& sourceId,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::Object<gson::JsonObject>& state) {
if (!sourceId || !featureId || !state) {
return;
}
rendererFrontend->setFeatureState(
jni::Make<std::string>(env, sourceId),
sourceLayerId ? std::optional<std::string>(jni::Make<std::string>(env, sourceLayerId)) : std::nullopt,
jni::Make<std::string>(env, featureId),
gson::JsonObject::convert(env, state));
map->triggerRepaint();
}
jni::Local<jni::Object<gson::JsonObject>> NativeMapView::getFeatureState(JNIEnv& env,
const jni::String& sourceId,
const jni::String& sourceLayerId,
const jni::String& featureId) {
if (!sourceId || !featureId) {
return jni::Local<jni::Object<gson::JsonObject>>();
}
const auto state = rendererFrontend->getFeatureState(
jni::Make<std::string>(env, sourceId),
sourceLayerId ? std::optional<std::string>(jni::Make<std::string>(env, sourceLayerId)) : std::nullopt,
jni::Make<std::string>(env, featureId));
if (state.empty()) {
return jni::Local<jni::Object<gson::JsonObject>>();
}
return gson::JsonObject::New(env, state);
}
void NativeMapView::removeFeatureState(JNIEnv& env,
const jni::String& sourceId,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::String& stateKey) {
if (!sourceId) {
return;
}
rendererFrontend->removeFeatureState(
jni::Make<std::string>(env, sourceId),
sourceLayerId ? std::optional<std::string>(jni::Make<std::string>(env, sourceLayerId)) : std::nullopt,
featureId ? std::optional<std::string>(jni::Make<std::string>(env, featureId)) : std::nullopt,
stateKey ? std::optional<std::string>(jni::Make<std::string>(env, stateKey)) : std::nullopt);
map->triggerRepaint();
}
jni::Local<jni::Object<Light>> NativeMapView::getLight(JNIEnv& env) {
mbgl::style::Light* light = map->getStyle().getLight();
if (light) {
@@ -1183,7 +1237,7 @@ jni::Local<jni::Array<jni::Object<Source>>> NativeMapView::getSources(JNIEnv& en
auto jSources = jni::Array<jni::Object<Source>>::New(env, sources.size());
int index = 0;
for (auto source : sources) {
jSources.Set(env, index, Source::peerForCoreSource(env, *source, *rendererFrontend));
jSources.Set(env, index, Source::peerForCoreSource(env, *source, *rendererFrontend, *map));
index++;
}
@@ -1199,7 +1253,7 @@ jni::Local<jni::Object<Source>> NativeMapView::getSource(JNIEnv& env, const jni:
}
// Create and return the source's native peer
return jni::NewLocal(env, Source::peerForCoreSource(env, *coreSource, *rendererFrontend));
return jni::NewLocal(env, Source::peerForCoreSource(env, *coreSource, *rendererFrontend, *map));
}
void NativeMapView::addSource(JNIEnv& env, const jni::Object<Source>& obj, jlong sourcePtr) {
@@ -1428,6 +1482,9 @@ void NativeMapView::registerNative(jni::JNIEnv& env) {
METHOD(&NativeMapView::queryShapeAnnotations, "nativeQueryShapeAnnotations"),
METHOD(&NativeMapView::queryRenderedFeaturesForPoint, "nativeQueryRenderedFeaturesForPoint"),
METHOD(&NativeMapView::queryRenderedFeaturesForBox, "nativeQueryRenderedFeaturesForBox"),
METHOD(&NativeMapView::setFeatureState, "nativeSetFeatureState"),
METHOD(&NativeMapView::getFeatureState, "nativeGetFeatureState"),
METHOD(&NativeMapView::removeFeatureState, "nativeRemoveFeatureState"),
METHOD(&NativeMapView::getLight, "nativeGetLight"),
METHOD(&NativeMapView::getLayers, "nativeGetLayers"),
METHOD(&NativeMapView::getLayer, "nativeGetLayer"),

View File

@@ -15,6 +15,7 @@
#include "graphics/rectf.hpp"
#include "geojson/feature.hpp"
#include "geojson/geometry.hpp"
#include "gson/json_object.hpp"
#include "geometry/lat_lng.hpp"
#include "geometry/projected_meters.hpp"
#include "style/layers/layer_manager.hpp"
@@ -258,6 +259,23 @@ public:
const jni::Array<jni::String>&,
const jni::Array<jni::Object<>>& jfilter);
void setFeatureState(JNIEnv&,
const jni::String& sourceId,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::Object<gson::JsonObject>& state);
jni::Local<jni::Object<gson::JsonObject>> getFeatureState(JNIEnv&,
const jni::String& sourceId,
const jni::String& sourceLayerId,
const jni::String& featureId);
void removeFeatureState(JNIEnv&,
const jni::String& sourceId,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::String& stateKey);
jni::Local<jni::Object<Light>> getLight(JNIEnv&);
jni::Local<jni::Array<jni::Object<Layer>>> getLayers(JNIEnv&);

View File

@@ -52,11 +52,14 @@ static std::unique_ptr<Source> createSourcePeer(jni::JNIEnv& env,
const jni::Object<Source>& Source::peerForCoreSource(jni::JNIEnv& env,
mbgl::style::Source& coreSource,
AndroidRendererFrontend& frontend) {
AndroidRendererFrontend& frontend,
mbgl::Map& map) {
if (!coreSource.peer.has_value()) {
coreSource.peer = createSourcePeer(env, coreSource, &frontend);
}
return coreSource.peer.get<std::unique_ptr<Source>>()->javaPeer;
auto* peer = coreSource.peer.get<std::unique_ptr<Source>>().get();
peer->bindToMap(frontend, map);
return peer->javaPeer;
}
const jni::Object<Source>& Source::peerForCoreSource(jni::JNIEnv& env, mbgl::style::Source& coreSource) {
@@ -172,7 +175,7 @@ void Source::addToMap(JNIEnv& env, const jni::Object<Source>& obj, mbgl::Map& ma
// Add strong reference to java source
javaPeer = jni::NewGlobal(env, obj);
rendererFrontend = &frontend;
bindToMap(frontend, map);
}
bool Source::removeFromMap(JNIEnv&, const jni::Object<Source>&, mbgl::Map& map) {
@@ -204,6 +207,67 @@ jni::Local<jni::Long> Source::getMinimumTileUpdateInterval(JNIEnv& env) {
return jni::Box(env, jni::jlong(source.getMinimumTileUpdateInterval().count() / 1000000));
}
jni::jboolean Source::setFeatureState(JNIEnv& env,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::Object<gson::JsonObject>& state) {
if (!rendererFrontend || !featureId || !state) {
return jni::jni_false;
}
rendererFrontend->setFeatureState(
source.getID(),
sourceLayerId ? std::optional<std::string>(jni::Make<std::string>(env, sourceLayerId)) : std::nullopt,
jni::Make<std::string>(env, featureId),
gson::JsonObject::convert(env, state));
if (map) {
map->triggerRepaint();
}
return jni::jni_true;
}
jni::Local<jni::Object<gson::JsonObject>> Source::getFeatureState(JNIEnv& env,
const jni::String& sourceLayerId,
const jni::String& featureId) {
if (!rendererFrontend || !featureId) {
return jni::Local<jni::Object<gson::JsonObject>>();
}
const auto state = rendererFrontend->getFeatureState(
source.getID(),
sourceLayerId ? std::optional<std::string>(jni::Make<std::string>(env, sourceLayerId)) : std::nullopt,
jni::Make<std::string>(env, featureId));
if (state.empty()) {
return jni::Local<jni::Object<gson::JsonObject>>();
}
return gson::JsonObject::New(env, state);
}
jni::jboolean Source::removeFeatureState(JNIEnv& env,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::String& stateKey) {
if (!rendererFrontend) {
return jni::jni_false;
}
rendererFrontend->removeFeatureState(
source.getID(),
sourceLayerId ? std::optional<std::string>(jni::Make<std::string>(env, sourceLayerId)) : std::nullopt,
featureId ? std::optional<std::string>(jni::Make<std::string>(env, featureId)) : std::nullopt,
stateKey ? std::optional<std::string>(jni::Make<std::string>(env, stateKey)) : std::nullopt);
if (map) {
map->triggerRepaint();
}
return jni::jni_true;
}
void Source::bindToMap(AndroidRendererFrontend& frontend, mbgl::Map& map) {
rendererFrontend = &frontend;
this->map = &map;
}
void Source::releaseJavaPeer() {
// We can't release the peer if the source was not removed from the map
if (!ownedSource) {
@@ -220,6 +284,7 @@ void Source::releaseJavaPeer() {
javaPeer.reset();
rendererFrontend = nullptr;
map = nullptr;
}
void Source::registerNative(jni::JNIEnv& env) {
@@ -242,7 +307,10 @@ void Source::registerNative(jni::JNIEnv& env) {
METHOD(&Source::isVolatile, "nativeIsVolatile"),
METHOD(&Source::setVolatile, "nativeSetVolatile"),
METHOD(&Source::setMinimumTileUpdateInterval, "nativeSetMinimumTileUpdateInterval"),
METHOD(&Source::getMinimumTileUpdateInterval, "nativeGetMinimumTileUpdateInterval"));
METHOD(&Source::getMinimumTileUpdateInterval, "nativeGetMinimumTileUpdateInterval"),
METHOD(&Source::setFeatureState, "nativeSetFeatureState"),
METHOD(&Source::getFeatureState, "nativeGetFeatureState"),
METHOD(&Source::removeFeatureState, "nativeRemoveFeatureState"));
// Register subclasses
GeoJSONSource::registerNative(env);

View File

@@ -6,6 +6,7 @@
#include "../value.hpp"
#include "../../android_renderer_frontend.hpp"
#include "../../gson/json_object.hpp"
#include <jni/jni.hpp>
@@ -19,7 +20,10 @@ public:
static void registerNative(jni::JNIEnv&);
static const jni::Object<Source>& peerForCoreSource(jni::JNIEnv&, mbgl::style::Source&);
static const jni::Object<Source>& peerForCoreSource(jni::JNIEnv&, mbgl::style::Source&, AndroidRendererFrontend&);
static const jni::Object<Source>& peerForCoreSource(jni::JNIEnv&,
mbgl::style::Source&,
AndroidRendererFrontend&,
mbgl::Map&);
/*
* Called when a Java object is created for a core source that belongs to a map.
@@ -62,6 +66,22 @@ public:
jni::Local<jni::Long> getMinimumTileUpdateInterval(JNIEnv&);
jni::jboolean setFeatureState(JNIEnv&,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::Object<gson::JsonObject>& state);
jni::Local<jni::Object<gson::JsonObject>> getFeatureState(JNIEnv&,
const jni::String& sourceLayerId,
const jni::String& featureId);
jni::jboolean removeFeatureState(JNIEnv&,
const jni::String& sourceLayerId,
const jni::String& featureId,
const jni::String& stateKey);
void bindToMap(AndroidRendererFrontend&, mbgl::Map&);
protected:
// Set on newly created sources until added to the map.
std::unique_ptr<mbgl::style::Source> ownedSource;
@@ -74,6 +94,9 @@ protected:
// RendererFrontend pointer is valid only when added to the map.
AndroidRendererFrontend* rendererFrontend{nullptr};
// Map pointer is valid only when added to the map.
mbgl::Map* map{nullptr};
};
} // namespace android

View File

@@ -8,6 +8,7 @@ import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonObject;
import org.maplibre.geojson.Feature;
import org.maplibre.geojson.Geometry;
import org.maplibre.android.annotations.Marker;
@@ -190,6 +191,21 @@ interface NativeMap {
@Nullable String[] layerIds,
@Nullable Expression filter);
void setFeatureState(@NonNull String sourceId,
@Nullable String sourceLayerId,
@NonNull String featureId,
@NonNull JsonObject state);
@Nullable
JsonObject getFeatureState(@NonNull String sourceId,
@Nullable String sourceLayerId,
@NonNull String featureId);
void removeFeatureState(@NonNull String sourceId,
@Nullable String sourceLayerId,
@Nullable String featureId,
@Nullable String stateKey);
//
// Projection API
//

View File

@@ -13,6 +13,7 @@ import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonObject;
import org.maplibre.geojson.Feature;
import org.maplibre.geojson.Geometry;
import org.maplibre.android.LibraryLoader;
@@ -1123,6 +1124,39 @@ final class NativeMapView implements NativeMap {
return features != null ? Arrays.asList(features) : new ArrayList<Feature>();
}
@Override
public void setFeatureState(@NonNull String sourceId,
@Nullable String sourceLayerId,
@NonNull String featureId,
@NonNull JsonObject state) {
if (checkState("setFeatureState")) {
return;
}
nativeSetFeatureState(sourceId, sourceLayerId, featureId, state);
}
@Override
@Nullable
public JsonObject getFeatureState(@NonNull String sourceId,
@Nullable String sourceLayerId,
@NonNull String featureId) {
if (checkState("getFeatureState")) {
return null;
}
return nativeGetFeatureState(sourceId, sourceLayerId, featureId);
}
@Override
public void removeFeatureState(@NonNull String sourceId,
@Nullable String sourceLayerId,
@Nullable String featureId,
@Nullable String stateKey) {
if (checkState("removeFeatureState")) {
return;
}
nativeRemoveFeatureState(sourceId, sourceLayerId, featureId, stateKey);
}
@Override
public void setApiBaseUrl(String baseUrl) {
if (checkState("setApiBaseUrl")) {
@@ -1711,6 +1745,24 @@ final class NativeMapView implements NativeMap {
String[] layerIds,
Object[] filter);
@Keep
private native void nativeSetFeatureState(String sourceId,
String sourceLayerId,
String featureId,
JsonObject state);
@Nullable
@Keep
private native JsonObject nativeGetFeatureState(String sourceId,
String sourceLayerId,
String featureId);
@Keep
private native void nativeRemoveFeatureState(String sourceId,
String sourceLayerId,
String featureId,
String stateKey);
@NonNull
@Keep
private native Light nativeGetLight();

View File

@@ -1439,6 +1439,16 @@ public class Expression {
return new Expression("id");
}
/**
* Gets a value from feature state.
*
* @param property the state property to read
* @return the expression
*/
public static Expression featureState(@NonNull String property) {
return new Expression("feature-state", literal(property));
}
/**
* Gets the value of a cluster property accumulated so far. Can only be used in the clusterProperties
* option of a clustered GeoJSON source.

View File

@@ -2,6 +2,7 @@ package org.maplibre.android.style.sources
import androidx.annotation.Keep
import androidx.annotation.UiThread
import com.google.gson.JsonObject
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Geometry
@@ -404,6 +405,67 @@ class GeoJsonSource : Source {
return nativeGetUrl()
}
/**
* Sets the state of a feature in this source.
*
* Feature state can be read in style expressions through `["feature-state", key]`.
* The target feature must already have an id in the underlying source data.
*
* @param featureId the id of the feature whose state to set
* @param state a JSON object with the state key-value pairs to merge
* @return true if the source is attached to a map and the update was dispatched
*/
fun setFeatureState(featureId: String, state: JsonObject): Boolean {
checkThread()
return nativeSetFeatureState(null, featureId, state)
}
/**
* Gets the current state of a feature in this source.
*
* The target feature must already have an id in the underlying source data.
*
* @param featureId the id of the feature whose state to get
* @return the feature state, or null
*/
fun getFeatureState(featureId: String): JsonObject? {
checkThread()
return nativeGetFeatureState(null, featureId)
}
/**
* Removes state from a feature in this source, or from all features when [featureId] is null.
*
* @param featureId the id of the feature, or null to target all features
* @param stateKey the state key to remove, or null to remove all keys
* @return true if the source is attached to a map and the update was dispatched
*/
fun removeFeatureState(featureId: String?, stateKey: String?): Boolean {
checkThread()
return nativeRemoveFeatureState(null, featureId, stateKey)
}
/**
* Removes all state from a single feature in this source.
*
* @param featureId the id of the feature
* @return true if the source is attached to a map and the update was dispatched
*/
fun removeFeatureState(featureId: String): Boolean {
checkThread()
return nativeRemoveFeatureState(null, featureId, null)
}
/**
* Removes all feature state entries from this source.
*
* @return true if the source is attached to a map and the update was dispatched
*/
fun resetFeatureStates(): Boolean {
checkThread()
return nativeRemoveFeatureState(null, null, null)
}
/**
* Queries the source for features.
*

View File

@@ -4,6 +4,7 @@ import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonObject;
import org.maplibre.android.LibraryLoader;
import org.maplibre.android.utils.ThreadUtils;
@@ -198,6 +199,21 @@ public abstract class Source {
@Keep
protected native Long nativeGetMinimumTileUpdateInterval();
@Keep
protected native boolean nativeSetFeatureState(@Nullable String sourceLayerId,
@NonNull String featureId,
@NonNull JsonObject state);
@Nullable
@Keep
protected native JsonObject nativeGetFeatureState(@Nullable String sourceLayerId,
@NonNull String featureId);
@Keep
protected native boolean nativeRemoveFeatureState(@Nullable String sourceLayerId,
@Nullable String featureId,
@Nullable String stateKey);
public void setDetached() {
detached = true;
}

View File

@@ -4,6 +4,7 @@ import android.net.Uri
import androidx.annotation.Keep
import androidx.annotation.Size
import androidx.annotation.UiThread
import com.google.gson.JsonObject
import org.maplibre.geojson.Feature
import org.maplibre.android.style.expressions.Expression
import java.net.URL
@@ -102,6 +103,73 @@ class VectorSource : Source {
initialize(id, tileSet.toValueObject())
}
/**
* Sets the state of a feature in this source.
*
* Feature state can be read in style expressions through `["feature-state", key]`.
* The target feature must already have an id in the underlying source data.
*
* @param sourceLayerId the source layer id
* @param featureId the id of the feature whose state to set
* @param state a JSON object with the state key-value pairs to merge
* @return true if the source is attached to a map and the update was dispatched
*/
fun setFeatureState(sourceLayerId: String, featureId: String, state: JsonObject): Boolean {
checkThread()
return nativeSetFeatureState(sourceLayerId, featureId, state)
}
/**
* Gets the current state of a feature in this source.
*
* The target feature must already have an id in the underlying source data.
*
* @param sourceLayerId the source layer id
* @param featureId the id of the feature whose state to get
* @return the feature state, or null
*/
fun getFeatureState(sourceLayerId: String, featureId: String): JsonObject? {
checkThread()
return nativeGetFeatureState(sourceLayerId, featureId)
}
/**
* Removes state from a feature in this source, or from all features in a source layer
* when [featureId] is null.
*
* @param sourceLayerId the source layer id
* @param featureId the id of the feature, or null to target all features
* @param stateKey the state key to remove, or null to remove all keys
* @return true if the source is attached to a map and the update was dispatched
*/
fun removeFeatureState(sourceLayerId: String, featureId: String?, stateKey: String?): Boolean {
checkThread()
return nativeRemoveFeatureState(sourceLayerId, featureId, stateKey)
}
/**
* Removes all state from a single feature in this source.
*
* @param sourceLayerId the source layer id
* @param featureId the id of the feature
* @return true if the source is attached to a map and the update was dispatched
*/
fun removeFeatureState(sourceLayerId: String, featureId: String): Boolean {
checkThread()
return nativeRemoveFeatureState(sourceLayerId, featureId, null)
}
/**
* Removes all feature state entries from the given source layer.
*
* @param sourceLayerId the source layer id
* @return true if the source is attached to a map and the update was dispatched
*/
fun resetFeatureStates(sourceLayerId: String): Boolean {
checkThread()
return nativeRemoveFeatureState(sourceLayerId, null, null)
}
/**
* Queries the source for features.
*

View File

@@ -261,4 +261,5 @@ class MapLibreMapTest : BaseTest() {
verify { developerAnimationListener.onDeveloperAnimationStarted() }
verify { nativeMapView.setZoom(2.0, target, 0) }
}
}