Index: trunk/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java	(revision 8568)
@@ -2,8 +2,6 @@
 package org.openstreetmap.josm.data.cache;
 
-import java.io.ByteArrayOutputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.io.InputStream;
 import java.net.HttpURLConnection;
 import java.net.URL;
@@ -30,4 +28,5 @@
 import org.openstreetmap.josm.data.cache.ICachedLoaderListener.LoadResult;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
+import org.openstreetmap.josm.tools.Utils;
 
 /**
@@ -350,5 +349,5 @@
 
                 attributes.setResponseCode(responseCode(urlConn));
-                byte[] raw = read(urlConn);
+                byte[] raw = Utils.readBytesFromStream(urlConn.getInputStream());
 
                 if (isResponseLoadable(urlConn.getHeaderFields(), responseCode(urlConn), raw)) {
@@ -473,26 +472,4 @@
     }
 
-    private static byte[] read(URLConnection urlConn) throws IOException {
-        InputStream input = urlConn.getInputStream();
-        try {
-            ByteArrayOutputStream bout = new ByteArrayOutputStream(input.available());
-            byte[] buffer = new byte[2048];
-            boolean finished = false;
-            do {
-                int read = input.read(buffer);
-                if (read >= 0) {
-                    bout.write(buffer, 0, read);
-                } else {
-                    finished = true;
-                }
-            } while (!finished);
-            if (bout.size() == 0)
-                return null;
-            return bout.toByteArray();
-        } finally {
-            input.close();
-        }
-    }
-
     /**
      * TODO: move to JobFactory
Index: trunk/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(revision 8568)
@@ -56,5 +56,7 @@
         SCANEX("scanex"),
         /** A WMS endpoint entry only stores the WMS server info, without layer, which are chosen later by the user. **/
