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}