[android] Bindings for Custom Geometry Sources
This commit is contained in:
@@ -231,6 +231,25 @@ public class LatLngBounds implements Parcelable {
|
||||
return new LatLngBounds(latNorth, lonEast, latSouth, lonWest);
|
||||
}
|
||||
|
||||
private static double lat_(int z, int y) {
|
||||
double n = Math.PI - 2.0 * Math.PI * y / Math.pow(2.0, z);
|
||||
return Math.toDegrees(Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))));
|
||||
}
|
||||
|
||||
private static double lon_(int z, int x) {
|
||||
return x / Math.pow(2.0, z) * 360.0 - GeoConstants.MAX_LONGITUDE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a LatLngBounds from a Tile identifier.
|
||||
* @param z Tile zoom level.
|
||||
* @param x Tile X coordinate.
|
||||
* @param y Tile Y coordinate.
|
||||
*/
|
||||
public static LatLngBounds from(int z, int x, int y) {
|
||||
return new LatLngBounds(lat_(z, y), lon_(z, x + 1), lat_(z, y + 1), lon_(z, x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a LatLngBounds from current bounds with an additional latitude-longitude pair.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.mapbox.mapboxsdk.style.sources;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.annotation.UiThread;
|
||||
import android.support.annotation.WorkerThread;
|
||||
|
||||
import com.mapbox.mapboxsdk.geometry.LatLngBounds;
|
||||
import com.mapbox.mapboxsdk.style.layers.Filter;
|
||||
import com.mapbox.services.commons.geojson.Feature;
|
||||
import com.mapbox.services.commons.geojson.FeatureCollection;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Custom Vector Source, allows using FeatureCollections.
|
||||
*
|
||||
*/
|
||||
@UiThread
|
||||
public class CustomGeometrySource extends Source {
|
||||
private ExecutorService executor;
|
||||
private GeometryTileProvider provider;
|
||||
private final Map<TileID, AtomicBoolean> cancelledTileRequests = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Create a CustomGeometrySource
|
||||
*
|
||||
* @param id The source id.
|
||||
* @param provider The tile provider that returns geometry data for this source.
|
||||
*/
|
||||
public CustomGeometrySource(String id, GeometryTileProvider provider) {
|
||||
this(id, provider, new GeoJsonOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CustomGeometrySource with non-default GeoJsonOptions.
|
||||
* <p>Supported options are minZoom, maxZoom, buffer, and tolerance.</p>
|
||||
*
|
||||
* @param id The source id.
|
||||
* @param provider The tile provider that returns geometry data for this source.
|
||||
* @param options GeoJsonOptions.
|
||||
*/
|
||||
public CustomGeometrySource(String id, GeometryTileProvider provider, GeoJsonOptions options) {
|
||||
this.provider = provider;
|
||||
executor = Executors.newFixedThreadPool(4);
|
||||
initialize(this, id, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate previously provided features within a given bounds at all zoom levels.
|
||||
* Invoking this method will result in new requests to `GeometryTileProvider` for regions
|
||||
* that contain, include, or intersect with the provided bounds.
|
||||
*
|
||||
* @param bounds The region in which features should be invalidated at all zoom levels
|
||||
*/
|
||||
public void invalidateRegion(LatLngBounds bounds) {
|
||||
nativeInvalidateBounds(bounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the geometry contents of a specific tile. Invoking this method will result
|
||||
* in new requests to `GeometryTileProvider` for visible tiles.
|
||||
*
|
||||
* @param zoomLevel Tile zoom level.
|
||||
* @param x Tile X coordinate.
|
||||
* @param y Tile Y coordinate.
|
||||
*/
|
||||
public void invalidateTile(int zoomLevel, int x, int y) {
|
||||
nativeInvalidateTile(zoomLevel, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update geometry contents of a specific tile. Use this method to update tiles
|
||||
* for which `GeometryTileProvider` was previously invoked. This method can be called from
|
||||
* background threads.
|
||||
*
|
||||
* @param zoomLevel Tile zoom level.
|
||||
* @param x Tile X coordinate.
|
||||
* @param y Tile Y coordinate.
|
||||
* @param data Feature collection for the tile.
|
||||
*/
|
||||
public void setTileData(int zoomLevel, int x, int y, FeatureCollection data) {
|
||||
nativeSetTileData(zoomLevel, x, y, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the source for features.
|
||||
*
|
||||
* @param filter an optional filter statement to filter the returned Features
|
||||
* @return the features
|
||||
*/
|
||||
@NonNull
|
||||
public List<Feature> querySourceFeatures(@Nullable Filter.Statement filter) {
|
||||
Feature[] features = querySourceFeatures(filter != null ? filter.toArray() : null);
|
||||
return features != null ? Arrays.asList(features) : new ArrayList<Feature>();
|
||||
}
|
||||
|
||||
protected native void initialize(CustomGeometrySource self, String sourceId, Object options);
|
||||
|
||||
private native Feature[] querySourceFeatures(Object[] filter);
|
||||
|
||||
private native void nativeSetTileData(int z, int x, int y, FeatureCollection data);
|
||||
|
||||
private native void nativeInvalidateTile(int z, int x, int y);
|
||||
|
||||
private native void nativeInvalidateBounds(LatLngBounds bounds);
|
||||
|
||||
@Override
|
||||
protected native void finalize() throws Throwable;
|
||||
|
||||
private void setTileData(TileID tileId, FeatureCollection data) {
|
||||
cancelledTileRequests.remove(tileId);
|
||||
nativeSetTileData(tileId.z, tileId.x, tileId.y, data);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void fetchTile(int z, int x, int y) {
|
||||
AtomicBoolean cancelFlag = new AtomicBoolean(false);
|
||||
TileID tileID = new TileID(z, x, y);
|
||||
cancelledTileRequests.put(tileID, cancelFlag);
|
||||
GeometryTileRequest request = new GeometryTileRequest(tileID, provider, this, cancelFlag);
|
||||
executor.submit(request);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void cancelTile(int z, int x, int y) {
|
||||
AtomicBoolean cancelFlag = cancelledTileRequests.get(new TileID(z, x, y));
|
||||
if (cancelFlag != null) {
|
||||
cancelFlag.compareAndSet(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static class TileID {
|
||||
public int z;
|
||||
public int x;
|
||||
public int y;
|
||||
|
||||
public TileID(int _z, int _x, int _y) {
|
||||
z = _z;
|
||||
x = _x;
|
||||
y = _y;
|
||||
}
|
||||
|
||||
public int hashCode() {
|
||||
return Arrays.hashCode(new int[]{z, x, y});
|
||||
}
|
||||
|
||||
public boolean equals(Object object) {
|
||||
if (object == this) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (object == null || getClass() != object.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (object instanceof TileID) {
|
||||
TileID other = (TileID)object;
|
||||
return this.z == other.z && this.x == other.x && this.y == other.y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class GeometryTileRequest implements Runnable {
|
||||
private TileID id;
|
||||
private GeometryTileProvider provider;
|
||||
private WeakReference<CustomGeometrySource> sourceRef;
|
||||
private AtomicBoolean cancelled;
|
||||
|
||||
public GeometryTileRequest(TileID _id, GeometryTileProvider p,
|
||||
CustomGeometrySource _source, AtomicBoolean _cancelled) {
|
||||
id = _id;
|
||||
provider = p;
|
||||
sourceRef = new WeakReference<>(_source);
|
||||
cancelled = _cancelled;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
FeatureCollection data = provider.getFeaturesForBounds(LatLngBounds.from(id.z, id.x, id.y), id.z);
|
||||
CustomGeometrySource source = sourceRef.get();
|
||||
if (!isCancelled() && source != null && !data.getFeatures().isEmpty()) {
|
||||
source.setTileData(id, data);
|
||||
}
|
||||
}
|
||||
|
||||
private Boolean isCancelled() {
|
||||
return cancelled.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,17 @@ import java.util.HashMap;
|
||||
*/
|
||||
public class GeoJsonOptions extends HashMap<String, Object> {
|
||||
|
||||
/**
|
||||
* Maximum zoom level at which to create vector tiles (higher means greater detail at high zoom levels).
|
||||
*
|
||||
* @param maxZoom the maximum zoom - Defaults to 18.
|
||||
* @return the current instance for chaining
|
||||
*/
|
||||
public GeoJsonOptions withMinZoom(int minZoom) {
|
||||
this.put("minzoom", minZoom);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum zoom level at which to create vector tiles (higher means greater detail at high zoom levels).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.mapbox.mapboxsdk.style.sources;
|
||||
|
||||
import android.support.annotation.WorkerThread;
|
||||
|
||||
import com.mapbox.mapboxsdk.geometry.LatLngBounds;
|
||||
import com.mapbox.services.commons.geojson.FeatureCollection;
|
||||
|
||||
/**
|
||||
* Interface that defines methods for working with {@link CustomGeometrySource}.
|
||||
*/
|
||||
public interface GeometryTileProvider {
|
||||
|
||||
/***
|
||||
* Interface method called by {@link CustomGeometrySource} to request features for a tile.
|
||||
*
|
||||
* @param bounds {@link LatLngBounds} of the tile.
|
||||
* @param zoomLevel Tile zoom level.
|
||||
* @return Return a @{link FeatureCollection} to be displayed in the requested tile.
|
||||
*/
|
||||
@WorkerThread
|
||||
FeatureCollection getFeaturesForBounds(LatLngBounds bounds, int zoomLevel);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.os.Parcelable;
|
||||
|
||||
import com.mapbox.mapboxsdk.exceptions.InvalidLatLngBoundsException;
|
||||
import com.mapbox.mapboxsdk.utils.MockParcel;
|
||||
import com.mapbox.services.android.telemetry.constants.GeoConstants;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
@@ -272,4 +273,22 @@ public class LatLngBoundsTest {
|
||||
Parcelable parcel = MockParcel.obtain(latLngBounds);
|
||||
assertEquals("Parcel should match original object", parcel, latLngBounds);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fromTileID() {
|
||||
//GeoConstants.MAX_LATITUDE is not defined to a high enough precision
|
||||
double MAX_LATITUDE = 85.05112877980659;
|
||||
LatLngBounds bounds = LatLngBounds.from(0, 0, 0);
|
||||
assertEquals(-GeoConstants.MAX_LONGITUDE, bounds.getLonWest(), DELTA);
|
||||
assertEquals(-MAX_LATITUDE, bounds.getLatSouth(), DELTA);
|
||||
assertEquals(GeoConstants.MAX_LONGITUDE, bounds.getLonEast(), DELTA);
|
||||
assertEquals(MAX_LATITUDE, bounds.getLatNorth(), DELTA);
|
||||
|
||||
bounds = LatLngBounds.from(10, 288, 385);
|
||||
assertEquals(-78.75, bounds.getLonWest(), DELTA);
|
||||
assertEquals(40.446947059600497, bounds.getLatSouth(), DELTA);
|
||||
assertEquals(-78.3984375, bounds.getLonEast(), DELTA);
|
||||
assertEquals(40.713955826286039, bounds.getLatNorth(), DELTA);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
add_definitions(-DMBGL_USE_GLES2=1)
|
||||
|
||||
include(cmake/test-files.cmake)
|
||||
|
||||
# Build thin archives.
|
||||
@@ -178,6 +177,8 @@ add_library(mbgl-android STATIC
|
||||
platform/android/src/style/layers/unknown_layer.hpp
|
||||
platform/android/src/style/sources/geojson_source.cpp
|
||||
platform/android/src/style/sources/geojson_source.hpp
|
||||
platform/android/src/style/sources/custom_geometry_source.cpp
|
||||
platform/android/src/style/sources/custom_geometry_source.hpp
|
||||
platform/android/src/style/sources/source.cpp
|
||||
platform/android/src/style/sources/source.hpp
|
||||
platform/android/src/style/sources/sources.cpp
|
||||
|
||||
122
platform/android/src/style/sources/custom_geometry_source.cpp
Normal file
122
platform/android/src/style/sources/custom_geometry_source.cpp
Normal file
@@ -0,0 +1,122 @@
|
||||
#include "custom_geometry_source.hpp"
|
||||
|
||||
#include <mbgl/renderer/query.hpp>
|
||||
|
||||
// Java -> C++ conversion
|
||||
#include "../android_conversion.hpp"
|
||||
#include "../conversion/filter.hpp"
|
||||
//#include "../conversion/geojson.hpp"
|
||||
|
||||
// C++ -> Java conversion
|
||||
#include "../../conversion/conversion.hpp"
|
||||
#include "../../conversion/collection.hpp"
|
||||
#include "../../geojson/conversion/feature.hpp"
|
||||
#include <mbgl/style/conversion/custom_geometry_source_options.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace mbgl {
|
||||
namespace android {
|
||||
|
||||
// This conversion is expected not to fail because it's used only in contexts where
|
||||
// the value was originally a GeoJsonOptions object on the Java side. If it fails
|
||||
// to convert, it's a bug in our serialization or Java-side static typing.
|
||||
static style::CustomGeometrySource::Options convertCustomGeometrySourceOptions(jni::JNIEnv& env, jni::Object<> options, style::TileFunction fetchFn, style::TileFunction cancelFn) {
|
||||
using namespace mbgl::style::conversion;
|
||||
if (!options) {
|
||||
return style::CustomGeometrySource::Options();
|
||||
}
|
||||
Error error;
|
||||
optional<style::CustomGeometrySource::Options> result = convert<style::CustomGeometrySource::Options>(Value(env, options), error);
|
||||
if (!result) {
|
||||
throw std::logic_error(error.message);
|
||||
}
|
||||
result->fetchTileFunction = fetchFn;
|
||||
result->cancelTileFunction = cancelFn;
|
||||
return *result;
|
||||
}
|
||||
|
||||
CustomGeometrySource::CustomGeometrySource(jni::JNIEnv& env, jni::Object<CustomGeometrySource> _obj, jni::String sourceId, jni::Object<> options)
|
||||
: Source(env, std::make_unique<mbgl::style::CustomGeometrySource>(
|
||||
jni::Make<std::string>(env, sourceId),
|
||||
convertCustomGeometrySourceOptions(env,
|
||||
options,
|
||||
std::bind(&CustomGeometrySource::fetchTile, this, std::placeholders::_1),
|
||||
std::bind(&CustomGeometrySource::cancelTile, this, std::placeholders::_1))) ),
|
||||
javaPeer(_obj.NewGlobalRef(env)) {
|
||||
}
|
||||
|
||||
CustomGeometrySource::~CustomGeometrySource() = default;
|
||||
|
||||
void CustomGeometrySource::fetchTile (const mbgl::CanonicalTileID& tileID) {
|
||||
android::UniqueEnv _env = android::AttachEnv();
|
||||
static auto fetchTile = javaClass.GetMethod<void (jni::jint, jni::jint, jni::jint)>(*_env, "fetchTile");
|
||||
assert(javaPeer);
|
||||
javaPeer->Call(*_env, fetchTile, (int)tileID.z, (int)tileID.x, (int)tileID.y);
|
||||
};
|
||||
|
||||
void CustomGeometrySource::cancelTile(const mbgl::CanonicalTileID& tileID) {
|
||||
android::UniqueEnv _env = android::AttachEnv();
|
||||
static auto cancelTile = javaClass.GetMethod<void (jni::jint, jni::jint, jni::jint)>(*_env, "cancelTile");
|
||||
assert(javaPeer);
|
||||
javaPeer->Call(*_env, cancelTile, (int)tileID.z, (int)tileID.x, (int)tileID.y);
|
||||
};
|
||||
|
||||
void CustomGeometrySource::setTileData(jni::JNIEnv& env, jni::jint z, jni::jint x, jni::jint y, jni::Object<geojson::FeatureCollection> jFeatures) {
|
||||
using namespace mbgl::android::geojson;
|
||||
|
||||
// Convert the jni object
|
||||
auto geometry = geojson::FeatureCollection::convert(env, jFeatures);
|
||||
|
||||
// Update the core source
|
||||
source.as<mbgl::style::CustomGeometrySource>()->CustomGeometrySource::setTileData(CanonicalTileID(z, x, y), GeoJSON(geometry));
|
||||
}
|
||||
|
||||
void CustomGeometrySource::invalidateTile(jni::JNIEnv&, jni::jint z, jni::jint x, jni::jint y) {
|
||||
source.as<mbgl::style::CustomGeometrySource>()->CustomGeometrySource::invalidateTile(CanonicalTileID(z, x, y));
|
||||
}
|
||||
void CustomGeometrySource::invalidateBounds(jni::JNIEnv& env, jni::Object<LatLngBounds> jBounds) {
|
||||
auto bounds = LatLngBounds::getLatLngBounds(env, jBounds);
|
||||
source.as<mbgl::style::CustomGeometrySource>()->CustomGeometrySource::invalidateRegion(bounds);
|
||||
}
|
||||
|
||||
jni::Array<jni::Object<geojson::Feature>> CustomGeometrySource::querySourceFeatures(jni::JNIEnv& env,
|
||||
jni::Array<jni::Object<>> jfilter) {
|
||||
using namespace mbgl::android::conversion;
|
||||
using namespace mbgl::android::geojson;
|
||||
|
||||
std::vector<mbgl::Feature> features;
|
||||
if (rendererFrontend) {
|
||||
features = rendererFrontend->querySourceFeatures(source.getID(), { {}, toFilter(env, jfilter) });
|
||||
}
|
||||
return *convert<jni::Array<jni::Object<Feature>>, std::vector<mbgl::Feature>>(env, features);
|
||||
}
|
||||
|
||||
jni::Class<CustomGeometrySource> CustomGeometrySource::javaClass;
|
||||
|
||||
jni::jobject* CustomGeometrySource::createJavaPeer(jni::JNIEnv& env) {
|
||||
static auto constructor = CustomGeometrySource::javaClass.template GetConstructor<jni::jlong>(env);
|
||||
return CustomGeometrySource::javaClass.New(env, constructor, reinterpret_cast<jni::jlong>(this));
|
||||
}
|
||||
|
||||
void CustomGeometrySource::registerNative(jni::JNIEnv& env) {
|
||||
// Lookup the class
|
||||
CustomGeometrySource::javaClass = *jni::Class<CustomGeometrySource>::Find(env).NewGlobalRef(env).release();
|
||||
|
||||
#define METHOD(MethodPtr, name) jni::MakeNativePeerMethod<decltype(MethodPtr), (MethodPtr)>(name)
|
||||
|
||||
// Register the peer
|
||||
jni::RegisterNativePeer<CustomGeometrySource>(
|
||||
env, CustomGeometrySource::javaClass, "nativePtr",
|
||||
std::make_unique<CustomGeometrySource, JNIEnv&, jni::Object<CustomGeometrySource>, jni::String, jni::Object<>>,
|
||||
"initialize",
|
||||
"finalize",
|
||||
METHOD(&CustomGeometrySource::querySourceFeatures, "querySourceFeatures"),
|
||||
METHOD(&CustomGeometrySource::setTileData, "nativeSetTileData"),
|
||||
METHOD(&CustomGeometrySource::invalidateTile, "nativeInvalidateTile"),
|
||||
METHOD(&CustomGeometrySource::invalidateBounds, "nativeInvalidateBounds")
|
||||
);
|
||||
}
|
||||
|
||||
} // namespace android
|
||||
} // namespace mbgl
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include "source.hpp"
|
||||
#include <mbgl/style/sources/custom_geometry_source.hpp>
|
||||
#include <mbgl/util/geojson.hpp>
|
||||
#include <mbgl/tile/tile_id.hpp>
|
||||
#include "../../geojson/geometry.hpp"
|
||||
#include "../../geojson/feature.hpp"
|
||||
#include "../../geojson/feature_collection.hpp"
|
||||
#include "../../geometry/lat_lng_bounds.hpp"
|
||||
#include <jni/jni.hpp>
|
||||
|
||||
namespace mbgl {
|
||||
namespace android {
|
||||
|
||||
class CustomGeometrySource : public Source {
|
||||
public:
|
||||
|
||||
static constexpr auto Name() { return "com/mapbox/mapboxsdk/style/sources/CustomGeometrySource"; };
|
||||
|
||||
static jni::Class<CustomGeometrySource> javaClass;
|
||||
|
||||
static void registerNative(jni::JNIEnv&);
|
||||
|
||||
CustomGeometrySource(jni::JNIEnv&,
|
||||
jni::Object<CustomGeometrySource>,
|
||||
jni::String,
|
||||
jni::Object<>);
|
||||
|
||||
~CustomGeometrySource();
|
||||
|
||||
void fetchTile(const mbgl::CanonicalTileID& tileID);
|
||||
void cancelTile(const mbgl::CanonicalTileID& tileID);
|
||||
void setTileData(jni::JNIEnv& env, jni::jint z, jni::jint x, jni::jint y, jni::Object<geojson::FeatureCollection> jf);
|
||||
|
||||
void invalidateTile(jni::JNIEnv& env, jni::jint z, jni::jint x, jni::jint y);
|
||||
void invalidateBounds(jni::JNIEnv& env, jni::Object<LatLngBounds> bounds);
|
||||
|
||||
jni::Array<jni::Object<geojson::Feature>> querySourceFeatures(jni::JNIEnv&,
|
||||
jni::Array<jni::Object<>> );
|
||||
|
||||
jni::jobject* createJavaPeer(jni::JNIEnv&);
|
||||
|
||||
jni::UniqueObject<CustomGeometrySource> javaPeer;
|
||||
}; // class CustomGeometrySource
|
||||
|
||||
} // namespace android
|
||||
} // namespace mbgl
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "raster_source.hpp"
|
||||
#include "unknown_source.hpp"
|
||||
#include "vector_source.hpp"
|
||||
#include "custom_geometry_source.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -54,6 +55,7 @@ void registerNativeSources(jni::JNIEnv& env) {
|
||||
RasterSource::registerNative(env);
|
||||
UnknownSource::registerNative(env);
|
||||
VectorSource::registerNative(env);
|
||||
CustomGeometrySource::registerNative(env);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user