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