Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PMTiles support #2391

Merged
merged 9 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ dependencies {
implementation 'ch.poole.android:sprites:0.0.4'
implementation 'ch.poole.android:indeterminate-checkbox:1.1.0@aar'
implementation 'ch.poole.misc:bentley-ottmann:0.1.1'
implementation 'ch.poole.geo.pmtiles-reader:Reader:0.3.6'

// for temp stuff during dev
// implementation(name:'alibrary', ext:'jar')
Expand Down
22 changes: 17 additions & 5 deletions documentation/docs/help/en/Custom imagery.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Supported layer types

* google/OSM type tile servers (raster tiles and to a limited extent Mapbox Vector Tiles)
* imagery layers in [MBTiles](https://github.com/mapbox/mbtiles-spec) format on device
* imagery layers in [MBTiles](https://github.com/mapbox/mbtiles-spec) and [PMtiles V3](https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md) format on device
* WMS servers supporting images in EPSG:3857 (including alternative names like EPSG:900913 etc) and EPSG:4326 projections

Besides manually adding layers you can add WMS layers by querying a WMS server for a list of supported layers see [layer control](Main%20map%20display.md#Layer%20control), or by querying the Open Aerial Map (OAM) service.
Expand All @@ -21,6 +21,12 @@ To add a custom layer goto the _Preferences_ screen and select _Custom imagery_,
* __Zoom__ _Min_ and _Max_ zoom levels, these indicates the minimum and maximum zoom levels available and are important for the app to determine over- and under-zoom correctly.
* __Tile size__ side length in pixels for the tiles, default 256. _Available from version 16.0 and later_

Selecting the _Save_ button will add the source to the configuration, _Save and set_ will additionally add the source to the active layers as the current background for background imagery sources.

### Remote PMTiles source

To add a remote PMTiles source add the URL and then select _Save_. Vespucci will attempt to retrieve all necessary information for the entry from the remote source if the URL ends with _.pmtiles_.

### Supported place holders

Place holders are replaced when the application retrieves imagery files from the source and are replaced by calculated values. There are some variants even between applications that in principle use the same system, which are noted for completeness sake below.
Expand Down Expand Up @@ -55,7 +61,9 @@ __{bbox}__ bounding box in _proj_ coordinates for WMS servers. _JOSM_, _Vespucci

__{subdomain}__ reserved, used internally by _Vespucci_

##### Required place holders
##### Required placeholders

Note: remote PMTiles sources do not require any placeholders.

* A valid normal (non-Bing) URL for a tile server must contain at least at least __{zoom}__, __{x}__ and one of __{y}__, __{-y}__ or __{ty}__.
* A valid WMS entry must be a legal WMS URL for a layer containing at least __{width}__, __{height}__ and __{bbox}__ place holders. Note: do not add a __{proj}__ place holder when adding such a layer in the "Custom imagery" form in Vespucci (it is supported in the configuration files), simply leave the SRS or CRS attribute in the URL as is with the desired projection value.
Expand All @@ -69,14 +77,18 @@ Tile server example:
WMS server example:

https://geodienste.sachsen.de/wms_geosn_dop-rgb/guest?FORMAT=image/jpeg&VERSION=1.1.1&SERVICE=WMS&REQUEST=GetMap&LAYERS=sn_dop_020&STYLES=&SRS=EPSG:3857&WIDTH={width}&HEIGHT={height}&BBOX={bbox}

PMTiles example:

https://r2-public.protomaps.com/protomaps-sample-datasets/overture-pois.pmtiles

## MBTiles
## MBTiles and PMTiles

To add a MBTiles layer, down/upload the MBTiles format file to your device, then go to the _Custom imagery_ form as described above. Tap the _SD card_ icon, navigate to the folder where you saved the file and select it. The form should now be filled out with all necessary values.
To add a MBTiles or PMTIles layer, down/upload the file to your device, then go to the _Custom imagery_ form as described above. Tap the _SD card_ icon, navigate to the folder where you saved the file and select it. The form should now be filled out with all necessary values.

Notes:

* Older MBTiles formats do not contain meta-data for min and max zoom levels, the query that determines the values from the actual file contents may take a long time to run if the file is large.
* Vespucci currently supports png and jpg contents.
* Vespucci currently supports png, jpg and mvt contents.
* You can adjust all values in the form before saving if necessary with exception of the URL field that contains the path to the file.
* You can create MBTiles files for example with [MOBAC](https://sourceforge.net/projects/mobac/) and many other tools, some more information can be found on the [HOT toolbox site](https://github.com/hotosm/toolbox/wiki/4.5-Creating-.mbtiles) and the [separate tutorial about the generation of custom MBTiles files](custom_imagery_mbtiles.md).
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package de.blau.android.layer;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import com.orhanobut.mockwebserverplus.MockWebServerPlus;

import android.app.Instrumentation;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import androidx.test.uiautomator.UiDevice;
import androidx.test.uiautomator.UiObject;
import androidx.test.uiautomator.UiObject2;
import androidx.test.uiautomator.UiObjectNotFoundException;
import androidx.test.uiautomator.UiScrollable;
import androidx.test.uiautomator.UiSelector;
import androidx.test.uiautomator.Until;
import de.blau.android.App;
import de.blau.android.JavaResources;
import de.blau.android.LayerUtils;
import de.blau.android.Logic;
import de.blau.android.Main;
import de.blau.android.Map;
import de.blau.android.MockTileServer;
import de.blau.android.PMTilesDispatcher;
import de.blau.android.R;
import de.blau.android.TestUtils;
import de.blau.android.TileDispatcher;
import de.blau.android.gpx.GpxTest;
import de.blau.android.layer.data.MapOverlay;
import de.blau.android.osm.BoundingBox;
import de.blau.android.osm.Node;
import de.blau.android.osm.StorageDelegator;
import de.blau.android.osm.Way;
import de.blau.android.prefs.AdvancedPrefDatabase;
import de.blau.android.prefs.Preferences;
import de.blau.android.resources.TileLayerDatabase;
import de.blau.android.resources.TileLayerSource;
import de.blau.android.views.layers.MapTilesLayer;
import okhttp3.mockwebserver.MockWebServer;

@RunWith(AndroidJUnit4.class)
@LargeTest
public class LayerDialogCustomImageryTest {

public static final int EXTENT_BUTTON = 1;
public static final int MENU_BUTTON = 3;

AdvancedPrefDatabase prefDB = null;
Main main = null;
UiDevice device = null;
Map map = null;
Instrumentation instrumentation = null;

@Rule
public ActivityTestRule<Main> mActivityRule = new ActivityTestRule<>(Main.class);

/**
* Pre-test setup
*/
@Before
public void setup() {
instrumentation = InstrumentationRegistry.getInstrumentation();
instrumentation.getTargetContext().deleteDatabase(TileLayerDatabase.DATABASE_NAME);
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
main = mActivityRule.getActivity();
TestUtils.grantPermissons(device);
LayerUtils.removeImageryLayers(main);
Preferences prefs = new Preferences(main);
map = main.getMap();
map.setPrefs(main, prefs);
TestUtils.dismissStartUpDialogs(device, main);
TestUtils.stopEasyEdit(main);
}

/**
* Test adding and then modifiy custom imagery
*/
@Test
public void customImagery() {
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/layers", true));
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/add", true));
TestUtils.scrollTo(main.getString(R.string.layer_add_custom_imagery), false);
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.layer_add_custom_imagery), true));
assertTrue(TestUtils.findText(device, false, main.getString(R.string.add_layer_title)));
UiObject name = device.findObject(new UiSelector().resourceId(device.getCurrentPackageName() + ":id/name"));
try {
name.setText("Custom imagery");
} catch (UiObjectNotFoundException e) {
fail(e.getMessage());
}
UiObject url = device.findObject(new UiSelector().resourceId(device.getCurrentPackageName() + ":id/url"));
try {
url.setText("https://test/");
} catch (UiObjectNotFoundException e) {
fail(e.getMessage());
}
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.save_and_set), true));
try (TileLayerDatabase db = new TileLayerDatabase(ApplicationProvider.getApplicationContext())) {
TileLayerSource tls = TileLayerDatabase.getLayerWithUrl(main, db.getReadableDatabase(), "https://test/");
assertNotNull(tls);
assertEquals("Custom imagery", tls.getName());
}
TestUtils.clickText(device, true, main.getString(R.string.done), true, false);
UiObject2 menuButton = TestUtils.getLayerButton(device, "Custom imagery", MENU_BUTTON);
menuButton.clickAndWait(Until.newWindow(), 2000);
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.layer_edit_custom_imagery_configuration), true));
assertTrue(TestUtils.findText(device, false, main.getString(R.string.edit_layer_title)));
url = device.findObject(new UiSelector().resourceId(device.getCurrentPackageName() + ":id/url"));
try {
url.setText("https://test2/");
} catch (UiObjectNotFoundException e) {
fail(e.getMessage());
}
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.save), true));
try (TileLayerDatabase db = new TileLayerDatabase(ApplicationProvider.getApplicationContext())) {
TileLayerSource tls = TileLayerDatabase.getLayerWithUrl(main, db.getReadableDatabase(), "https://test2/");
assertNotNull(tls);
}
}