-        WMS_ENDPOINT("wms_endpoint");
+        WMS_ENDPOINT("wms_endpoint"),
+        /** WMTS stores GetCapabilities URL. Does not store any information about the layer **/
+        WMTS("wmts");
 
 
Index: trunk/src/org/openstreetmap/josm/data/imagery/WMTSTileSource.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/WMTSTileSource.java	(revision 8568)
+++ trunk/src/org/openstreetmap/josm/data/imagery/WMTSTileSource.java	(revision 8568)
@@ -0,0 +1,606 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Dimension;
+import java.awt.GridBagLayout;
+import java.awt.Point;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.ListSelectionModel;
+import javax.xml.namespace.QName;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathExpression;
+import javax.xml.xpath.XPathExpressionException;
+import javax.xml.xpath.XPathFactory;
+
+import org.openstreetmap.gui.jmapviewer.Coordinate;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource;
+import org.openstreetmap.gui.jmapviewer.tilesources.TMSTileSource;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.io.CachedFile;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Utils;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+/**
+ * Tile Source handling WMS providers
+ *
+ * @author Wiktor Niesiobędzki
+ * @since 8526
+ */
+public class WMTSTileSource extends TMSTileSource implements TemplatedTileSource {
+    private static final String PATTERN_HEADER  = "\\{header\\(([^,]+),([^}]+)\\)\\}";
+
+    private static final String URL_GET_ENCODING_PARAMS = "SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER={layer}&STYLE={style}&"
+            + "FORMAT={format}&tileMatrixSet={TileMatrixSet}&tileMatrix={TileMatrix}&tileRow={TileRow}&tileCol={TileCol}";
+
+    private static final String[] ALL_PATTERNS = {
+        PATTERN_HEADER,
+    };
+
+    private class TileMatrix {
+        String identifier;
+        double scaleDenominator;
+        EastNorth topLeftCorner;
+        int tileWidth;
+        int tileHeight;
+    }
+
+    private class TileMatrixSet {
+        SortedSet<TileMatrix> tileMatrix = new TreeSet<>(new Comparator<TileMatrix>() {
+            @Override
+            public int compare(TileMatrix o1, TileMatrix o2) {
+                // reverse the order, so it will be from greatest (lowest zoom level) to lowest value (highest zoom level)
+                return -1 * Double.compare(o1.scaleDenominator, o2.scaleDenominator);
+            }
+        }); // sorted by zoom level
+        String crs;
+        String identifier;
+    }
+
+    private class Layer {
+        String format;
+        String name;
+        Map<String, TileMatrixSet> tileMatrixSetByCRS = new ConcurrentHashMap<>();
+        public String baseUrl;
+    }
+
+    private enum TransferMode {
+        KVP("KVP"),
+        REST("RESTful");
+
+        private final String typeString;
+
+        private TransferMode(String urlString) {
+            this.typeString = urlString;
+        }
+
+        private final String getTypeString() {
+            return typeString;
+        }
+
+        private static TransferMode fromString(String s) {
+            for (TransferMode type : TransferMode.values()) {
+                if (type.getTypeString().equals(s)) {
+                    return type;
+                }
+            }
+            return null;
+        }
+    }
+
+    private class SelectLayerDialog extends ExtendedDialog {
+        private Layer[] layers;
+        private JList<String> list;
+
+        private SelectLayerDialog(Collection<Layer> layers) {
+            super(Main.parent, tr("Select WMTS layer"), new String[]{tr("Add layers"), tr("Cancel")});
+            this.layers = layers.toArray(new Layer[]{});
+            this.list = new JList<>(getLayerNames(layers));
+            this.list.setPreferredSize(new Dimension(400, 400));
+            this.list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION) ;
+            JPanel panel = new JPanel(new GridBagLayout());
+            panel.add(this.list, GBC.eol().fill());
+            setContent(panel);
+        }
+
+        private String[] getLayerNames(Collection<Layer> layers) {
+            Collection<String> ret = new ArrayList<>();
+            for(Layer layer: layers) {
+                ret.add(layer.name);
+            }
+            return ret.toArray(new String[]{});
+        }
+
+        public Layer getSelectedLayer() {
+            int index = list.getSelectedIndex();
+            if (index < 0) {
+                return null; //nothing selected
+            }
+            return layers[index];
+        }
+    }
+
+    private Map<String, String> headers = new HashMap<>();
+    private Collection<Layer> layers;
+    private Layer currentLayer;
+    private TileMatrixSet currentTileMatrixSet;
+    private double crsScale;
+    private TransferMode transferMode;
+
+    /**
+     * Creates a tile source based on imagery info
+     * @param info imagery info
+     * @throws IOException
+     */
+    public WMTSTileSource(ImageryInfo info) throws IOException {
+        super(info);
+        this.baseUrl = normalizeCapabilitiesUrl(handleTemplate(info.getUrl()));
+        this.layers = getCapabilities();
+        if (layers.size() > 1) {
+            SelectLayerDialog layerSelection = new SelectLayerDialog(layers);
+            if (layerSelection.showDialog().getValue() == 1) {
+                this.currentLayer = layerSelection.getSelectedLayer();
+                // TODO: save layer information into ImageryInfo / ImageryPreferences
+            } else {
+                throw new IllegalArgumentException(); //user canceled operation
+            }
+        } else if (layers.size() == 1) {
+            this.currentLayer = this.layers.iterator().next();
+        } else {
+            throw new IllegalArgumentException(tr("No layers defined by getCapabilities document: {0}", info.getUrl()));
+        }
+
+        initProjection();
+    }
+
+
+
+    private String handleTemplate(String url) {
+        Pattern pattern = Pattern.compile(PATTERN_HEADER);
+        StringBuffer output = new StringBuffer();
+        Matcher matcher = pattern.matcher(url);
+        while (matcher.find()) {
+            this.headers.put(matcher.group(1), matcher.group(2));
+            matcher.appendReplacement(output, "");
+        }
+        matcher.appendTail(output);
+        return output.toString();
+    }
+
+    private Collection<Layer> getCapabilities() throws IOException  {
+        DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
+        builderFactory.setValidating(false);
+        builderFactory.setNamespaceAware(false);
+        DocumentBuilder builder = null;
+        byte[] data = {};
+        InputStream in = new CachedFile(baseUrl).
+                setHttpHeaders(headers).
+                setMaxAge(7 * CachedFile.DAYS).
+                setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince).
+                getInputStream();
+        try {
+            builder = builderFactory.newDocumentBuilder();
+            data = Utils.readBytesFromStream(in);
+            if (data == null || data.length == 0) {
+                throw new IllegalArgumentException("Could not read data from: " + baseUrl);
+            }
+            Document document = builder.parse(new ByteArrayInputStream(data));
+            Node getTileOperation = getByXpath(document, "/Capabilities/OperationsMetadata/Operation[@name=\"GetTile\"]/DCP/HTTP/Get").item(0);
+            this.baseUrl = getStringByXpath(getTileOperation, "@href");
+            this.transferMode = TransferMode.fromString(getStringByXpath(getTileOperation, "Constraint[@name=\"GetEncoding\"]/AllowedValues/Value"));
+            NodeList layersNodeList = getByXpath(document, "/Capabilities/Contents/Layer");
+            Map<String, TileMatrixSet> matrixSetById = parseMatrices(getByXpath(document, "/Capabilities/Contents/TileMatrixSet"));
+            return parseLayer(layersNodeList, matrixSetById);
+
+        } catch (Exception e) {
+            Main.error(e);
+            //Main.error(new String(data, "UTF-8"));
+        }
+        return null;
+    }
+
+    private static String normalizeCapabilitiesUrl(String url) throws MalformedURLException {
+        URL inUrl = new URL(url);
+        URL ret = new URL(inUrl.getProtocol(), inUrl.getHost(), inUrl.getPort(), inUrl.getFile());
+        return ret.toExternalForm();
+    }
+
+    private final Collection<Layer> parseLayer(NodeList nodeList, Map<String, TileMatrixSet> matrixSetById) throws XPathExpressionException {
+        Collection<Layer> ret = new ArrayList<>();
+        for (int layerId = 0; layerId < nodeList.getLength(); layerId++) {
+            Node layerNode = nodeList.item(layerId);
+            Layer layer = new Layer();
+            layer.format = getStringByXpath(layerNode, "Format");
+            layer.name = getStringByXpath(layerNode, "Identifier");
+            layer.baseUrl = getStringByXpath(layerNode, "ResourceURL[@resourceType='tile']/@template");
+            NodeList tileMatrixSetLinks = getByXpath(layerNode, "TileMatrixSetLink");
+            for (int tileMatrixId = 0; tileMatrixId < tileMatrixSetLinks.getLength(); tileMatrixId++) {
+                Node tileMatrixLink = tileMatrixSetLinks.item(tileMatrixId);
+                TileMatrixSet tms = matrixSetById.get(getStringByXpath(tileMatrixLink, "TileMatrixSet"));
+                layer.tileMatrixSetByCRS.put(tms.crs, tms);
+            }
+            ret.add(layer);
+        }
+        return ret;
+
+    }
+
+    private final Map<String, TileMatrixSet> parseMatrices(NodeList nodeList) throws DOMException, XPathExpressionException {
+        Map<String, TileMatrixSet> ret = new ConcurrentHashMap<>();
+        for (int matrixSetId = 0; matrixSetId < nodeList.getLength(); matrixSetId++) {
+            Node matrixSetNode = nodeList.item(matrixSetId);
+            TileMatrixSet matrixSet = new TileMatrixSet();
+            matrixSet.identifier = getStringByXpath(matrixSetNode, "Identifier");
+            matrixSet.crs = crsToCode(getStringByXpath(matrixSetNode, "SupportedCRS"));
+            NodeList tileMatrixList = getByXpath(matrixSetNode, "TileMatrix");
+            for (int matrixId = 0; matrixId < tileMatrixList.getLength(); matrixId++) {
+                Node tileMatrixNode = tileMatrixList.item(matrixId);
+                TileMatrix tileMatrix = new TileMatrix();
+                tileMatrix.identifier = getStringByXpath(tileMatrixNode, "Identifier");
+                tileMatrix.scaleDenominator = Double.parseDouble(getStringByXpath(tileMatrixNode, "ScaleDenominator"));
+                String[] topLeftCorner = getStringByXpath(tileMatrixNode, "TopLeftCorner").split(" ");
+                tileMatrix.topLeftCorner = new EastNorth(Double.parseDouble(topLeftCorner[1]), Double.parseDouble(topLeftCorner[0]));
+                tileMatrix.tileHeight = Integer.parseInt(getStringByXpath(tileMatrixNode, "TileHeight"));
+                tileMatrix.tileWidth = Integer.parseInt(getStringByXpath(tileMatrixNode, "TileHeight"));
+                if (tileMatrix.tileHeight != tileMatrix.tileWidth) {
+                    throw new AssertionError(tr("Only square tiles are supported. {0}x{1} returned by server for TileMatrix identifier {2}",
+                            tileMatrix.tileHeight, tileMatrix.tileWidth, tileMatrix.identifier));
+                }
+
+                matrixSet.tileMatrix.add(tileMatrix);
+            }
+            ret.put(matrixSet.identifier, matrixSet);
+        }
+        return ret;
+    }
+
+    private static String crsToCode(String crsIdentifier) {
+        if(crsIdentifier.startsWith("urn:ogc:def:crs:")) {
+            return crsIdentifier.replaceFirst("urn:ogc:def:crs:([^:]*):[^:]*:(.*)$", "$1:$2");
+        }
+        return crsIdentifier;
+    }
+    private static String getStringByXpath(Node document, String xpathQuery) throws XPathExpressionException {
+        return (String) getByXpath(document, xpathQuery, XPathConstants.STRING);
+    }
+
+    private static NodeList getByXpath(Node document, String xpathQuery) throws XPathExpressionException {
+        return (NodeList) getByXpath(document, xpathQuery, XPathConstants.NODESET);
+    }
+
+
+    private static Object getByXpath(Node document, String xpathQuery, QName returnType) throws XPathExpressionException {
+        XPath xpath = XPathFactory.newInstance().newXPath();
+        XPathExpression expr = xpath.compile(xpathQuery);
+        return expr.evaluate(document, returnType);
+    }
+
+    /**
+     * Initializes projection for this TileSource with current projection
+     */
+    protected void initProjection() {
+        initProjection(Main.getProjection());
+    }
+
+    /**
+     * Initializes projection for this TileSource with projection
+     * @param proj projection to be used by this TileSource
+     */
+    public void initProjection(Projection proj) {
+        this.currentTileMatrixSet = currentLayer.tileMatrixSetByCRS.get(proj.toCode());
+        if(this.currentTileMatrixSet == null) {
+            Main.warn("Unsupported CRS selected");
+            // take first, maybe it will work (if user sets custom projections, codes will not match)
+            this.currentTileMatrixSet = currentLayer.tileMatrixSetByCRS.values().iterator().next();
+        }
+        this.crsScale = getTileSize() * 0.28e-03 / proj.getMetersPerUnit() ;
+    }
+
+    @Override
+    public int getDefaultTileSize() {
+        return getTileSize();
+    }
+
+    // FIXME: remove in September 2015, when ImageryPreferenceEntry.tileSize will be initialized to -1 instead to 256
+    // need to leave it as it is to keep compatiblity between tested and latest JOSM versions
+    @Override
+    public int getTileSize() {
+        TileMatrix matrix = getTileMatrix(1);
+        if (matrix == null) {
+            return 1;
+        }
+        return matrix.tileHeight;
+    }
+
+    @Override
+    public String getTileUrl(int zoom, int tilex, int tiley) throws IOException {
+        String url;
+        switch (transferMode) {
+        case KVP:
+            url = baseUrl + URL_GET_ENCODING_PARAMS;
+            break;
+        case REST:
+            url = currentLayer.baseUrl;
+            break;
+        default:
+            url = "";
+            break;
+        }
+
+        TileMatrix tileMatrix = getTileMatrix(zoom);
+
+        if (tileMatrix == null) {
+            return ""; // no matrix, probably unsupported CRS selected.
+        }
+
+        return url.replaceAll("\\{layer\\}", this.currentLayer.name)
+                .replaceAll("\\{format\\}", this.currentLayer.format)
+                .replaceAll("\\{TileMatrixSet\\}", this.currentTileMatrixSet.identifier)
+                .replaceAll("\\{TileMatrix\\}", tileMatrix.identifier)
+                .replaceAll("\\{TileRow\\}", Integer.toString(tiley))
+                .replaceAll("\\{TileCol\\}", Integer.toString(tilex));
+    }
+
+    /**
+     *
+     * @param zoom
+     * @return TileMatrix that's working on this zoom level
+     */
+    private TileMatrix getTileMatrix(int zoom) {
+        if (zoom > getMaxZoom()) {
+            return null;
+        }
+        if (zoom < 1) {
+            return null;
+        }
+        return this.currentTileMatrixSet.tileMatrix.toArray(new TileMatrix[]{})[zoom - 1];
+    }
+
+    @Override
+    public double getDistance(double lat1, double lon1, double lat2, double lon2) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public int lonToX(double lon, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public int latToY(double lat, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public double XToLon(int x, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public double YToLat(int y, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public double latToTileY(double lat, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+
+    @Override
+    public ICoordinate tileXYToLatLon(Tile tile) {
+        return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom());
+    }
+
+    @Override
+    public ICoordinate tileXYToLatLon(TileXY xy, int zoom) {
+        return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom);
+    }
+
+    @Override
+    public ICoordinate tileXYToLatLon(int x, int y, int zoom) {
+        TileMatrix matrix = getTileMatrix(zoom);
+        if (matrix == null) {
+            return Main.getProjection().getWorldBoundsLatLon().getCenter().toCoordinate();
+        }
+        double scale = matrix.scaleDenominator * this.crsScale;
+        EastNorth ret = new EastNorth(matrix.topLeftCorner.getX() + x * scale, matrix.topLeftCorner.getY() - y * scale);
+        return Main.getProjection().eastNorth2latlon(ret).toCoordinate();
+    }
+
+    @Override
+    public TileXY latLonToTileXY(double lat, double lon, int zoom) {
+        Projection proj = Main.getProjection();
+        EastNorth enPoint = proj.latlon2eastNorth(new LatLon(lat, lon));
+        TileMatrix matrix = getTileMatrix(zoom);
+        if (matrix == null) {
+            return new TileXY(0, 0);
+        }
+        double scale = matrix.scaleDenominator * this.crsScale;
+        return new TileXY(
+                (enPoint.east() - matrix.topLeftCorner.east()) / scale,
+                (matrix.topLeftCorner.north() - enPoint.north()) / scale
+                );
+    }
+
+    @Override
+    public TileXY latLonToTileXY(ICoordinate point, int zoom) {
+        return latLonToTileXY(point.getLat(),  point.getLon(), zoom);
+    }
+
+    @Override
+    public int getTileXMax(int zoom) {
+        return getTileXMax(zoom, Main.getProjection());
+    }
+
+    @Override
+    public int getTileXMin(int zoom) {
+        return 0;
+    }
+
+    @Override
+    public int getTileYMax(int zoom) {
+        return getTileYMax(zoom, Main.getProjection());
+    }
+
+    @Override
+    public int getTileYMin(int zoom) {
+        return 0;
+    }
+
+    @Override
+    public Point latLonToXY(double lat, double lon, int zoom) {
+        TileMatrix matrix = getTileMatrix(zoom);
+        if (matrix == null) {
+            return new Point(0, 0);
+        }
+        double scale = matrix.scaleDenominator * this.crsScale;
+        EastNorth point = Main.getProjection().latlon2eastNorth(new LatLon(lat, lon));
+        return new Point(
+                    (int) Math.round((point.east() - matrix.topLeftCorner.east())   / scale),
+                    (int) Math.round((matrix.topLeftCorner.north() - point.north()) / scale)
+                );
+    }
+
+    @Override
+    public Point latLonToXY(ICoordinate point, int zoom) {
+        return latLonToXY(point.getLat(), point.getLon(), zoom);
+    }
+
+    @Override
+    public Coordinate XYToLatLon(Point point, int zoom) {
+        return XYToLatLon(point.x, point.y, zoom);
+    }
+
+    @Override
+    public Coordinate XYToLatLon(int x, int y, int zoom) {
+        TileMatrix matrix = getTileMatrix(zoom);
+        if (matrix == null ){
+            return new Coordinate(0, 0);
+        }
+        double scale = matrix.scaleDenominator * this.crsScale;
+        Projection proj = Main.getProjection();
+        EastNorth ret = new EastNorth(
+                matrix.topLeftCorner.east() + x * scale,
+                matrix.topLeftCorner.north() - y * scale
+                );
+        LatLon ll = proj.eastNorth2latlon(ret);
+        return new Coordinate(ll.lat(), ll.lon());
+    }
+
+    @Override
+    public double lonToTileX(double lon, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public double tileXToLon(int x, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public double tileYToLat(int y, int zoom) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
+    @Override
+    public Map<String, String> getHeaders() {
+        return headers;
+    }
+
+    @Override
+    public int getMaxZoom() {
+        if (this.currentTileMatrixSet != null) {
+            return this.currentTileMatrixSet.tileMatrix.size();
+        }
+        return 0;
+    }
+
+    /**
+     * Checks if url is acceptable by this Tile Source
+     * @param url URL to check
+     */
+    public static void checkUrl(String url) {
+        CheckParameterUtil.ensureParameterNotNull(url, "url");
+        Matcher m = Pattern.compile("\\{[^}]*\\}").matcher(url);
+        while (m.find()) {
+            boolean isSupportedPattern = false;
+            for (String pattern : ALL_PATTERNS) {
+                if (m.group().matches(pattern)) {
+                    isSupportedPattern = true;
+                    break;
+                }
+            }
+            if (!isSupportedPattern) {
+                throw new IllegalArgumentException(
+                        tr("{0} is not a valid WMS argument. Please check this server URL:\n{1}", m.group(), url));
+            }
+        }
+    }
+
+    /**
+     * @return set of projection codes that this TileSource supports
+     */
+    public Set<String> getSupportedProjections() {
+        return this.currentLayer.tileMatrixSetByCRS.keySet();
+    }
+
+    private int getTileYMax(int zoom, Projection proj) {
+        TileMatrix matrix = getTileMatrix(zoom);
+        if (matrix == null) {
+            return 0;
+        }
+        double scale = matrix.scaleDenominator * this.crsScale;
+        Bounds bounds = Main.getProjection().getWorldBoundsLatLon();
+        EastNorth min = proj.latlon2eastNorth(bounds.getMin());
+        EastNorth max = proj.latlon2eastNorth(bounds.getMax());
+        return (int) Math.ceil(Math.abs(max.getY() - min.getY()) / scale);
+    }
+
+    private int getTileXMax(int zoom, Projection proj) {
+        TileMatrix matrix = getTileMatrix(zoom);
+        if (matrix == null) {
+            return 0;
+        }
+        double scale = matrix.scaleDenominator * this.crsScale;
+        Bounds bounds = Main.getProjection().getWorldBoundsLatLon();
+        EastNorth min = proj.latlon2eastNorth(bounds.getMin());
+        EastNorth max = proj.latlon2eastNorth(bounds.getMax());
+        return (int) Math.ceil(Math.abs(max.getX() - min.getX()) / scale);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/projection/CustomProjection.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/projection/CustomProjection.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/data/projection/CustomProjection.java	(revision 8568)
@@ -8,4 +8,5 @@
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -35,4 +36,7 @@
 public class CustomProjection extends AbstractProjection {
 
+    private final static Map<String, Double> UNITS_TO_METERS = getUnitsToMeters();
+    private final static double METER_PER_UNIT_DEGREE = 2 * Math.PI * 6370997 / 360;
+
     /**
      * pref String that defines the projection
@@ -45,4 +49,5 @@
     protected String cacheDir;
     protected Bounds bounds;
+    private double metersPerUnit = METER_PER_UNIT_DEGREE; // default to degrees
 
     /**
@@ -89,8 +94,9 @@
         wktext("wktext", false),  // ignored
         /** meters, US survey feet, etc. */
-        units("units", true),     // ignored
+        units("units", true),
         /** Don't use the /usr/share/proj/proj_def.dat defaults file */
         no_defs("no_defs", false),
         init("init", true),
+        to_meter("to_meter", true),
         // JOSM extensions, not present in PROJ.4
         wmssrs("wmssrs", true),
@@ -103,5 +109,5 @@
 
         /** Map of all parameters by key */
-        static final Map<String, Param> paramsByKey = new HashMap<>();
+        static final Map<String, Param> paramsByKey = new ConcurrentHashMap<>();
         static {
             for (Param p : Param.values()) {
@@ -198,4 +204,12 @@
             if (s != null) {
                 this.code = s;
+            }
+            s = parameters.get(Param.units.key);
+            if (s != null) {
+                this.metersPerUnit = UNITS_TO_METERS.get(s);
+            }
+            s = parameters.get(Param.to_meter.key);
+            if (s != null) {
+                this.metersPerUnit = parseDouble(s, Param.to_meter.key);
             }
         }
@@ -528,3 +542,35 @@
         return name != null ? name : tr("Custom Projection");
     }
+
+    @Override
+    public double getMetersPerUnit() {
+        return metersPerUnit;
+    }
+
+    private static Map<String, Double> getUnitsToMeters() {
+        Map<String, Double> ret = new ConcurrentHashMap<>();
+        ret.put("km", 1000d);
+        ret.put("m", 1d);
+        ret.put("dm", 1d/10);
+        ret.put("cm", 1d/100);
+        ret.put("mm", 1d/1000);
+        ret.put("kmi", 1852.0);
+        ret.put("in", 0.0254);
+        ret.put("ft", 0.3048);
+        ret.put("yd", 0.9144);
+        ret.put("mi", 1609.344);
+        ret.put("fathom", 1.8288);
+        ret.put("chain", 20.1168);
+        ret.put("link", 0.201168);
+        ret.put("us-in", 1d/39.37);
+        ret.put("us-ft", 0.304800609601219);
+        ret.put("us-yd", 0.914401828803658);
+        ret.put("us-ch", 20.11684023368047);
+        ret.put("us-mi", 1609.347218694437);
+        ret.put("ind-yd", 0.91439523);
+        ret.put("ind-ft", 0.30479841);
+        ret.put("ind-ch", 20.11669506);
+        ret.put("degree", METER_PER_UNIT_DEGREE);
+        return ret;
+    }
 }
Index: trunk/src/org/openstreetmap/josm/data/projection/Projection.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/projection/Projection.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/data/projection/Projection.java	(revision 8568)
@@ -68,3 +68,14 @@
      */
     Bounds getWorldBoundsLatLon();
+
+    /**
+     * Get the number of meters per unit of this projection. This more
+     * defines the scale of the map, than real conversion of unit to meters
+     * as this value is more less correct only along great circles.
+     *
+     * Used by WMTS to properly scale tiles
+     * @return meters per unit of projection
+     *
+     */
+    double getMetersPerUnit();
 }
Index: trunk/src/org/openstreetmap/josm/data/projection/proj/Proj.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/projection/proj/Proj.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/data/projection/proj/Proj.java	(revision 8568)
@@ -64,4 +64,3 @@
      */
     double[] invproject(double east, double north);
-
 }
Index: trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(revision 8568)
@@ -31,4 +31,5 @@
 import java.util.Set;
 import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import javax.swing.AbstractAction;
@@ -44,5 +45,4 @@
 
 import org.openstreetmap.gui.jmapviewer.AttributionSupport;
-import org.openstreetmap.gui.jmapviewer.Coordinate;
 import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
 import org.openstreetmap.gui.jmapviewer.OsmTileLoader;
@@ -51,4 +51,5 @@
 import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
 import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
@@ -102,5 +103,5 @@
     /** do set autoload when creating a new layer */
     public static final BooleanProperty PROP_DEFAULT_AUTOLOAD = new BooleanProperty(PREFERENCE_PREFIX + ".default_autoload", true);
-    /** do set showerrors when creating a new layer */
+    /** do show errors per default */
     public static final BooleanProperty PROP_DEFAULT_SHOWERRORS = new BooleanProperty(PREFERENCE_PREFIX + ".default_showerrors", true);
     /** minimum zoom level to show to user */
@@ -118,5 +119,4 @@
 
     private AttributionSupport attribution = new AttributionSupport();
-    Tile showMetadataTile;
 
     // needed public access for session exporter
@@ -130,5 +130,4 @@
     protected TileCache tileCache;
     protected TileSource tileSource;
-    //protected  tileMatrix;
     protected TileLoader tileLoader;
 
@@ -154,13 +153,17 @@
     protected abstract TileSource getTileSource(ImageryInfo info) throws IllegalArgumentException;
 
-    protected abstract Map<String, String> getHeaders(TileSource tileSource);
-
-    protected void initTileSource(TileSource tileMatrix) {
-        this.tileSource = tileMatrix;
-        attribution.initialize(tileMatrix);
+    protected Map<String, String> getHeaders(TileSource tileSource) {
+        if (tileSource instanceof TemplatedTileSource) {
+            return ((TemplatedTileSource) tileSource).getHeaders();
+        }
+        return null;
+    }
+
+    protected void initTileSource(TileSource tileSource) {
+        attribution.initialize(tileSource);
 
         currentZoomLevel = getBestZoom();
 
-        Map<String, String> headers = getHeaders(tileMatrix);
+        Map<String, String> headers = getHeaders(tileSource);
 
         tileLoader = getTileLoaderFactory().makeTileLoader(this, headers);
@@ -247,5 +250,5 @@
     }
 
-    private int getBestZoom() {
+    protected int getBestZoom() {
         double factor = getScaleFactor(1); // check the ratio between area of tilesize at zoom 1 to current view
         double result = Math.log(factor)/Math.log(2)/2+1;
@@ -273,5 +276,5 @@
 
     private final class ShowTileInfoAction extends AbstractAction {
-        private final TileHolder clickedTileHolder;
+        private transient final TileHolder clickedTileHolder;
 
         private ShowTileInfoAction(TileHolder clickedTileHolder) {
@@ -349,4 +352,5 @@
         }
 
+        @Override
         public Component createMenuComponent() {
             JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
@@ -414,6 +418,6 @@
         @Override
         public void actionPerformed(ActionEvent ae) {
-            double new_factor = Math.sqrt(getScaleFactor(currentZoomLevel));
-            Main.map.mapView.zoomToFactor(new_factor);
+            double newFactor = Math.sqrt(getScaleFactor(currentZoomLevel));
+            Main.map.mapView.zoomToFactor(newFactor);
             redraw();
         }
@@ -451,6 +455,7 @@
     @Override
     public void hookUpMapView() {
-        initTileSource(getTileSource(info));
+        this.tileSource = getTileSource(info);
         projectionChanged(null, Main.getProjection()); // check if projection is supported
+        initTileSource(this.tileSource);
 
         // keep them final here, so we avoid namespace clutter in the class
@@ -538,8 +543,10 @@
                     @Override
                     protected void finish() {
+                        // empty - flush is instaneus
                     }
 
                     @Override
                     protected void cancel() {
+                        // empty - flush is instaneus
                     }
                 }.run();
@@ -642,6 +649,5 @@
      */
     public static void setMaxZoomLvl(int maxZoomLvl) {
-        maxZoomLvl = checkMaxZoomLvl(maxZoomLvl, null);
-        PROP_MAX_ZOOM_LVL.put(maxZoomLvl);
+        PROP_MAX_ZOOM_LVL.put(checkMaxZoomLvl(maxZoomLvl, null));
     }
 
@@ -651,6 +657,5 @@
      */
     public static void setMinZoomLvl(int minZoomLvl) {
-        minZoomLvl = checkMinZoomLvl(minZoomLvl, null);
-        PROP_MIN_ZOOM_LVL.put(minZoomLvl);
+        PROP_MIN_ZOOM_LVL.put(checkMinZoomLvl(minZoomLvl, null));
     }
 
@@ -742,5 +747,4 @@
      */
     public boolean decreaseZoomLevel() {
-        //int minZoom = this.getMinZoomLvl();
         if (zoomDecreaseAllowed()) {
             if (Main.isDebugEnabled()) {
@@ -750,5 +754,4 @@
             zoomChanged();
         } else {
-            /*Main.debug("Current zoom level could not be decreased. Min. zoom level "+minZoom+" reached.");*/
             return false;
         }
@@ -786,6 +789,5 @@
      */
     private Tile getTile(int x, int y, int zoom) {
-        int max = (1 << zoom);
-        if (x < 0 || x >= max || y < 0 || y >= max)
+        if (x < 0 || x >= tileSource.getTileXMax(zoom) || y < 0 || y >= tileSource.getTileYMax(zoom))
             return null;
         return tileCache.getTile(tileSource, x, y, zoom);
@@ -1003,7 +1005,7 @@
         }
 
-        /*int xCursor = -1;
+        int xCursor = -1;
         int yCursor = -1;
-        if (PROP_DRAW_DEBUG.get()) {
+        if (Main.isDebugEnabled()) {
             if (yCursor < t.getYtile()) {
                 if (t.getYtile() % 32 == 31) {
@@ -1026,5 +1028,5 @@
                 xCursor = t.getXtile();
             }
-        }*/
+        }
     }
 
@@ -1042,7 +1044,6 @@
     }
 
-    private Coordinate getShiftedCoord(EastNorth en) {
-        LatLon ll = getShiftedLatLon(en);
-        return new Coordinate(ll.lat(), ll.lon());
+    private ICoordinate getShiftedCoord(EastNorth en) {
+        return getShiftedLatLon(en).toCoordinate();
     }
 
@@ -1117,7 +1118,7 @@
 
         private int size() {
-            int x_span = x1 - x0 + 1;
-            int y_span = y1 - y0 + 1;
-            return x_span * y_span;
+            int xSpan = x1 - x0 + 1;
+            int ySpan = y1 - y0 + 1;
+            return xSpan * ySpan;
         }
 
@@ -1355,5 +1356,5 @@
             }
             int newzoom = displayZoomLevel + zoomOffset;
-            if (newzoom < MIN_ZOOM) {
+            if (newzoom < getMinZoomLvl() || newzoom > getMaxZoomLvl()) {
                 continue;
             }
@@ -1513,7 +1514,7 @@
     public class PrecacheTask implements TileLoaderListener {
         private final ProgressMonitor progressMonitor;
-        private volatile int totalCount;
-        private volatile int processedCount = 0;
-        private TileLoader tileLoader;
+        private int totalCount;
+        private AtomicInteger processedCount = new AtomicInteger(0);
+        private final TileLoader tileLoader;
 
         /**
@@ -1534,5 +1535,5 @@
          */
         public boolean isFinished() {
-            return processedCount >= totalCount;
+            return processedCount.get() >= totalCount;
         }
 
@@ -1556,7 +1557,7 @@
         public void tileLoadingFinished(Tile tile, boolean success) {
             if (success) {
-                this.processedCount++;
+                int processed = this.processedCount.incrementAndGet();
                 this.progressMonitor.worked(1);
-                this.progressMonitor.setCustomText(tr("Downloaded {0}/{1} tiles", processedCount, totalCount));
+                this.progressMonitor.setCustomText(tr("Downloaded {0}/{1} tiles", processed, totalCount));
             }
         }
Index: trunk/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(revision 8568)
@@ -41,5 +41,4 @@
 import org.openstreetmap.josm.data.ProjectionBounds;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
-import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
 import org.openstreetmap.josm.data.imagery.OffsetBookmark;
 import org.openstreetmap.josm.data.preferences.ColorProperty;
@@ -153,10 +152,17 @@
 
     public static ImageryLayer create(ImageryInfo info) {
-        ImageryType type = info.getImageryType();
-        if (type == ImageryType.WMS || type == ImageryType.HTML)
+        switch(info.getImageryType()) {
+        case WMS:
+        case HTML:
             return new WMSLayer(info);
-        else if (type == ImageryType.TMS || type == ImageryType.BING || type == ImageryType.SCANEX)
+        case WMTS:
+            return new WMTSLayer(info);
+        case TMS:
+        case BING:
+        case SCANEX:
             return new TMSLayer(info);
-        else throw new AssertionError();
+        default:
+            throw new AssertionError(tr("Unsupported imagery type: {0}", info.getImageryType()));
+        }
     }
 
Index: trunk/src/org/openstreetmap/josm/gui/layer/TMSLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/TMSLayer.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/gui/layer/TMSLayer.java	(revision 8568)
@@ -78,12 +78,4 @@
     }
 
-    @Override
-    protected Map<String, String> getHeaders(TileSource tileSource) {
-        if (tileSource instanceof TemplatedTMSTileSource) {
-            return ((TemplatedTMSTileSource) tileSource).getHeaders();
-        }
-        return null;
-    }
-
     /**
      * Creates and returns a new TileSource instance depending on the {@link ImageryType}
Index: trunk/src/org/openstreetmap/josm/gui/layer/WMTSLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/WMTSLayer.java	(revision 8568)
+++ trunk/src/org/openstreetmap/josm/gui/layer/WMTSLayer.java	(revision 8568)
@@ -0,0 +1,126 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.imagery.CachedTileLoaderFactory;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
+import org.openstreetmap.josm.data.imagery.TileLoaderFactory;
+import org.openstreetmap.josm.data.imagery.WMSCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.WMTSTileSource;
+import org.openstreetmap.josm.data.preferences.BooleanProperty;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.MapView;
+
+/**
+ * WMTS layer based on AbstractTileSourceLayer. Overrides few methods to align WMTS to Tile based computations
+ * but most magic is done within WMTSTileSource class.
+ *
+ * Full specification of the protocol available at:
+ * http://www.opengeospatial.org/standards/wmts
+ *
+ * @author Wiktor Niesiobędzki
+ *
+ */
+public class WMTSLayer extends AbstractTileSourceLayer {
+    /**
+     * default setting of autozoom per layer
+     */
+    public static final BooleanProperty PROP_DEFAULT_AUTOZOOM = new BooleanProperty("imagery.wmts.default_autozoom", true);
+
+
+    /**
+     * Creates WMTS layer from ImageryInfo
+     * @param info Imagery Info describing the layer
+     */
+    public WMTSLayer(ImageryInfo info) {
+        super(info);
+    }
+
+    private static TileLoaderFactory loaderFactory = new CachedTileLoaderFactory("WMTS") {
+        @Override
+        protected TileLoader getLoader(TileLoaderListener listener, String cacheName, int connectTimeout,
+                int readTimeout, Map<String, String> headers, String cacheDir) throws IOException {
+            return new WMSCachedTileLoader(listener, cacheName, connectTimeout, readTimeout, headers, cacheDir);
+        }
+
+    };
+
+    @Override
+    protected TileLoaderFactory getTileLoaderFactory() {
+        return loaderFactory;
+    }
+
+    @Override
+    protected TileSource getTileSource(ImageryInfo info) {
+        try {
+            if (info.getImageryType() == ImageryType.WMTS && info.getUrl() != null) {
+                WMTSTileSource.checkUrl(info.getUrl());
+                WMTSTileSource tileSource = new WMTSTileSource(info);
+                info.setAttribution(tileSource);
+                return tileSource;
+            }
+        } catch (Exception e) {
+            Main.warn("Could not create imagery layer:");
+            Main.warn(e);
+        }
+        return null;
+    }
+
+    /**
+     * @param zoom level of the tile
+     * @return how many pixels of the screen occupies one pixel of the tile
+     */
+    private double getTileToScreenRatio(int zoom) {
+         ICoordinate north = tileSource.tileXYToLatLon(0, 0, zoom);
+         ICoordinate south = tileSource.tileXYToLatLon(0, 1, zoom);
+
+         MapView mv = Main.map.mapView;
+         LatLon topLeft = mv.getLatLon(0, 0);
+         LatLon botLeft = mv.getLatLon(0, tileSource.getTileSize());
+
+         return Math.abs((north.getLat() - south.getLat()) / ( topLeft.lat() - botLeft.lat()));
+    }
+
+    @Override
+    protected int getBestZoom() {
+        if (!Main.isDisplayingMapView()) return 1;
+
+        for(int i=getMinZoomLvl(); i <= getMaxZoomLvl(); i++) {
+            double ret = getTileToScreenRatio(i);
+            if (ret < 1) {
+                return i;
+            }
+        }
+        return getMaxZoomLvl();
+    }
+
+    @Override
+    public boolean isProjectionSupported(Projection proj) {
+        return ((WMTSTileSource)tileSource).getSupportedProjections().contains(proj.toCode());
+    }
+
+    @Override
+    public String nameSupportedProjections() {
+        StringBuilder ret = new StringBuilder();
+        for (String e: ((WMTSTileSource)tileSource).getSupportedProjections()) {
+            ret.append(e).append(", ");
+        }
+        return ret.substring(0, ret.length()-2);
+    }
+
+    @Override
+    public void projectionChanged(Projection oldValue, Projection newValue) {
+        super.projectionChanged(oldValue, newValue);
+        ((WMTSTileSource)tileSource).initProjection(newValue);
+    }
+
+}
Index: trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddImageryPanel.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddImageryPanel.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddImageryPanel.java	(revision 8568)
@@ -16,4 +16,5 @@
 
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
 import org.openstreetmap.josm.gui.widgets.JosmTextArea;
 import org.openstreetmap.josm.gui.widgets.JosmTextField;
@@ -86,4 +87,14 @@
     }
 
+    protected static String sanitize(String s, ImageryType type) {
+        String ret = s;
+        String imageryType = type.getTypeString() + ":";
+        if (ret.startsWith(imageryType)) {
+            // remove ImageryType from URL
+            ret = ret.substring(imageryType.length());
+        }
+        return sanitize(ret);
+    }
+
     protected final String getImageryName() {
         return sanitize(name.getText());
Index: trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddTMSLayerPanel.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddTMSLayerPanel.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddTMSLayerPanel.java	(revision 8568)
@@ -11,4 +11,5 @@
 
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
 import org.openstreetmap.josm.gui.widgets.JosmTextArea;
 import org.openstreetmap.josm.gui.widgets.JosmTextField;
@@ -71,5 +72,5 @@
             a.append('[').append(z).append(']');
         }
-        a.append(':').append(getImageryRawUrl());
+        a.append(':').append(sanitize(getImageryRawUrl(), ImageryType.TMS));
         return a.toString();
     }
Index: trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddWMSLayerPanel.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddWMSLayerPanel.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddWMSLayerPanel.java	(revision 8568)
@@ -178,5 +178,5 @@
 
     protected final String getWmsUrl() {
-        return sanitize(wmsUrl.getText());
+        return sanitize(wmsUrl.getText(), ImageryInfo.ImageryType.WMS);
     }
 
Index: trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddWMTSLayerPanel.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddWMTSLayerPanel.java	(revision 8568)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddWMTSLayerPanel.java	(revision 8568)
@@ -0,0 +1,41 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import javax.swing.JLabel;
+
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
+import org.openstreetmap.josm.tools.GBC;
+
+/**
+ * Panel for adding WMTS imagery sources
+ * @author Wiktor Niesiobędzki
+ *
+ */
+public class AddWMTSLayerPanel extends AddImageryPanel {
+
+    /**
+     * default constructor
+     */
+    public AddWMTSLayerPanel() {
+        add(new JLabel(tr("1. Enter getCapabilities URL")), GBC.eol());
+        add(rawUrl, GBC.eop().fill());
+        rawUrl.setLineWrap(true);
+        rawUrl.setAlignmentY(TOP_ALIGNMENT);
+        add(new JLabel(tr("2. Enter name for this layer")), GBC.eol());
+        add(name, GBC.eol().fill(GBC.HORIZONTAL));
+        registerValidableComponent(rawUrl);
+    }
+    @Override
+    protected ImageryInfo getImageryInfo() {
+        return new ImageryInfo(getImageryName(), "wmts:" + sanitize(getImageryRawUrl(), ImageryType.WMTS));
+    }
+
+    @Override
+    protected boolean isImageryValid() {
+        return !getImageryName().isEmpty() && !getImageryRawUrl().isEmpty();
+    }
+
+}
Index: trunk/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryPreference.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryPreference.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryPreference.java	(revision 8568)
@@ -389,4 +389,5 @@
             activeToolbar.add(new NewEntryAction(ImageryInfo.ImageryType.WMS));
             activeToolbar.add(new NewEntryAction(ImageryInfo.ImageryType.TMS));
+            activeToolbar.add(new NewEntryAction(ImageryInfo.ImageryType.WMTS));
             //activeToolbar.add(edit); TODO
             activeToolbar.add(remove);
@@ -482,8 +483,17 @@
                 putValue(SHORT_DESCRIPTION, tr("Add a new {0} entry by entering the URL", type.toString()));
                 String icon = /* ICON(dialogs/) */ "add";
-                if (ImageryInfo.ImageryType.WMS.equals(type))
+                switch (type) {
+                case WMS:
                     icon = /* ICON(dialogs/) */ "add_wms";
-                else if (ImageryInfo.ImageryType.TMS.equals(type))
+                    break;
+                case TMS:
                     icon = /* ICON(dialogs/) */ "add_tms";
+                    break;
+                case WMTS:
+                    icon = /* ICON(dialogs/) */ "add_wmts";
+                    break;
+                default:
+                    break;
+                }
                 putValue(SMALL_ICON, ImageProvider.get("dialogs", icon));
                 this.type = type;
@@ -493,9 +503,15 @@
             public void actionPerformed(ActionEvent evt) {
                 final AddImageryPanel p;
-                if (ImageryInfo.ImageryType.WMS.equals(type)) {
+                switch (type) {
+                case WMS:
                     p = new AddWMSLayerPanel();
-                } else if (ImageryInfo.ImageryType.TMS.equals(type)) {
+                    break;
+                case TMS:
                     p = new AddTMSLayerPanel();
-                } else {
+                    break;
+                case WMTS:
+                    p = new AddWMTSLayerPanel();
+                    break;
+                default:
                     throw new IllegalStateException("Type " + type + " not supported");
                 }
Index: trunk/src/org/openstreetmap/josm/io/CachedFile.java
===================================================================
--- trunk/src/org/openstreetmap/josm/io/CachedFile.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/io/CachedFile.java	(revision 8568)
@@ -20,4 +20,7 @@
 import java.util.Enumeration;
 import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
@@ -72,4 +75,6 @@
     public static final long DAYS = 24*60*60; // factor to get caching time in days
 
+    private Map<String, String> httpHeaders = new ConcurrentHashMap<>();
+
     /**
      * Constructs a CachedFile object from a given filename, URL or internal resource.
@@ -142,4 +147,14 @@
     public CachedFile setCachingStrategy(CachingStrategy cachingStrategy) {
         this.cachingStrategy = cachingStrategy;
+        return this;
+    }
+
+    /**
+     * Sets the http headers. Only applies to URL pointing to http or https resources
+     * @param headers that should be sent together with request
+     * @return this object
+     */
+    public CachedFile setHttpHeaders(Map<String, String> headers) {
+        this.httpHeaders.putAll(headers);
         return this;
     }
@@ -397,5 +412,5 @@
         destDirFile = new File(destDir, localPath + ".tmp");
         try {
-            HttpURLConnection con = connectFollowingRedirect(url, httpAccept, ifModifiedSince);
+            HttpURLConnection con = connectFollowingRedirect(url, httpAccept, ifModifiedSince, httpHeaders);
             if (ifModifiedSince != null && con.getResponseCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
                 if (Main.isDebugEnabled()) {
@@ -464,4 +479,27 @@
     public static HttpURLConnection connectFollowingRedirect(URL downloadUrl, String httpAccept, Long ifModifiedSince)
             throws MalformedURLException, IOException {
+        return connectFollowingRedirect(downloadUrl, httpAccept, ifModifiedSince, null);
+    }
+    /**
+     * Opens a connection for downloading a resource.
+     * <p>
+     * Manually follows redirects because
+     * {@link HttpURLConnection#setFollowRedirects(boolean)} fails if the redirect
+     * is going from a http to a https URL, see <a href="https://bugs.openjdk.java.net/browse/JDK-4620571">bug report</a>.
+     * <p>
+     * This can cause problems when downloading from certain GitHub URLs.
+     *
+     * @param downloadUrl The resource URL to download
+     * @param httpAccept The accepted MIME types sent in the HTTP Accept header. Can be {@code null}
+     * @param ifModifiedSince The download time of the cache file, optional
+     * @param headers http headers to be sent together with http request
+     * @return The HTTP connection effectively linked to the resource, after all potential redirections
+     * @throws MalformedURLException If a redirected URL is wrong
+     * @throws IOException If any I/O operation goes wrong
+     * @throws OfflineAccessException if resource is accessed in offline mode, in any protocol
+     * @since TODO
+     */
+    public static HttpURLConnection connectFollowingRedirect(URL downloadUrl, String httpAccept, Long ifModifiedSince, Map<String, String> headers)
+            throws MalformedURLException, IOException {
         CheckParameterUtil.ensureParameterNotNull(downloadUrl, "downloadUrl");
         String downloadString = downloadUrl.toExternalForm();
@@ -474,4 +512,9 @@
             if (ifModifiedSince != null) {
                 con.setIfModifiedSince(ifModifiedSince);
+            }
+            if (headers != null) {
+                for (Entry<String, String> header: headers.entrySet()) {
+                    con.setRequestProperty(header.getKey(), header.getValue());
+                }
             }
             con.setInstanceFollowRedirects(false);
Index: trunk/src/org/openstreetmap/josm/tools/Utils.java
===================================================================
--- trunk/src/org/openstreetmap/josm/tools/Utils.java	(revision 8567)
+++ trunk/src/org/openstreetmap/josm/tools/Utils.java	(revision 8568)
@@ -14,4 +14,5 @@
 import java.awt.datatransfer.UnsupportedFlavorException;
 import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
 import java.io.File;
@@ -1337,3 +1338,31 @@
         return hasExtension(file.getName(), extensions);
     }
+
+    /**
+     * Reads the input stream and closes the stream at the end of processing (regardless if an exception was thrown)
+     *
+     * @param stream
+     * @return byte array of data in input stream
+     * @throws IOException
+     */
+    public static byte[] readBytesFromStream(InputStream stream) throws IOException {
+        try {
+            ByteArrayOutputStream bout = new ByteArrayOutputStream(stream.available());
+            byte[] buffer = new byte[2048];
+            boolean finished = false;
+            do {
+                int read = stream.read(buffer);
+                if (read >= 0) {
+                    bout.write(buffer, 0, read);
+                } else {
+                    finished = true;
+                }
+            } while (!finished);
+            if (bout.size() == 0)
+                return null;
+            return bout.toByteArray();
+        } finally {
+            stream.close();
+        }
+    }
 }
