package cgeo.geocaching.connector.gc; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import java.io.IOException; import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import cgeo.geocaching.SearchResult; import cgeo.geocaching.enumerations.CacheSize; import cgeo.geocaching.enumerations.CacheType; import cgeo.geocaching.enumerations.StatusCode; import cgeo.geocaching.files.ParserException; import cgeo.geocaching.location.Geopoint; import cgeo.geocaching.location.GeopointFormatter.Format; import cgeo.geocaching.location.Units; import cgeo.geocaching.location.Viewport; import cgeo.geocaching.maps.LivemapStrategy; import cgeo.geocaching.maps.LivemapStrategy.Flag; import cgeo.geocaching.models.Geocache; import cgeo.geocaching.network.Parameters; import cgeo.geocaching.sensors.Sensors; import cgeo.geocaching.settings.Settings; import cgeo.geocaching.storage.DataStore; import cgeo.geocaching.utils.Formatter; import cgeo.geocaching.utils.JsonUtils; import cgeo.geocaching.utils.LeastRecentlyUsedMap; import cgeo.geocaching.utils.Log; import io.reactivex.Single; import io.reactivex.functions.BiFunction; public class GCMap { private static Viewport lastSearchViewport = null; private static final Bitmap ONE_ONE_BITMAP = Bitmap.createBitmap(1, 1, Config.ARGB_8888); private GCMap() { // utility class } public static SearchResult searchByGeocodes(final Set<String> geocodes) { final SearchResult result = new SearchResult(); final Set<String> filteredGeocodes = GCConnector.getInstance().handledGeocodes(geocodes); if (filteredGeocodes.isEmpty()) { return result; } final String geocodeList = StringUtils.join(filteredGeocodes.toArray(), "|"); try { final Parameters params = new Parameters("i", geocodeList, "_", String.valueOf(System.currentTimeMillis())); params.add("app", "cgeo"); final String referer = GCConstants.URL_LIVE_MAP_DETAILS; final String data = Tile.requestMapInfo(referer, params, referer).blockingGet(); // Example JSON information // {"status":"success", // "data":[{"name":"Mission: Impossible","gc":"GC1234","g":"34c2e609-5246-4f91-9029-d6c02b0f2a82","available":true,"archived":false,"subrOnly":false,"li":false,"fp":"5","difficulty":{"text":3.5,"value":"3_5"},"terrain":{"text":1.0,"value":"1"},"hidden":"7/23/2001","container":{"text":"Regular","value":"regular.gif"},"type":{"text":"Unknown Cache","value":8},"owner":{"text":"Ca$h_Cacher","value":"2db18e69-6877-402a-848d-6362621424f6"}}, // {"name":"HP: Hannover - Sahlkamp","gc":"GC2Q97X","g":"a09149ca-00e0-4aa2-b332-db2b4dfb18d2","available":true,"archived":false,"subrOnly":false,"li":false,"fp":"0","difficulty":{"text":1.0,"value":"1"},"terrain":{"text":1.5,"value":"1_5"},"hidden":"5/29/2011","container":{"text":"Small","value":"small.gif"},"type":{"text":"Traditional Cache","value":2},"owner":{"text":"GeoM@n","value":"1deaa69e-6bcc-421d-95a1-7d32b468cb82"}}] // } final ObjectNode json = (ObjectNode) JsonUtils.reader.readTree(data); final String status = json.path("status").asText(); if (StringUtils.isBlank(status)) { throw new ParserException("No status inside JSON"); } if ("success".compareTo(status) != 0) { throw new ParserException("Wrong status inside JSON"); } final ArrayNode dataArray = (ArrayNode) json.get("data"); if (dataArray == null) { throw new ParserException("No data inside JSON"); } final List<Geocache> caches = new ArrayList<>(); for (final JsonNode dataObject: dataArray) { final Geocache cache = new Geocache(); cache.setName(dataObject.path("name").asText()); cache.setGeocode(dataObject.path("gc").asText()); cache.setGuid(dataObject.path("g").asText()); // 34c2e609-5246-4f91-9029-d6c02b0f2a82" cache.setDisabled(!dataObject.path("available").asBoolean()); cache.setArchived(dataObject.path("archived").asBoolean()); cache.setPremiumMembersOnly(dataObject.path("subrOnly").asBoolean()); // "li" seems to be "false" always cache.setFavoritePoints(Integer.parseInt(dataObject.path("fp").asText())); cache.setDifficulty(Float.parseFloat(dataObject.path("difficulty").path("text").asText())); // 3.5 cache.setTerrain(Float.parseFloat(dataObject.path("terrain").path("text").asText())); // 1.5 cache.setHidden(GCLogin.parseGcCustomDate(dataObject.path("hidden").asText(), "MM/dd/yyyy")); // 7/23/2001 cache.setSize(CacheSize.getById(dataObject.path("container").path("text").asText())); // Regular cache.setType(CacheType.getByPattern(dataObject.path("type").path("text").asText())); // Traditional Cache cache.setOwnerDisplayName(dataObject.path("owner").path("text").asText()); caches.add(cache); } result.addAndPutInCache(caches); } catch (ParserException | ParseException | IOException | NumberFormatException ignored) { result.setError(StatusCode.UNKNOWN_ERROR); } return result; } /** * @param data * Retrieved data. * @return SearchResult. Never null. */ public static SearchResult parseMapJSON(final String data, final Tile tile, final Bitmap bitmap, final LivemapStrategy strategy) { final SearchResult searchResult = new SearchResult(); try { if (StringUtils.isEmpty(data)) { throw new ParserException("No page given"); } // Example JSON information // {"grid":[....], // "keys":["","55_55","55_54","17_25","55_53","17_27","17_26","57_53","57_55","3_62","3_61","57_54","3_60","15_27","15_26","15_25","4_60","4_61","4_62","16_25","16_26","16_27","2_62","2_60","2_61","56_53","56_54","56_55"], // "data":{"55_55":[{"i":"gEaR","n":"Spiel & Sport"}],"55_54":[{"i":"gEaR","n":"Spiel & Sport"}],"17_25":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"55_53":[{"i":"gEaR","n":"Spiel & Sport"}],"17_27":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"17_26":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"57_53":[{"i":"gEaR","n":"Spiel & Sport"}],"57_55":[{"i":"gEaR","n":"Spiel & Sport"}],"3_62":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"3_61":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"57_54":[{"i":"gEaR","n":"Spiel & Sport"}],"3_60":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"15_27":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"15_26":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"15_25":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"4_60":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"4_61":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"4_62":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"16_25":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"16_26":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"16_27":[{"i":"Rkzt","n":"EDSSW: Rathaus "}],"2_62":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"2_60":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"2_61":[{"i":"gOWz","n":"Baumarktserie - Wer Wo Was -"}],"56_53":[{"i":"gEaR","n":"Spiel & Sport"}],"56_54":[{"i":"gEaR","n":"Spiel & Sport"}],"56_55":[{"i":"gEaR","n":"Spiel & Sport"}]} // } final ObjectNode json = (ObjectNode) JsonUtils.reader.readTree(data); final ArrayNode grid = (ArrayNode) json.get("grid"); if (grid == null || grid.size() != (UTFGrid.GRID_MAXY + 1)) { throw new ParserException("No grid inside JSON"); } final ArrayNode keys = (ArrayNode) json.get("keys"); if (keys == null) { throw new ParserException("No keys inside JSON"); } final ObjectNode dataObject = (ObjectNode) json.get("data"); if (dataObject == null) { throw new ParserException("No data inside JSON"); } // iterate over the data and construct all caches in this tile final Map<String, List<UTFGridPosition>> positions = new HashMap<>(); // JSON id as key final Map<String, List<UTFGridPosition>> singlePositions = new HashMap<>(); // JSON id as key final LeastRecentlyUsedMap<String, String> nameCache = new LeastRecentlyUsedMap.LruCache<>(2000); // JSON id, cache name for (final JsonNode rawKey: keys) { final String key = rawKey.asText(); if (StringUtils.isNotBlank(key)) { // index 0 is empty final UTFGridPosition pos = UTFGridPosition.fromString(key); final ArrayNode dataForKey = (ArrayNode) dataObject.get(key); for (final JsonNode cacheInfo: dataForKey) { final String id = cacheInfo.get("i").asText(); nameCache.put(id, cacheInfo.get("n").asText()); List<UTFGridPosition> listOfPositions = positions.get(id); List<UTFGridPosition> singleListOfPositions = singlePositions.get(id); if (listOfPositions == null) { listOfPositions = new ArrayList<>(); positions.put(id, listOfPositions); singleListOfPositions = new ArrayList<>(); singlePositions.put(id, singleListOfPositions); } listOfPositions.add(pos); if (dataForKey.size() == 1) { singleListOfPositions.add(pos); } } } } final List<Geocache> caches = new ArrayList<>(); for (final Entry<String, List<UTFGridPosition>> entry : positions.entrySet()) { final String id = entry.getKey(); final List<UTFGridPosition> pos = entry.getValue(); final UTFGridPosition xy = UTFGrid.getPositionInGrid(pos); final Geocache cache = new Geocache(); cache.setDetailed(false); cache.setReliableLatLon(false); cache.setGeocode(id); cache.setName(nameCache.get(id)); cache.setCoords(tile.getCoord(xy), tile.getZoomLevel()); if (strategy.flags.contains(LivemapStrategy.Flag.PARSE_TILES) && bitmap != null) { for (final UTFGridPosition singlePos : singlePositions.get(id)) { if (IconDecoder.parseMapPNG(cache, bitmap, singlePos, tile.getZoomLevel())) { break; // cache parsed } } } else { cache.setType(CacheType.UNKNOWN, tile.getZoomLevel()); } boolean exclude = false; if (Settings.isExcludeMyCaches() && (cache.isFound() || cache.isOwner())) { // workaround for BM exclude = true; } if (Settings.isExcludeDisabledCaches() && cache.isDisabled()) { exclude = true; } if (!Settings.getCacheType().contains(cache) && cache.getType() != CacheType.UNKNOWN) { // workaround for BM exclude = true; } if (!exclude) { caches.add(cache); } } searchResult.addAndPutInCache(caches); Log.d("Retrieved " + searchResult.getCount() + " caches for tile " + tile.toString()); } catch (RuntimeException | ParserException | IOException e) { Log.e("GCMap.parseMapJSON", e); } return searchResult; } /** * Searches the view port on the live map with Strategy.AUTO * * @param viewport * Area to search * @param tokens * Live map tokens */ @NonNull public static SearchResult searchByViewport(@NonNull final Viewport viewport, @Nullable final MapTokens tokens) { final int speed = (int) Sensors.getInstance().currentGeo().getSpeed() * 60 * 60 / 1000; // in km/h LivemapStrategy strategy = Settings.getLiveMapStrategy(); if (strategy == LivemapStrategy.AUTO) { strategy = speed >= 30 ? LivemapStrategy.FAST : LivemapStrategy.DETAILED; } final SearchResult result = searchByViewport(viewport, tokens, strategy); if (Settings.isDebug()) { final StringBuilder text = new StringBuilder(Formatter.SEPARATOR).append(strategy.getL10n()).append(Formatter.SEPARATOR).append(Units.getSpeed(speed)); result.setUrl(result.getUrl() + text); } Log.d(String.format(Locale.getDefault(), "GCMap: returning %d caches from search", result.getCount())); return result; } /** * Searches the view port on the live map for caches. * The strategy dictates if only live map information is used or if an additional * searchByCoordinates query is issued. * * @param viewport * Area to search * @param tokens * Live map tokens * @param strategy * Strategy for data retrieval and parsing, @see Strategy */ @NonNull private static SearchResult searchByViewport(@NonNull final Viewport viewport, @Nullable final MapTokens tokens, @NonNull final LivemapStrategy strategy) { Log.d("GCMap.searchByViewport" + viewport.toString()); final SearchResult searchResult = new SearchResult(); if (Settings.isDebug()) { searchResult.setUrl(viewport.getCenter().format(Format.LAT_LON_DECMINUTE)); } if (strategy.flags.contains(LivemapStrategy.Flag.LOAD_TILES)) { final Set<Tile> tiles = Tile.getTilesForViewport(viewport); if (Settings.isDebug()) { searchResult.setUrl(String.valueOf(tiles.iterator().next().getZoomLevel()) + Formatter.SEPARATOR + searchResult.getUrl()); } for (final Tile tile : tiles) { if (!Tile.cache.contains(tile)) { final Parameters params = new Parameters( "x", String.valueOf(tile.getX()), "y", String.valueOf(tile.getY()), "z", String.valueOf(tile.getZoomLevel()), "ep", "1", "app", "cgeo"); if (tokens != null) { params.put("k", tokens.getUserSession(), "st", tokens.getSessionToken()); } if (Settings.isExcludeMyCaches()) { // works only for PM params.put("hf", "1", "hh", "1"); // hide found, hide hidden } // ect: exclude cache type (probably), comma separated list if (Settings.getCacheType() != CacheType.ALL) { params.put("ect", getCacheTypeFilter(Settings.getCacheType())); } if (tile.getZoomLevel() != 14) { params.put("_", String.valueOf(System.currentTimeMillis())); } // The PNG must be requested first, otherwise the following request would always return with 204 - No Content final Single<Bitmap> bitmapObs = Tile.requestMapTile(params).onErrorResumeNext(Single.just(ONE_ONE_BITMAP)); final Single<String> dataObs = Tile.requestMapInfo(GCConstants.URL_MAP_INFO, params, GCConstants.URL_LIVE_MAP).onErrorResumeNext(Single.just("")); try { Single.zip(bitmapObs, dataObs, new BiFunction<Bitmap, String, String>() { @Override public String apply(final Bitmap bitmap, final String data) { final boolean validBitmap = bitmap.getWidth() == Tile.TILE_SIZE && bitmap.getHeight() == Tile.TILE_SIZE; if (StringUtils.isEmpty(data)) { Log.w("GCMap.searchByViewport: No data from server for tile (" + tile.getX() + "/" + tile.getY() + ")"); } else { final SearchResult search = parseMapJSON(data, tile, validBitmap ? bitmap : null, strategy); if (CollectionUtils.isEmpty(search.getGeocodes())) { Log.w("GCMap.searchByViewport: No cache parsed for viewport " + viewport); } else { synchronized (searchResult) { searchResult.addSearchResult(search); } } synchronized (Tile.cache) { Tile.cache.add(tile); } } // release native bitmap memory if we didn't get the placeholder if (bitmap != ONE_ONE_BITMAP) { bitmap.recycle(); } return data; } }).toCompletable().blockingAwait(); } catch (final Exception e) { Log.e("GCMap.searchByViewPort: connection error", e); } } } // Check for vanished found caches if (tiles.iterator().next().getZoomLevel() >= Tile.ZOOMLEVEL_MIN_PERSONALIZED) { searchResult.addFilteredGeocodes(DataStore.getCachedMissingFromSearch(searchResult, tiles, GCConnector.getInstance(), Tile.ZOOMLEVEL_MIN_PERSONALIZED - 1)); } } if (strategy.flags.contains(Flag.SEARCH_NEARBY) && Settings.isGCPremiumMember()) { final Geopoint center = viewport.getCenter(); if (lastSearchViewport == null || !lastSearchViewport.contains(center)) { final SearchResult search = GCParser.searchByCoords(center, Settings.getCacheType()); if (search != null && !search.isEmpty()) { final Set<String> geocodes = search.getGeocodes(); lastSearchViewport = DataStore.getBounds(geocodes); searchResult.addGeocodes(geocodes); } } } return searchResult; } /** * Creates a list of caches types to filter on the live map (exclusion string) * * @param typeToDisplay * - cache type to omit from exclusion list so it gets displayed * * cache types for live map filter: * 2 = traditional, 9 = ape, 5 = letterbox * 3 = multi * 6 = event, 453 = mega, 13 = cito, 1304 = gps adventures * 4 = virtual, 11 = webcam, 137 = earth * 8 = mystery, 1858 = wherigo */ private static String getCacheTypeFilter(final CacheType typeToDisplay) { // Put all types in set, remove what should be visible in a second step final Set<String> filterTypes = new HashSet<>(Arrays.asList("2", "9", "5", "3", "6", "453", "13", "1304", "4", "11", "137", "8", "1858")); switch (typeToDisplay) { case TRADITIONAL: filterTypes.remove("2"); break; case PROJECT_APE: filterTypes.remove("9"); break; case LETTERBOX: filterTypes.remove("5"); break; case MULTI: filterTypes.remove("3"); break; case EVENT: filterTypes.remove("6"); break; case MEGA_EVENT: filterTypes.remove("453"); break; case CITO: filterTypes.remove("13"); break; case GPS_EXHIBIT: filterTypes.remove("1304"); break; case VIRTUAL: filterTypes.remove("4"); break; case WEBCAM: filterTypes.remove("11"); break; case EARTH: filterTypes.remove("137"); break; case MYSTERY: filterTypes.remove("8"); break; case WHERIGO: filterTypes.remove("1858"); break; default: // nothing to remove otherwise } return StringUtils.join(filterTypes.toArray(), ","); } }