/**
* Test adding a MBT source
*/
@Test
public void customImageryMBT() {
final String fileName = "ersatz_background.mbt";
try {
JavaResources.copyFileFromResources(main, fileName, null, "/");
} catch (IOException e) {
fail("copying " + fileName + " failed");
}
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/layers", true));
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/add", true));
TestUtils.scrollTo(main.getString(R.string.layer_add_custom_imagery), false);
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.layer_add_custom_imagery), true));
assertTrue(TestUtils.findText(device, false, main.getString(R.string.add_layer_title)));
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/file_button", true));
TestUtils.selectFile(device, main, null, fileName, true);
assertTrue(TestUtils.findText(device, false, "Vespucci Test"));
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.save_and_set), true));
assertTrue(TestUtils.textGone(device, main.getString(R.string.layer_add_custom_imagery), 2000));
assertTrue(TestUtils.findText(device, false, "Vespucci Test")); // layer dialog
try (TileLayerDatabase db = new TileLayerDatabase(ApplicationProvider.getApplicationContext())) {
TileLayerSource tls = TileLayerDatabase.getLayer(main, db.getReadableDatabase(), MockTileServer.MOCK_TILE_SOURCE);
assertNotNull(tls);
assertEquals(TileLayerSource.TYPE_TMS, tls.getType());
assertEquals(TileLayerSource.TileType.BITMAP, tls.getTileType());
}
}

