diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 00000000000..8bb1bfb0a7f
Binary files /dev/null and b/.DS_Store differ
diff --git a/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java b/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java
index e4180713f0f..bba85d9c927 100755
--- a/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java
+++ b/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java
@@ -5363,6 +5363,14 @@ String newerVersionComponentException(String componentType, int srcCompVersion,
@Description("Terrain map type")
String mapTypeTerrain();
+ @DefaultMessage("Custom")
+ @Description("Custom map type")
+ String mapTypeCustom();
+
+ @DefaultMessage("CustomUrl")
+ @Description("The URL of the custom tile layer to use as the base of the map")
+ String mapCustomUrl();
+
@DefaultMessage("Metric")
@Description("Display name for the metric unit system")
String mapScaleUnitsMetric();
@@ -5431,6 +5439,22 @@ String newerVersionComponentException(String componentType, int srcCompVersion,
@Description("")
String expectedLatLongPair(String property);
+ @DefaultMessage("The provided URL {0} does not contain placeholders for {1}.") // Can't use {x} here, Java compiler tries to interpret the variable x
+ @Description("")
+ String customUrlNoPlaceholders(String property, String placeholders);
+
+ @DefaultMessage("The provided URL {0}, when tested, failed authentication (with HTTP status code {1}).")
+ @Description("")
+ String customUrlBadAuthentication(String property, int statusCode);
+
+ @DefaultMessage("The provided URL {0}, when tested, returned a bad HTTP status code ({1}).")
+ @Description("")
+ String customUrlBadStatusCode(String property, int statusCode);
+
+ @DefaultMessage("The provided URL {0}, when tested, returned an exception ({1}).")
+ @Description("")
+ String customUrlException(String property, String e);
+
@DefaultMessage("Notice!")
@Description("Title for the Warning Dialog Box")
String NoticeTitle();
diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockMap.java b/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockMap.java
index 16081ff965d..f324657bd6e 100644
--- a/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockMap.java
+++ b/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockMap.java
@@ -29,6 +29,7 @@ public final class MockMap extends MockContainer {
protected static final String PROPERTY_NAME_LATITUDE = "Latitude";
protected static final String PROPERTY_NAME_LONGITUDE = "Longitude";
protected static final String PROPERTY_NAME_MAP_TYPE = "MapType";
+ protected static final String PROPERTY_NAME_CUSTOM_URL = "CustomUrl";
protected static final String PROPERTY_NAME_CENTER_FROM_STRING = "CenterFromString";
protected static final String PROPERTY_NAME_ZOOM_LEVEL = "ZoomLevel";
protected static final String PROPERTY_NAME_SHOW_COMPASS = "ShowCompass";
@@ -181,6 +182,8 @@ public void onPropertyChange(String propertyName, String newValue) {
invalidateMap();
} else if (propertyName.equals(PROPERTY_NAME_MAP_TYPE)) {
setMapType(newValue);
+ } else if (propertyName.equals(PROPERTY_NAME_CUSTOM_URL)) {
+ setCustomUrl(newValue);
} else if (propertyName.equals(PROPERTY_NAME_CENTER_FROM_STRING)) {
setCenter(newValue);
} else if (propertyName.equals(PROPERTY_NAME_ZOOM_LEVEL)) {
@@ -222,6 +225,10 @@ private void setMapType(String tileLayerId) {
}
}
+ private void setCustomUrl(String newCustomUrl) {
+ updateCustomUrl(newCustomUrl);
+ }
+
private void setCenter(String center) {
String[] parts = center.split(",");
if (parts.length != 2) {
@@ -484,7 +491,10 @@ private native void initPanel()/*-{
attribution: 'Satellite imagery © USGS'}),
L.tileLayer('//basemap.nationalmap.gov/ArcGIS/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}',
{minZoom: 0, maxZoom: 15,
- attribution: 'Map data © USGS'})
+ attribution: 'Map data © USGS'}),
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ {minZoom: 0, maxZoom: 18,
+ attribution: 'Custom map'})
];
this.@com.google.appinventor.client.editor.simple.components.MockMap::tileLayers = tileLayers;
this.@com.google.appinventor.client.editor.simple.components.MockMap::baseLayer =
@@ -606,6 +616,23 @@ private native void updateMapType(int type)/*-{
}
}-*/;
+ private native void updateCustomUrl(String customUrl)/*-{
+ var L = $wnd.top.L;
+ var map = this.@com.google.appinventor.client.editor.simple.components.MockMap::mapInstance;
+ var tileLayers = this.@com.google.appinventor.client.editor.simple.components.MockMap::tileLayers;
+ var baseLayer = this.@com.google.appinventor.client.editor.simple.components.MockMap::baseLayer;
+ if (map && baseLayer && tileLayers) {
+ tileLayers[4] = L.tileLayer(customUrl,
+ {minZoom: 0, maxZoom: 18,
+ attribution: 'Custom map data'});
+ map.removeLayer(baseLayer);
+ baseLayer = tileLayers[4];
+ map.addLayer(baseLayer);
+ baseLayer.bringToBack();
+ this.@com.google.appinventor.client.editor.simple.components.MockMap::tileLayers = tileLayers;
+ }
+ }-*/;
+
native LatLng projectFromXY(int x, int y)/*-{
var map = this.@com.google.appinventor.client.editor.simple.components.MockMap::mapInstance;
if (map) {
diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/utils/PropertiesUtil.java b/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/utils/PropertiesUtil.java
index 52f979dd708..b975d056ad8 100644
--- a/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/utils/PropertiesUtil.java
+++ b/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/utils/PropertiesUtil.java
@@ -45,6 +45,7 @@
import com.google.appinventor.client.editor.youngandroid.properties.YoungAndroidListViewLayoutChoicePropertyEditor;
import com.google.appinventor.client.editor.youngandroid.properties.YoungAndroidMapScaleUnitsPropertyEditor;
import com.google.appinventor.client.editor.youngandroid.properties.YoungAndroidMapTypePropertyEditor;
+import com.google.appinventor.client.editor.youngandroid.properties.YoungAndroidMapCustomUrlPropertyEditor;
import com.google.appinventor.client.editor.youngandroid.properties.YoungAndroidNavigationMethodChoicePropertyEditor;
import com.google.appinventor.client.editor.youngandroid.properties.YoungAndroidRecyclerViewOrientationPropertyEditor;
import com.google.appinventor.client.editor.youngandroid.properties.YoungAndroidScreenAnimationChoicePropertyEditor;
@@ -266,6 +267,8 @@ public static PropertyEditor createPropertyEditor(String editorType, String defa
return new YoungAndroidFloatRangePropertyEditor(-180, 180);
} else if (editorType.equals(PropertyTypeConstants.PROPERTY_TYPE_MAP_TYPE)) {
return new YoungAndroidMapTypePropertyEditor();
+ } else if (editorType.equals(PropertyTypeConstants.PROPERTY_TYPE_MAP_CUSTOMURL)) {
+ return new YoungAndroidMapCustomUrlPropertyEditor();
} else if (editorType.equals(PropertyTypeConstants.PROPERTY_TYPE_MAP_UNIT_SYSTEM)) {
return new YoungAndroidMapScaleUnitsPropertyEditor();
} else if (editorType.equals(PropertyTypeConstants.PROPERTY_TYPE_MAP_ZOOM)) {
diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidMapCustomUrlPropertyEditor.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidMapCustomUrlPropertyEditor.java
new file mode 100644
index 00000000000..502ee4d64ae
--- /dev/null
+++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidMapCustomUrlPropertyEditor.java
@@ -0,0 +1,65 @@
+// -*- mode: java; c-basic-offset: 2; -*-
+// Copyright 2024 MIT, All rights reserved
+// Released under the Apache License, Version 2.0
+// http://www.apache.org/licenses/LICENSE-2.0
+
+package com.google.appinventor.client.editor.youngandroid.properties;
+
+import com.google.appinventor.client.widgets.properties.TextPropertyEditor;
+import static com.google.appinventor.client.Ode.MESSAGES;
+import com.google.gwt.http.client.*;
+import com.google.gwt.user.client.Window;
+
+/**
+ * Property editor for Map custom URL matching a particular format.
+ */
+public class YoungAndroidMapCustomUrlPropertyEditor extends TextPropertyEditor {
+
+ public YoungAndroidMapCustomUrlPropertyEditor() {
+ }
+
+ @Override
+ protected void validate(String text) throws InvalidTextException {
+ // Check that the custom URL looks vaguely correct
+ if (!(text.startsWith("https://") || text.startsWith("http://"))
+ || !text.contains("{x}")
+ || !text.contains("{y}")
+ || !text.contains("{z}")) {
+ throw new InvalidTextException(MESSAGES.customUrlNoPlaceholders(text, "{x}, {y} and {z}"));
+ }
+
+ // Try to request a single tile from the custom URL source as a final validation, only report errors
+ String urlString = text.replace("{x}", "0")
+ .replace("{y}", "0")
+ .replace("{z}", "0");
+ RequestBuilder builder = new RequestBuilder(RequestBuilder.GET, urlString);
+ try {
+ builder.sendRequest(null, new RequestCallback() {
+ @Override
+ public void onResponseReceived(Request request, Response response) {
+ handleResponseCode(urlString, response.getStatusCode());
+ }
+
+ @Override
+ public void onError(Request request, Throwable exception) {
+ handleRequestError(urlString, exception);
+ }
+ });
+ } catch (RequestException e) {
+ throw new InvalidTextException(MESSAGES.customUrlException(urlString, e.getMessage()));
+ }
+ }
+
+ // Window.alert is used here, rather than throw InvalidTextException, due to RequestBuilder Override signatures
+ private void handleResponseCode(String urlString, int responseCode) {
+ if (responseCode == 401 || responseCode == 403) {
+ Window.alert(MESSAGES.customUrlBadAuthentication(urlString, responseCode));
+ } else if (responseCode >= 400) {
+ Window.alert(MESSAGES.customUrlBadStatusCode(urlString, responseCode));
+ }
+ }
+
+ private void handleRequestError(String urlString, Throwable exception) {
+ Window.alert(MESSAGES.customUrlException(urlString, exception.getMessage()));
+ }
+}
\ No newline at end of file
diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidMapTypePropertyEditor.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidMapTypePropertyEditor.java
index d5ad7042aa7..371df9f6d49 100644
--- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidMapTypePropertyEditor.java
+++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidMapTypePropertyEditor.java
@@ -19,7 +19,8 @@ public class YoungAndroidMapTypePropertyEditor extends ChoicePropertyEditor {
private static final Choice[] mapTypes = new Choice[] {
new Choice(MESSAGES.mapTypeRoads(), "1"),
new Choice(MESSAGES.mapTypeAerial(), "2"),
- new Choice(MESSAGES.mapTypeTerrain(), "3")
+ new Choice(MESSAGES.mapTypeTerrain(), "3"),
+ new Choice(MESSAGES.mapTypeCustom(), "4")
};
public YoungAndroidMapTypePropertyEditor() {
diff --git a/appinventor/appengine/src/com/google/appinventor/client/youngandroid/YoungAndroidFormUpgrader.java b/appinventor/appengine/src/com/google/appinventor/client/youngandroid/YoungAndroidFormUpgrader.java
index f916d4d5c0d..1beeab0970f 100644
--- a/appinventor/appengine/src/com/google/appinventor/client/youngandroid/YoungAndroidFormUpgrader.java
+++ b/appinventor/appengine/src/com/google/appinventor/client/youngandroid/YoungAndroidFormUpgrader.java
@@ -2064,6 +2064,10 @@ private static int upgradeMapProperties(Map A two-dimensional container that renders map tiles in the background and " +
"allows for multiple Marker elements to identify points on the map. Map tiles are supplied " +
- "by OpenStreetMap contributors and the United States Geological Survey.
The Map component provides three utilities for manipulating its boundaries within App " + "Inventor. First, a locking mechanism is provided to allow the map to be moved relative to " + "other components on the Screen. Second, when unlocked, the user can pan the Map to any " + @@ -123,6 +123,7 @@ public Map(final ComponentContainer container) { EnableZoom(true); EnablePan(true); MapTypeAbstract(MapType.Road); + CustomUrl("https://tile.openstreetmap.org/{z}/{x}/{y}.png"); ShowCompass(false); LocationSensor(new LocationSensor(container.$form(), false)); ShowUser(false); @@ -301,6 +302,7 @@ public float Rotation() { * 1. Roads * 2. Aerial * 3. Terrain + * 4. Custom * * @param type Integer identifying the tile set to use for the map's base layer. */ @@ -321,6 +323,7 @@ public void MapType(@Options(MapType.class) int type) { * 1. Roads * 2. Aerial * 3. Terrain + * 4. Custom * * **Note:** Road layers are provided by OpenStreetMap and aerial and terrain layers are * provided by the U.S. Geological Survey. @@ -329,7 +332,7 @@ public void MapType(@Options(MapType.class) int type) { */ @SimpleProperty(category = PropertyCategory.APPEARANCE, description = "The type of tile layer to use as the base of the map. Valid values " + - "are: 1 (Roads), 2 (Aerial), 3 (Terrain)") + "are: 1 (Roads), 2 (Aerial), 3 (Terrain), 4 (Custom)") public @Options(MapType.class) int MapType() { return MapTypeAbstract().toUnderlyingValue(); } @@ -350,6 +353,30 @@ public void MapTypeAbstract(MapType type) { mapController.setMapTypeAbstract(type); } + /** + * @return Returns the custom URL of the base tile layer in use by the map. + */ + @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_MAP_CUSTOMURL, + defaultValue = "https://tile.openstreetmap.org/{z}/{x}/{y}.png") + @SimpleProperty(category = PropertyCategory.ADVANCED, + description = "The URL of the custom tile layer to use as the base of the map. Valid URLs " + + "should include {z}, {x} and {y} placeholders and any authentication required.
" + + "e.g. https://tile.openstreetmap.org/{z}/{x}/{y}.png " + + "or https://example.com/geoserver/gwc/service/tms/1.0.0/workspace:layername@EPSG:3857@jpeg/{z}/{x}/{y}.jpeg?flipY=true&authkey=123") + public String CustomUrl() { + return mapController.getCustomUrl(); + } + + /** + * Update the custom URL of the base tile layer in use by the map. + * e.g. https://tile.openstreetmap.org/{z}/{x}/{y}.png + * e.g. https://example.com/geoserver/gwc/service/tms/1.0.0/workspace:layername@EPSG:3857@jpeg/{z}/{x}/{y}.jpeg?flipY=true&authkey=123 + */ + @SimpleProperty(category = PropertyCategory.ADVANCED) + public void CustomUrl(String url) { + mapController.setCustomUrl(url); + } + /** * Show a compass on the map. If the device provides a digital compass, orientation changes will * be used to rotate the compass icon. diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/util/DummyMapController.java b/appinventor/components/src/com/google/appinventor/components/runtime/util/DummyMapController.java index 5fe04b24e2e..b1f2becad13 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/util/DummyMapController.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/util/DummyMapController.java @@ -65,6 +65,14 @@ public MapFactory.MapType getMapType() { throw new UnsupportedOperationException(); } + public void setCustomUrl(String url) { + throw new UnsupportedOperationException(); + } + + public String getCustomUrl() { + throw new UnsupportedOperationException(); + } + public void setMapTypeAbstract(MapType type) { throw new UnsupportedOperationException(); } diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/util/MapFactory.java b/appinventor/components/src/com/google/appinventor/components/runtime/util/MapFactory.java index db63fc8ff57..1dc170c71cb 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/util/MapFactory.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/util/MapFactory.java @@ -201,6 +201,20 @@ public interface MapController { */ void setMapTypeAbstract(com.google.appinventor.components.common.MapType type); + /** + * Get the customUrl of the map being used. + * + * @return the customUrl of the map's active tile layer + */ + String getCustomUrl(); + + /** + * Set the customUrl of the map being used. + * + * @param Set the new map customUrl for the map + */ + void setCustomUrl(String url); + /** * Set whether the compass is displayed on the map. * @@ -1313,6 +1327,7 @@ public interface MapMarker extends MapFeature, HasFill, HasStroke { * prior to official release. Some apps developed using earlier versions of Maps on test servers * may reference this property, but it may be removed in a future version of App Inventor. */ + @Deprecated @SuppressWarnings("squid:S00100") void ShowShadow(boolean show); @@ -1321,6 +1336,7 @@ public interface MapMarker extends MapFeature, HasFill, HasStroke { * @return true if the marker should have a shadow, otherwise false. * @deprecated See the deprecation message for {@link #ShowShadow(boolean)}. */ + @Deprecated @SuppressWarnings("squid:S00100") boolean ShowShadow(); @@ -1577,7 +1593,12 @@ public enum MapType { /** * Terrain tile layer. */ - TERRAIN + TERRAIN, + + /** + * Custom tile layer. + */ + CUSTOM } /** diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/util/NativeOpenStreetMapController.java b/appinventor/components/src/com/google/appinventor/components/runtime/util/NativeOpenStreetMapController.java index 404dc4751f1..51bb091f6ea 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/util/NativeOpenStreetMapController.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/util/NativeOpenStreetMapController.java @@ -68,9 +68,11 @@ import org.osmdroid.tileprovider.modules.IFilesystemCache; import org.osmdroid.tileprovider.modules.MapTileFilesystemProvider; import org.osmdroid.tileprovider.modules.MapTileSqlCacheProvider; +import org.osmdroid.tileprovider.modules.SqlTileWriter; import org.osmdroid.tileprovider.modules.TileWriter; import org.osmdroid.tileprovider.tilesource.ITileSource; import org.osmdroid.tileprovider.tilesource.TileSourceFactory; +import org.osmdroid.tileprovider.tilesource.XYTileSource; import org.osmdroid.util.BoundingBox; import org.osmdroid.util.GeoPoint; import org.osmdroid.views.MapView; @@ -109,6 +111,7 @@ class NativeOpenStreetMapController implements MapController, MapListener { private RelativeLayout containerView; private MapView view; private MapType tileType; + private String customUrl; private boolean zoomEnabled; private boolean zoomControlEnabled; private CompassOverlay compass = null; @@ -416,6 +419,39 @@ public MapFactory.MapType getMapType() { return MapFactory.MapType.values()[tileType.toUnderlyingValue()]; } + @Override + public String getCustomUrl() { + return customUrl; + } + + @Override + public void setCustomUrl(String url) { + if (!url.equals(this.customUrl)) { + // Clear any cached tiles if the custom URL changes + // TODO: even though isCleared returns true, the cache is still not empty. Debug further. + // Only solution is to use Android's Storage > Clear cache, which does work. + SqlTileWriter sqlTileWriter = new SqlTileWriter(); + boolean isCleared = sqlTileWriter.purgeCache(); //view.getTileProvider().getTileSource().name()); //"Custom"); + // setRotationEnabled(isCleared); + view.getTileProvider().clearTileCache(); + } + this.customUrl = url; + view.setTileSource(getCustomTileSource()); + } + + private ITileSource getCustomTileSource() { + final ITileSource tileSource = new XYTileSource("Custom", 1, 20, 256, "", + new String[] { customUrl }) { + @Override + public String getTileURLString(MapTile aTile) { + return getBaseUrl().replace("{z}", String.valueOf(aTile.getZoomLevel())) + .replace("{x}", String.valueOf(aTile.getX())) + .replace("{y}", String.valueOf(aTile.getY())); + } + }; + return tileSource; + } + @Override public void setMapTypeAbstract(MapType type) { tileType = type; @@ -429,6 +465,9 @@ public void setMapTypeAbstract(MapType type) { case Terrain: view.setTileSource(TileSourceFactory.USGS_TOPO); break; + case Custom: + view.setTileSource(getCustomTileSource()); + break; } } diff --git a/appinventor/docs/markdown/reference/components/maps.md b/appinventor/docs/markdown/reference/components/maps.md index e8c24fd0fa0..11d52df825f 100644 --- a/appinventor/docs/markdown/reference/components/maps.md +++ b/appinventor/docs/markdown/reference/components/maps.md @@ -363,7 +363,7 @@ A `FeatureCollection` groups one or more map features together. Any events that A two-dimensional container that renders map tiles in the background and allows for multiple [`Marker`](#Marker) elements to identify points on the map. Map tiles are supplied by OpenStreetMap - contributors and the the United States Geological Survey. + contributors and the the United States Geological Survey, or a custom basemap URL can be provided. The `Map` component provides three utilities for manipulating its boundaries with App Inventor. First, a locking mechanism is provided to allow the map to be moved relative to other components @@ -393,6 +393,11 @@ A two-dimensional container that renders map tiles in the background and allows [`PanTo`](#Map.PanTo) with numerical latitude and longitude rather than convert to the string representation for use with this property. +{:id="Map.CustomUrl" .text} *CustomUrl* +: Update the custom URL of the base tile layer in use by the map. + e.g. https://tile.openstreetmap.org/{z}/{x}/{y}.png + e.g. https://example.com/geoserver/gwc/service/tms/1.0.0/workspace:layername + {:id="Map.EnablePan" .boolean} *EnablePan* : Enables or disables the ability of the user to move the Map. @@ -433,6 +438,7 @@ A two-dimensional container that renders map tiles in the background and allows 1. Roads 2. Aerial 3. Terrain + 4. Custom **Note:** Road layers are provided by OpenStreetMap and aerial and terrain layers are provided by the U.S. Geological Survey.