001// License: GPL. For details, see Readme.txt file.
002package org.openstreetmap.gui.jmapviewer.tilesources;
003
004import java.awt.Image;
005import java.io.IOException;
006import java.net.MalformedURLException;
007import java.net.URL;
008import java.util.ArrayList;
009import java.util.List;
010import java.util.Locale;
011import java.util.concurrent.Callable;
012import java.util.concurrent.ExecutionException;
013import java.util.concurrent.Future;
014import java.util.concurrent.FutureTask;
015import java.util.concurrent.TimeUnit;
016import java.util.concurrent.TimeoutException;
017import java.util.logging.Level;
018import java.util.logging.Logger;
019import java.util.regex.Pattern;
020
021import javax.xml.parsers.DocumentBuilder;
022import javax.xml.parsers.DocumentBuilderFactory;
023import javax.xml.parsers.ParserConfigurationException;
024import javax.xml.xpath.XPath;
025import javax.xml.xpath.XPathConstants;
026import javax.xml.xpath.XPathExpression;
027import javax.xml.xpath.XPathExpressionException;
028import javax.xml.xpath.XPathFactory;
029
030import org.openstreetmap.gui.jmapviewer.Coordinate;
031import org.openstreetmap.gui.jmapviewer.FeatureAdapter;
032import org.openstreetmap.gui.jmapviewer.JMapViewer;
033import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
034import org.w3c.dom.Document;
035import org.w3c.dom.Node;
036import org.w3c.dom.NodeList;
037import org.xml.sax.InputSource;
038import org.xml.sax.SAXException;
039
040/**
041 * Tile source for the Bing Maps REST Imagery API.
042 * @see <a href="https://msdn.microsoft.com/en-us/library/bb259689.aspx">MSDN (1)</a>
043 *  and <a href="https://msdn.microsoft.com/en-us/library/ff701724.aspx">MSDN (2)</a>
044 */
045public class BingAerialTileSource extends TMSTileSource {
046
047    private static final Logger LOG = FeatureAdapter.getLogger(BingAerialTileSource.class);
048
049    /** Setting key for Bing metadata API URL. Must contain {@link #API_KEY_PLACEHOLDER} */
050    public static final String METADATA_API_SETTING = "jmapviewer.bing.metadata-api-url";
051    /** Setting key for Bing API key */
052    public static final String API_KEY_SETTING = "jmapviewer.bing.api-key";
053    /** Placeholder to specify Bing API key in metadata API URL*/
054    public static final String API_KEY_PLACEHOLDER = "{apiKey}";
055
056    /** Bing Metadata API URL */
057    private static final String METADATA_API_URL =
058            "https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial?include=ImageryProviders&output=xml&key=" + API_KEY_PLACEHOLDER;
059    /** Original Bing API key created by Potlatch2 developers in 2010 */
060    private static final String API_KEY = "Arzdiw4nlOJzRwOz__qailc8NiR31Tt51dN2D7cm57NrnceZnCpgOkmJhNpGoppU";
061    
062    private static volatile Future<List<Attribution>> attributions; // volatile is required for getAttribution(), see below.
063    private static String imageUrlTemplate;
064    private static Integer imageryZoomMax;
065    private static String[] subdomains;
066
067    private static final Pattern subdomainPattern = Pattern.compile("\\{subdomain\\}");
068    private static final Pattern quadkeyPattern = Pattern.compile("\\{quadkey\\}");
069    private static final Pattern culturePattern = Pattern.compile("\\{culture\\}");
070    private String brandLogoUri;
071
072    /**
073     * Constructs a new {@code BingAerialTileSource}.
074     */
075    public BingAerialTileSource() {
076        super(new TileSourceInfo("Bing", null, null));
077        minZoom = 1;
078    }
079
080    /**
081     * Constructs a new {@code BingAerialTileSource}.
082     * @param info imagery info
083     */
084    public BingAerialTileSource(TileSourceInfo info) {
085        super(info);
086    }
087
088    protected static class Attribution {
089        private String attributionText;
090        private int minZoom;
091        private int maxZoom;
092        private Coordinate min;
093        private Coordinate max;
094    }
095
096    @Override
097    public String getTileUrl(int zoom, int tilex, int tiley) throws IOException {
098        // make sure that attribution is loaded. otherwise subdomains is null.
099        if (getAttribution() == null)
100            throw new IOException("Attribution is not loaded yet");
101
102        int t = (zoom + tilex + tiley) % subdomains.length;
103        String subdomain = subdomains[t];
104
105        String url = imageUrlTemplate;
106        url = subdomainPattern.matcher(url).replaceAll(subdomain);
107        url = quadkeyPattern.matcher(url).replaceAll(computeQuadTree(zoom, tilex, tiley));
108
109        return url;
110    }
111
112    protected URL getAttributionUrl() throws MalformedURLException {
113        return new URL(FeatureAdapter.getSetting(METADATA_API_SETTING, METADATA_API_URL)
114                .replace(API_KEY_PLACEHOLDER, FeatureAdapter.getSetting(API_KEY_SETTING, API_KEY)));
115    }
116
117    protected List<Attribution> parseAttributionText(InputSource xml) throws IOException {
118        try {
119            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
120            DocumentBuilder builder = factory.newDocumentBuilder();
121            Document document = builder.parse(xml);
122
123            XPathFactory xPathFactory = XPathFactory.newInstance();
124            XPath xpath = xPathFactory.newXPath();
125            imageUrlTemplate = xpath.compile("//ImageryMetadata/ImageUrl/text()").evaluate(document).replace(
126                    "http://ecn.{subdomain}.tiles.virtualearth.net/",
127                    "https://ecn.{subdomain}.tiles.virtualearth.net/");
128            imageUrlTemplate = culturePattern.matcher(imageUrlTemplate).replaceAll(Locale.getDefault().toString());
129            imageryZoomMax = Integer.valueOf(xpath.compile("//ImageryMetadata/ZoomMax/text()").evaluate(document));
130
131            NodeList subdomainTxt = (NodeList) xpath.compile("//ImageryMetadata/ImageUrlSubdomains/string/text()")
132                    .evaluate(document, XPathConstants.NODESET);
133            subdomains = new String[subdomainTxt.getLength()];
134            for (int i = 0; i < subdomainTxt.getLength(); i++) {
135                subdomains[i] = subdomainTxt.item(i).getNodeValue();
136            }
137
138            brandLogoUri = xpath.compile("/Response/BrandLogoUri/text()").evaluate(document);
139
140            XPathExpression attributionXpath = xpath.compile("Attribution/text()");
141            XPathExpression coverageAreaXpath = xpath.compile("CoverageArea");
142            XPathExpression zoomMinXpath = xpath.compile("ZoomMin/text()");
143            XPathExpression zoomMaxXpath = xpath.compile("ZoomMax/text()");
144            XPathExpression southLatXpath = xpath.compile("BoundingBox/SouthLatitude/text()");
145            XPathExpression westLonXpath = xpath.compile("BoundingBox/WestLongitude/text()");
146            XPathExpression northLatXpath = xpath.compile("BoundingBox/NorthLatitude/text()");
147            XPathExpression eastLonXpath = xpath.compile("BoundingBox/EastLongitude/text()");
148
149            NodeList imageryProviderNodes = (NodeList) xpath.compile("//ImageryMetadata/ImageryProvider")
150                    .evaluate(document, XPathConstants.NODESET);
151            List<Attribution> attributionsList = new ArrayList<>(imageryProviderNodes.getLength());
152            for (int i = 0; i < imageryProviderNodes.getLength(); i++) {
153                Node providerNode = imageryProviderNodes.item(i);
154
155                String attribution = attributionXpath.evaluate(providerNode);
156
157                NodeList coverageAreaNodes = (NodeList) coverageAreaXpath.evaluate(providerNode, XPathConstants.NODESET);
158                for (int j = 0; j < coverageAreaNodes.getLength(); j++) {
159                    Node areaNode = coverageAreaNodes.item(j);
160                    Attribution attr = new Attribution();
161                    attr.attributionText = attribution;
162
163                    attr.maxZoom = Integer.parseInt(zoomMaxXpath.evaluate(areaNode));
164                    attr.minZoom = Integer.parseInt(zoomMinXpath.evaluate(areaNode));
165
166                    Double southLat = Double.valueOf(southLatXpath.evaluate(areaNode));
167                    Double northLat = Double.valueOf(northLatXpath.evaluate(areaNode));
168                    Double westLon = Double.valueOf(westLonXpath.evaluate(areaNode));
169                    Double eastLon = Double.valueOf(eastLonXpath.evaluate(areaNode));
170                    attr.min = new Coordinate(southLat, westLon);
171                    attr.max = new Coordinate(northLat, eastLon);
172
173                    attributionsList.add(attr);
174                }
175            }
176
177            return attributionsList;
178        } catch (SAXException | ParserConfigurationException | XPathExpressionException | NumberFormatException e) {
179            LOG.log(Level.SEVERE, "Could not parse Bing aerials attribution metadata.", e);
180        }
181        return null;
182    }
183
184    @Override
185    public int getMaxZoom() {
186        if (imageryZoomMax != null)
187            return imageryZoomMax;
188        else
189            return 22;
190    }
191
192    @Override
193    public boolean requiresAttribution() {
194        return true;
195    }
196
197    @Override
198    public String getAttributionLinkURL() {
199        // Terms of Use URL to comply with Bing Terms of Use
200        // (the requirement is that we have such a link at the bottom of the window)
201        return "https://www.microsoft.com/maps/assets/docs/terms.aspx";
202    }
203
204    @Override
205    public Image getAttributionImage() {
206        try {
207            final URL imageResource = JMapViewer.class.getResource("images/bing_maps.png");
208            if (imageResource != null) {
209                return FeatureAdapter.readImage(imageResource);
210            } else {
211                // Some Linux distributions (like Debian) will remove Bing logo from sources, so get it at runtime
212                for (int i = 0; i < 5 && getAttribution() == null; i++) {
213                    // Makes sure attribution is loaded
214                    if (JMapViewer.debug) {
215                        System.out.println("Bing attribution attempt " + (i+1));
216                    }
217                }
218                if (brandLogoUri != null && !brandLogoUri.isEmpty()) {
219                    System.out.println("Reading Bing logo from "+brandLogoUri);
220                    return FeatureAdapter.readImage(new URL(brandLogoUri));
221                }
222            }
223        } catch (IOException e) {
224            LOG.log(Level.SEVERE, "Error while retrieving Bing logo: "+e.getMessage());
225        }
226        return null;
227    }
228
229    @Override
230    public String getAttributionImageURL() {
231        return "https://opengeodata.org/microsoft-imagery-details";
232    }
233
234    @Override
235    public String getTermsOfUseText() {
236        return null;
237    }
238
239    @Override
240    public String getTermsOfUseURL() {
241        return "https://opengeodata.org/microsoft-imagery-details";
242    }
243
244    protected Callable<List<Attribution>> getAttributionLoaderCallable() {
245        return new Callable<List<Attribution>>() {
246
247            @Override
248            public List<Attribution> call() throws Exception {
249                int waitTimeSec = 1;
250                while (true) {
251                    try {
252                        InputSource xml = new InputSource(getAttributionUrl().openStream());
253                        List<Attribution> r = parseAttributionText(xml);
254                        System.out.println("Successfully loaded Bing attribution data.");
255                        return r;
256                    } catch (IOException ex) {
257                        LOG.log(Level.SEVERE, "Could not connect to Bing API. Will retry in " + waitTimeSec + " seconds.");
258                        Thread.sleep(TimeUnit.SECONDS.toMillis(waitTimeSec));
259                        waitTimeSec *= 2;
260                    }
261                }
262            }
263        };
264    }
265
266    protected List<Attribution> getAttribution() {
267        if (attributions == null) {
268            // see http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
269            synchronized (BingAerialTileSource.class) {
270                if (attributions == null) {
271                  final FutureTask<List<Attribution>> loader = new FutureTask<>(getAttributionLoaderCallable());
272                  new Thread(loader, "bing-attribution-loader").start();
273                  attributions = loader;
274                }
275            }
276        }
277        try {
278            return attributions.get(0, TimeUnit.MILLISECONDS);
279        } catch (TimeoutException ex) {
280            LOG.log(Level.WARNING, "Bing: attribution data is not yet loaded.");
281        } catch (ExecutionException ex) {
282            throw new RuntimeException(ex.getCause());
283        } catch (InterruptedException ign) {
284            LOG.log(Level.SEVERE, "InterruptedException: " + ign.getMessage());
285        }
286        return null;
287    }
288
289    @Override
290    public String getAttributionText(int zoom, ICoordinate topLeft, ICoordinate botRight) {
291        try {
292            final List<Attribution> data = getAttribution();
293            if (data == null)
294                return "Error loading Bing attribution data";
295            StringBuilder a = new StringBuilder();
296            for (Attribution attr : data) {
297                if (zoom <= attr.maxZoom && zoom >= attr.minZoom) {
298                    if (topLeft.getLon() < attr.max.getLon() && botRight.getLon() > attr.min.getLon()
299                            && topLeft.getLat() > attr.min.getLat() && botRight.getLat() < attr.max.getLat()) {
300                        a.append(attr.attributionText);
301                        a.append(' ');
302                    }
303                }
304            }
305            return a.toString();
306        } catch (RuntimeException e) {
307            e.printStackTrace();
308        }
309        return "Error loading Bing attribution data";
310    }
311
312    private static String computeQuadTree(int zoom, int tilex, int tiley) {
313        StringBuilder k = new StringBuilder();
314        for (int i = zoom; i > 0; i--) {
315            char digit = 48;
316            int mask = 1 << (i - 1);
317            if ((tilex & mask) != 0) {
318                digit += (char) 1;
319            }
320            if ((tiley & mask) != 0) {
321                digit += (char) 2;
322            }
323            k.append(digit);
324        }
325        return k.toString();
326    }
327}