/**
* Test adding a (local) PMTiles source
*/
@Test
public void customImageryLocalPMTiles() {
final String fileName = "protomaps(vector)ODbL_firenze.pmtiles";
try {
JavaResources.copyFileFromResources(main, fileName, null, "/");
} catch (IOException e) {
fail("copying " + fileName + " failed");
}
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/layers", true));
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/add", true));
TestUtils.scrollTo(main.getString(R.string.layer_add_custom_imagery), false);
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.layer_add_custom_imagery), true));
assertTrue(TestUtils.findText(device, false, main.getString(R.string.add_layer_title)));
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/file_button", true));
TestUtils.selectFile(device, main, null, fileName, true);
assertTrue(TestUtils.findText(device, false, "protomaps 2023-01"));
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.save_and_set), true));
assertTrue(TestUtils.textGone(device, main.getString(R.string.layer_add_custom_imagery), 2000));
assertTrue(TestUtils.findText(device, false, "protomaps 2023-01")); // layer dialog
try (TileLayerDatabase db = new TileLayerDatabase(ApplicationProvider.getApplicationContext())) {
TileLayerSource tls = TileLayerDatabase.getLayer(main, db.getReadableDatabase(), "PROTOMAPS20230118T074939Z");
assertNotNull(tls);
assertEquals(TileLayerSource.TYPE_PMT_3, tls.getType());
assertEquals(TileLayerSource.TileType.MVT, tls.getTileType());
}
}

/**
* Test adding a (remote) PMTiles source
*/
@Test
public void customImageryRemotePMTiles() {
final String fileName = "protomaps(vector)ODbL_firenze.pmtiles";
try (MockWebServer tileServer = new MockWebServer()) {

PMTilesDispatcher tileDispatcher = new PMTilesDispatcher(main, fileName);
tileServer.setDispatcher(tileDispatcher);

String tileUrl = tileServer.url("/").toString() + "firenze.pmtiles";

assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/layers", true));
assertTrue(TestUtils.clickResource(device, true, device.getCurrentPackageName() + ":id/add", true));
TestUtils.scrollTo(main.getString(R.string.layer_add_custom_imagery), false);
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.layer_add_custom_imagery), true));
assertTrue(TestUtils.findText(device, false, main.getString(R.string.add_layer_title)));
UiObject url = device.findObject(new UiSelector().resourceId(device.getCurrentPackageName() + ":id/url"));
try {
url.setText(tileUrl);
} catch (UiObjectNotFoundException e) {
fail(e.getMessage());
}
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.save), true));

assertTrue(TestUtils.findText(device, false, "protomaps 2023-01"));
assertTrue(TestUtils.clickText(device, false, main.getString(R.string.save_and_set), true));
assertTrue(TestUtils.textGone(device, main.getString(R.string.layer_add_custom_imagery), 2000));
assertTrue(TestUtils.findText(device, false, "protomaps 2023-01")); // layer dialog
try (TileLayerDatabase db = new TileLayerDatabase(ApplicationProvider.getApplicationContext())) {
TileLayerSource tls = TileLayerDatabase.getLayer(main, db.getReadableDatabase(), "PROTOMAPS20230118T074939Z");
assertNotNull(tls);
assertEquals(TileLayerSource.TYPE_PMT_3, tls.getType());
assertEquals(TileLayerSource.TileType.MVT, tls.getTileType());
}
} catch (IOException e) {
fail("setting up tiles server with " + fileName + " failed");
}
}
}
Loading