package cgeo.geocaching.connector.gc; import cgeo.geocaching.CgeoApplication; import cgeo.geocaching.R; import cgeo.geocaching.SearchResult; import cgeo.geocaching.connector.trackable.TrackableBrand; import cgeo.geocaching.enumerations.CacheSize; import cgeo.geocaching.enumerations.CacheType; import cgeo.geocaching.enumerations.LoadFlags; import cgeo.geocaching.enumerations.LoadFlags.SaveFlag; import cgeo.geocaching.enumerations.StatusCode; import cgeo.geocaching.enumerations.WaypointType; import cgeo.geocaching.files.LocParser; import cgeo.geocaching.gcvote.GCVote; import cgeo.geocaching.gcvote.GCVoteRating; import cgeo.geocaching.location.DistanceParser; import cgeo.geocaching.location.Geopoint; import cgeo.geocaching.log.LogEntry; import cgeo.geocaching.log.LogType; import cgeo.geocaching.log.LogTypeTrackable; import cgeo.geocaching.log.TrackableLog; import cgeo.geocaching.models.Geocache; import cgeo.geocaching.models.Image; import cgeo.geocaching.models.PocketQuery; import cgeo.geocaching.models.Trackable; import cgeo.geocaching.models.Waypoint; import cgeo.geocaching.network.Network; import cgeo.geocaching.network.Parameters; import cgeo.geocaching.settings.Settings; import cgeo.geocaching.storage.DataStore; import cgeo.geocaching.utils.AndroidRxUtils; import cgeo.geocaching.utils.DisposableHandler; import cgeo.geocaching.utils.JsonUtils; import cgeo.geocaching.utils.Log; import cgeo.geocaching.utils.MatcherWrapper; import cgeo.geocaching.utils.SynchronizedDateFormat; import cgeo.geocaching.utils.TextUtils; import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import java.io.IOException; import java.io.InputStream; import java.text.ParseException; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; import java.util.regex.Pattern; import javax.annotation.Nonnull; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.reactivex.Observable; import io.reactivex.ObservableEmitter; import io.reactivex.ObservableOnSubscribe; import io.reactivex.Single; import io.reactivex.functions.BiFunction; import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.compress.utils.IOUtils; import org.apache.commons.lang3.StringEscapeUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutablePair; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; public final class GCParser { @NonNull private static final SynchronizedDateFormat DATE_TB_IN_1 = new SynchronizedDateFormat("EEEEE, dd MMMMM yyyy", Locale.ENGLISH); // Saturday, 28 March 2009 @NonNull private static final SynchronizedDateFormat DATE_TB_IN_2 = new SynchronizedDateFormat("EEEEE, MMMMM dd, yyyy", Locale.ENGLISH); // Saturday, March 28, 2009 @NonNull private static final ImmutablePair<StatusCode, Geocache> UNKNOWN_PARSE_ERROR = ImmutablePair.of(StatusCode.UNKNOWN_ERROR, null); private GCParser() { // Utility class } @Nullable private static SearchResult parseSearch(final String url, final String pageContent) { if (StringUtils.isBlank(pageContent)) { Log.e("GCParser.parseSearch: No page given"); return null; } String page = pageContent; final SearchResult searchResult = new SearchResult(); searchResult.setUrl(url); searchResult.setViewstates(GCLogin.getViewstates(page)); if (!page.contains("SearchResultsTable")) { // there are no results. aborting here avoids a wrong error log in the next parsing step return searchResult; } int startPos = page.indexOf("<div id=\"ctl00_ContentBody_ResultsPanel\""); if (startPos == -1) { Log.e("GCParser.parseSearch: ID \"ctl00_ContentBody_dlResults\" not found on page"); return null; } page = page.substring(startPos); // cut on <table startPos = page.indexOf('>'); final int endPos = page.indexOf("ctl00_ContentBody_UnitTxt"); if (startPos == -1 || endPos == -1) { Log.e("GCParser.parseSearch: ID \"ctl00_ContentBody_UnitTxt\" not found on page"); return null; } page = page.substring(startPos + 1, endPos - startPos + 1); // cut between <table> and </table> final List<String> cids = new ArrayList<>(); final String[] rows = StringUtils.splitByWholeSeparator(page, "<tr class="); final int rowsCount = rows.length; int excludedCaches = 0; final List<Geocache> caches = new ArrayList<>(); for (int z = 1; z < rowsCount; z++) { final Geocache cache = new Geocache(); final String row = rows[z]; // check for cache type presence if (!row.contains("images/wpttypes")) { continue; } try { final MatcherWrapper matcherGuidAndDisabled = new MatcherWrapper(GCConstants.PATTERN_SEARCH_GUIDANDDISABLED, row); while (matcherGuidAndDisabled.find()) { if (matcherGuidAndDisabled.group(2) != null) { cache.setName(TextUtils.stripHtml(matcherGuidAndDisabled.group(2).trim())); } if (matcherGuidAndDisabled.group(3) != null) { cache.setLocation(TextUtils.stripHtml(matcherGuidAndDisabled.group(3).trim())); } final String attr = matcherGuidAndDisabled.group(1); if (attr != null) { cache.setDisabled(attr.contains("Strike")); cache.setArchived(attr.contains("OldWarning")); } } } catch (final RuntimeException e) { // failed to parse GUID and/or Disabled Log.w("GCParser.parseSearch: Failed to parse GUID and/or Disabled data", e); } if (Settings.isExcludeDisabledCaches() && (cache.isDisabled() || cache.isArchived())) { // skip disabled and archived caches excludedCaches++; continue; } cache.setGeocode(TextUtils.getMatch(row, GCConstants.PATTERN_SEARCH_GEOCODE, true, 1, cache.getGeocode(), true)); // cache type cache.setType(CacheType.getByWaypointType(TextUtils.getMatch(row, GCConstants.PATTERN_SEARCH_TYPE, null))); // cache direction - image if (Settings.getLoadDirImg()) { final String direction = TextUtils.getMatch(row, GCConstants.PATTERN_SEARCH_DIRECTION_DISTANCE, false, null); if (direction != null) { cache.setDirectionImg(direction); } } // cache distance - estimated distance for basic members final String distance = TextUtils.getMatch(row, GCConstants.PATTERN_SEARCH_DIRECTION_DISTANCE, false, 2, null, false); if (distance != null) { cache.setDistance(DistanceParser.parseDistance(distance, !Settings.useImperialUnits())); } // difficulty/terrain final MatcherWrapper matcherDT = new MatcherWrapper(GCConstants.PATTERN_SEARCH_DIFFICULTY_TERRAIN, row); if (matcherDT.find()) { final Float difficulty = parseStars(matcherDT.group(1)); if (difficulty != null) { cache.setDifficulty(difficulty); } final Float terrain = parseStars(matcherDT.group(3)); if (terrain != null) { cache.setTerrain(terrain); } } // size final String container = TextUtils.getMatch(row, GCConstants.PATTERN_SEARCH_CONTAINER, false, null); cache.setSize(CacheSize.getById(container)); // date hidden, makes sorting event caches easier final String dateHidden = TextUtils.getMatch(row, GCConstants.PATTERN_SEARCH_HIDDEN_DATE, false, null); if (StringUtils.isNotBlank(dateHidden)) { try { final Date date = GCLogin.parseGcCustomDate(dateHidden); if (date != null) { cache.setHidden(date); } } catch (final ParseException e) { Log.e("Error parsing event date from search", e); } } // cache inventory final MatcherWrapper matcherTbs = new MatcherWrapper(GCConstants.PATTERN_SEARCH_TRACKABLES, row); String inventoryPre = null; while (matcherTbs.find()) { try { cache.setInventoryItems(Integer.parseInt(matcherTbs.group(1))); } catch (final NumberFormatException e) { Log.e("Error parsing trackables count", e); } inventoryPre = matcherTbs.group(2); } if (StringUtils.isNotBlank(inventoryPre)) { assert inventoryPre != null; final MatcherWrapper matcherTbsInside = new MatcherWrapper(GCConstants.PATTERN_SEARCH_TRACKABLESINSIDE, inventoryPre); while (matcherTbsInside.find()) { if (matcherTbsInside.group(1) != null && !matcherTbsInside.group(1).equalsIgnoreCase("premium member only cache") && cache.getInventoryItems() <= 0) { cache.setInventoryItems(1); } } } // premium cache cache.setPremiumMembersOnly(row.contains("/images/icons/16/premium_only.png")); // found it cache.setFound(row.contains("/images/icons/16/found.png") || row.contains("uxUserLogDate\" class=\"Success\"")); // infer cache id from geocode cache.setCacheId(String.valueOf(GCConstants.gccodeToGCId(cache.getGeocode()))); cids.add(cache.getCacheId()); // favorite count try { final String result = getNumberString(TextUtils.getMatch(row, GCConstants.PATTERN_SEARCH_FAVORITE, false, 1, null, true)); if (result != null) { cache.setFavoritePoints(Integer.parseInt(result)); } } catch (final NumberFormatException e) { Log.w("GCParser.parseSearch: Failed to parse favorite count", e); } caches.add(cache); } searchResult.addAndPutInCache(caches); // total caches found try { final String result = TextUtils.getMatch(page, GCConstants.PATTERN_SEARCH_TOTALCOUNT, false, 1, null, true); if (result != null) { searchResult.setTotalCountGC(Integer.parseInt(result) - excludedCaches); } } catch (final NumberFormatException e) { Log.w("GCParser.parseSearch: Failed to parse cache count", e); } if (!cids.isEmpty() && Settings.isGCPremiumMember()) { Log.i("Trying to get .loc for " + cids.size() + " caches"); final Observable<Set<Geocache>> storedCaches = Observable.defer(new Callable<Observable<Set<Geocache>>>() { @Override public Observable<Set<Geocache>> call() { return Observable.just(DataStore.loadCaches(Geocache.getGeocodes(caches), LoadFlags.LOAD_CACHE_OR_DB)); } }).subscribeOn(Schedulers.io()).cache(); storedCaches.subscribe(); // Force asynchronous start of database loading try { // get coordinates for parsed caches final Parameters params = new Parameters( "__EVENTTARGET", "", "__EVENTARGUMENT", ""); GCLogin.putViewstates(params, searchResult.getViewstates()); for (final String cid : cids) { params.put("CID", cid); } params.put("Download", "Download Waypoints"); // retrieve target url final String queryUrl = TextUtils.getMatch(pageContent, GCConstants.PATTERN_SEARCH_POST_ACTION, ""); if (StringUtils.isEmpty(queryUrl)) { Log.w("Loc download url not found"); } else { final String coordinates = Network.getResponseData(Network.postRequest("https://www.geocaching.com/seek/" + queryUrl, params), false); if (StringUtils.contains(coordinates, "You have not agreed to the license agreement. The license agreement is required before you can start downloading GPX or LOC files from Geocaching.com")) { Log.i("User has not agreed to the license agreement. Can\'t download .loc file."); searchResult.setError(StatusCode.UNAPPROVED_LICENSE); return searchResult; } LocParser.parseLoc(coordinates, storedCaches.blockingSingle()); } } catch (final RuntimeException e) { Log.e("GCParser.parseSearch.CIDs", e); } } return searchResult; } @Nullable private static Float parseStars(final String value) { final float floatValue = Float.parseFloat(StringUtils.replaceChars(value, ',', '.')); return floatValue >= 0.5 && floatValue <= 5.0 ? floatValue : null; } @Nullable static SearchResult parseCache(final String page, final DisposableHandler handler) { final ImmutablePair<StatusCode, Geocache> parsed = parseCacheFromText(page, handler); // attention: parseCacheFromText already stores implicitly through searchResult.addCache if (parsed.left != StatusCode.NO_ERROR) { return new SearchResult(parsed.left); } final Geocache cache = parsed.right; getExtraOnlineInfo(cache, page, handler); // too late: it is already stored through parseCacheFromText cache.setDetailedUpdatedNow(); if (DisposableHandler.isDisposed(handler)) { return null; } // save full detailed caches DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_cache); DataStore.saveCache(cache, EnumSet.of(SaveFlag.DB)); // update progress message so user knows we're still working. This is more of a place holder than // actual indication of what the program is doing DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_render); return new SearchResult(cache); } @NonNull static SearchResult parseAndSaveCacheFromText(final String page, @Nullable final DisposableHandler handler) { final ImmutablePair<StatusCode, Geocache> parsed = parseCacheFromText(page, handler); final SearchResult result = new SearchResult(parsed.left); if (parsed.left == StatusCode.NO_ERROR) { result.addAndPutInCache(Collections.singletonList(parsed.right)); DataStore.saveLogs(parsed.right.getGeocode(), getLogs(parseUserToken(page), Logs.ALL).blockingIterable()); } return result; } /** * Parse cache from text and return either an error code or a cache object in a pair. Note that inline logs are * not parsed nor saved, while the cache itself is. * * @param pageIn * the page text to parse * @param handler * the handler to send the progress notifications to * @return a pair, with a {@link StatusCode} on the left, and a non-null cache object on the right * iff the status code is {@link StatusCode#NO_ERROR}. */ @NonNull private static ImmutablePair<StatusCode, Geocache> parseCacheFromText(final String pageIn, @Nullable final DisposableHandler handler) { DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_details); if (StringUtils.isBlank(pageIn)) { Log.e("GCParser.parseCache: No page given"); return UNKNOWN_PARSE_ERROR; } if (StringUtils.contains(pageIn, GCConstants.STRING_404_FILE_NOT_FOUND)) { return ImmutablePair.of(StatusCode.CACHE_NOT_FOUND, null); } if (pageIn.contains(GCConstants.STRING_UNPUBLISHED_OTHER) || pageIn.contains(GCConstants.STRING_UNPUBLISHED_FROM_SEARCH)) { return ImmutablePair.of(StatusCode.UNPUBLISHED_CACHE, null); } if (pageIn.contains(GCConstants.STRING_PREMIUMONLY_1) || pageIn.contains(GCConstants.STRING_PREMIUMONLY_2)) { return ImmutablePair.of(StatusCode.PREMIUM_ONLY, null); } final String cacheName = TextUtils.stripHtml(TextUtils.getMatch(pageIn, GCConstants.PATTERN_NAME, true, "")); if (GCConstants.STRING_UNKNOWN_ERROR.equalsIgnoreCase(cacheName)) { return UNKNOWN_PARSE_ERROR; } // first handle the content with line breaks, then trim everything for easier matching and reduced memory consumption in parsed fields String personalNoteWithLineBreaks = ""; final MatcherWrapper matcher = new MatcherWrapper(GCConstants.PATTERN_PERSONALNOTE, pageIn); if (matcher.find()) { personalNoteWithLineBreaks = matcher.group(1).trim(); } final String page = TextUtils.replaceWhitespace(pageIn); final Geocache cache = new Geocache(); final String status = TextUtils.getMatch(page, GCConstants.PATTERN_STATUS, ""); cache.setDisabled(containsStatus(status, GCConstants.STATUS_DISABLED)); cache.setArchived(containsStatus(status, GCConstants.STATUS_ARCHIVED)); cache.setPremiumMembersOnly(TextUtils.matches(page, GCConstants.PATTERN_PREMIUMMEMBERS)); cache.setFavorite(TextUtils.matches(page, GCConstants.PATTERN_IS_FAVORITE)); // cache geocode cache.setGeocode(TextUtils.getMatch(page, GCConstants.PATTERN_GEOCODE, true, cache.getGeocode())); // cache id cache.setCacheId(String.valueOf(GCConstants.gccodeToGCId(cache.getGeocode()))); // cache guid cache.setGuid(TextUtils.getMatch(page, GCConstants.PATTERN_GUID, true, cache.getGuid())); // cache watchlistcount cache.setWatchlistCount(getWatchListCount(page)); // name cache.setName(cacheName); // owner real name cache.setOwnerUserId(Network.decode(TextUtils.getMatch(page, GCConstants.PATTERN_OWNER_USERID, true, cache.getOwnerUserId()))); cache.setUserModifiedCoords(false); String tableInside = page; final int pos = tableInside.indexOf(GCConstants.STRING_CACHEDETAILS); if (pos == -1) { Log.e("GCParser.parseCache: ID \"cacheDetails\" not found on page"); return UNKNOWN_PARSE_ERROR; } tableInside = tableInside.substring(pos); if (StringUtils.isNotBlank(tableInside)) { // cache terrain String result = TextUtils.getMatch(tableInside, GCConstants.PATTERN_TERRAIN, true, null); if (result != null) { try { cache.setTerrain(Float.parseFloat(StringUtils.replaceChars(result, '_', '.'))); } catch (final NumberFormatException e) { Log.e("Error parsing terrain value", e); } } // cache difficulty result = TextUtils.getMatch(tableInside, GCConstants.PATTERN_DIFFICULTY, true, null); if (result != null) { try { cache.setDifficulty(Float.parseFloat(StringUtils.replaceChars(result, '_', '.'))); } catch (final NumberFormatException e) { Log.e("Error parsing difficulty value", e); } } // owner cache.setOwnerDisplayName(StringEscapeUtils.unescapeHtml4(TextUtils.getMatch(tableInside, GCConstants.PATTERN_OWNER_DISPLAYNAME, true, cache.getOwnerDisplayName()))); // hidden try { String hiddenString = TextUtils.getMatch(tableInside, GCConstants.PATTERN_HIDDEN, true, null); if (StringUtils.isNotBlank(hiddenString)) { cache.setHidden(GCLogin.parseGcCustomDate(hiddenString)); } if (cache.getHiddenDate() == null) { // event date hiddenString = TextUtils.getMatch(tableInside, GCConstants.PATTERN_HIDDENEVENT, true, null); if (StringUtils.isNotBlank(hiddenString)) { cache.setHidden(GCLogin.parseGcCustomDate(hiddenString)); } } } catch (final ParseException e) { // failed to parse cache hidden date Log.w("GCParser.parseCache: Failed to parse cache hidden (event) date", e); } // favorite try { cache.setFavoritePoints(Integer.parseInt(TextUtils.getMatch(tableInside, GCConstants.PATTERN_FAVORITECOUNT, true, "0"))); } catch (final NumberFormatException e) { Log.e("Error parsing favorite count", e); } // cache size cache.setSize(CacheSize.getById(TextUtils.getMatch(tableInside, GCConstants.PATTERN_SIZE, true, CacheSize.NOT_CHOSEN.id))); } // cache found cache.setFound(TextUtils.matches(page, GCConstants.PATTERN_FOUND)); // cache type cache.setType(CacheType.getByGuid(TextUtils.getMatch(page, GCConstants.PATTERN_TYPE, true, cache.getType().id))); // on watchlist cache.setOnWatchlist(TextUtils.matches(page, GCConstants.PATTERN_WATCHLIST)); // latitude and longitude. Can only be retrieved if user is logged in String latlon = TextUtils.getMatch(page, GCConstants.PATTERN_LATLON, true, ""); if (StringUtils.isNotEmpty(latlon)) { try { cache.setCoords(new Geopoint(latlon)); cache.setReliableLatLon(true); } catch (final Geopoint.GeopointException e) { Log.w("GCParser.parseCache: Failed to parse cache coordinates", e); } } // cache location cache.setLocation(TextUtils.getMatch(page, GCConstants.PATTERN_LOCATION, true, "")); // cache hint final String result = TextUtils.getMatch(page, GCConstants.PATTERN_HINT, false, null); if (result != null) { // replace linebreak and paragraph tags final String hint = GCConstants.PATTERN_LINEBREAK.matcher(result).replaceAll("\n"); cache.setHint(StringUtils.replace(hint, "</p>", "").trim()); } cache.checkFields(); // cache personal note cache.setPersonalNote(personalNoteWithLineBreaks); // cache short description cache.setShortDescription(TextUtils.getMatch(page, GCConstants.PATTERN_SHORTDESC, true, "")); // cache description final String longDescription = TextUtils.getMatch(page, GCConstants.PATTERN_DESC, true, ""); String relatedWebPage = TextUtils.getMatch(page, GCConstants.PATTERN_RELATED_WEB_PAGE, true, ""); if (StringUtils.isNotEmpty(relatedWebPage)) { relatedWebPage = String.format("<br/><br/><a href=\"%s\"><b>%s</b></a>", relatedWebPage, relatedWebPage); } cache.setDescription(longDescription + relatedWebPage); // cache attributes try { final List<String> attributes = new ArrayList<>(); final String attributesPre = TextUtils.getMatch(page, GCConstants.PATTERN_ATTRIBUTES, true, null); if (attributesPre != null) { final MatcherWrapper matcherAttributesInside = new MatcherWrapper(GCConstants.PATTERN_ATTRIBUTESINSIDE, attributesPre); while (matcherAttributesInside.find()) { if (!matcherAttributesInside.group(2).equalsIgnoreCase("blank")) { // by default, use the tooltip of the attribute String attribute = matcherAttributesInside.group(2).toLowerCase(Locale.US); // if the image name can be recognized, use the image name as attribute final String imageName = matcherAttributesInside.group(1).trim(); if (StringUtils.isNotEmpty(imageName)) { final int start = imageName.lastIndexOf('/'); final int end = imageName.lastIndexOf('.'); if (start >= 0 && end >= 0) { attribute = imageName.substring(start + 1, end).replace('-', '_').toLowerCase(Locale.US); } } attributes.add(attribute); } } } cache.setAttributes(attributes); } catch (final RuntimeException e) { // failed to parse cache attributes Log.w("GCParser.parseCache: Failed to parse cache attributes", e); } // cache spoilers try { if (DisposableHandler.isDisposed(handler)) { return UNKNOWN_PARSE_ERROR; } DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_spoilers); final MatcherWrapper matcherSpoilersInside = new MatcherWrapper(GCConstants.PATTERN_SPOILER_IMAGE, page); while (matcherSpoilersInside.find()) { final String url = fullScaleImageUrl(matcherSpoilersInside.group(1)); String title = null; if (matcherSpoilersInside.group(2) != null) { title = matcherSpoilersInside.group(2); } String description = null; if (matcherSpoilersInside.group(3) != null) { description = matcherSpoilersInside.group(3); } if (title != null) { cache.addSpoiler(new Image.Builder().setUrl(url).setTitle(title).setDescription(description).build()); } } } catch (final RuntimeException e) { // failed to parse cache spoilers Log.w("GCParser.parseCache: Failed to parse cache spoilers", e); } // background image, to be added only if the image is not already present in the cache listing final MatcherWrapper matcherBackgroundImage = new MatcherWrapper(GCConstants.PATTERN_BACKGROUND_IMAGE, page); if (matcherBackgroundImage.find()) { final String url = fullScaleImageUrl(matcherBackgroundImage.group(1)); boolean present = false; for (final Image image : cache.getSpoilers()) { if (StringUtils.equals(image.getUrl(), url)) { present = true; break; } } if (!present) { cache.addSpoiler(new Image.Builder().setUrl(url).setTitle(CgeoApplication.getInstance().getString(R.string.cache_image_background)).build()); } } // cache inventory try { final MatcherWrapper matcherInventory = new MatcherWrapper(GCConstants.PATTERN_INVENTORY, page); if (matcherInventory.find()) { final String inventoryPre = matcherInventory.group(); final ArrayList<Trackable> inventory = new ArrayList<>(); if (StringUtils.isNotBlank(inventoryPre)) { final MatcherWrapper matcherInventoryInside = new MatcherWrapper(GCConstants.PATTERN_INVENTORYINSIDE, inventoryPre); while (matcherInventoryInside.find()) { final Trackable inventoryItem = new Trackable(); inventoryItem.forceSetBrand(TrackableBrand.TRAVELBUG); inventoryItem.setGuid(matcherInventoryInside.group(1)); inventoryItem.setName(matcherInventoryInside.group(2)); inventory.add(inventoryItem); } } cache.mergeInventory(inventory, EnumSet.of(TrackableBrand.TRAVELBUG)); } } catch (final RuntimeException e) { // failed to parse cache inventory Log.w("GCParser.parseCache: Failed to parse cache inventory (2)", e); } // cache logs counts try { final String countlogs = TextUtils.getMatch(page, GCConstants.PATTERN_COUNTLOGS, true, null); if (countlogs != null) { final MatcherWrapper matcherLog = new MatcherWrapper(GCConstants.PATTERN_COUNTLOG, countlogs); while (matcherLog.find()) { final String typeStr = matcherLog.group(1); final String countStr = getNumberString(matcherLog.group(2)); if (StringUtils.isNotBlank(typeStr) && LogType.getByIconName(typeStr) != LogType.UNKNOWN && StringUtils.isNotBlank(countStr)) { cache.getLogCounts().put(LogType.getByIconName(typeStr), Integer.valueOf(countStr)); } } } if (cache.getLogCounts().isEmpty()) { Log.w("GCParser.parseCache: Failed to parse cache log count"); } } catch (final NumberFormatException e) { // failed to parse logs Log.w("GCParser.parseCache: Failed to parse cache log count", e); } // waypoints - reset collection cache.setWaypoints(Collections.<Waypoint> emptyList(), false); // add waypoint for original coordinates in case of user-modified listing-coordinates try { final String originalCoords = TextUtils.getMatch(page, GCConstants.PATTERN_LATLON_ORIG, false, null); if (originalCoords != null) { final Waypoint waypoint = new Waypoint(CgeoApplication.getInstance().getString(R.string.cache_coordinates_original), WaypointType.ORIGINAL, false); waypoint.setCoords(new Geopoint(originalCoords)); cache.addOrChangeWaypoint(waypoint, false); cache.setUserModifiedCoords(true); } } catch (final Geopoint.GeopointException ignored) { } int wpBegin = page.indexOf("<table class=\"Table\" id=\"ctl00_ContentBody_Waypoints\">"); if (wpBegin != -1) { // parse waypoints if (DisposableHandler.isDisposed(handler)) { return UNKNOWN_PARSE_ERROR; } DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_waypoints); String wpList = page.substring(wpBegin); int wpEnd = wpList.indexOf("</p>"); if (wpEnd > -1 && wpEnd <= wpList.length()) { wpList = wpList.substring(0, wpEnd); } if (!wpList.contains("No additional waypoints to display.")) { wpEnd = wpList.indexOf("</table>"); wpList = wpList.substring(0, wpEnd); wpBegin = wpList.indexOf("<tbody>"); wpEnd = wpList.indexOf("</tbody>"); if (wpBegin >= 0 && wpEnd >= 0 && wpEnd <= wpList.length()) { wpList = wpList.substring(wpBegin + 7, wpEnd); } final String[] wpItems = StringUtils.splitByWholeSeparator(wpList, "<tr"); for (int j = 1; j < wpItems.length; j += 2) { final String[] wp = StringUtils.splitByWholeSeparator(wpItems[j], "<td"); assert wp != null; if (wp.length < 8) { Log.e("GCParser.cacheParseFromText: not enough waypoint columns in table"); continue; } // waypoint name // res is null during the unit tests final String name = TextUtils.getMatch(wp[6], GCConstants.PATTERN_WPNAME, true, 1, CgeoApplication.getInstance().getString(R.string.waypoint), true); // waypoint type final String resulttype = TextUtils.getMatch(wp[3], GCConstants.PATTERN_WPTYPE, null); final Waypoint waypoint = new Waypoint(name, WaypointType.findById(resulttype), false); // waypoint prefix waypoint.setPrefix(TextUtils.getMatch(wp[4], GCConstants.PATTERN_WPPREFIXORLOOKUPORLATLON, true, 2, waypoint.getPrefix(), false)); // waypoint lookup waypoint.setLookup(TextUtils.getMatch(wp[5], GCConstants.PATTERN_WPPREFIXORLOOKUPORLATLON, true, 2, waypoint.getLookup(), false)); // waypoint latitude and longitude latlon = TextUtils.stripHtml(TextUtils.getMatch(wp[7], GCConstants.PATTERN_WPPREFIXORLOOKUPORLATLON, false, 2, "", false)).trim(); if (!StringUtils.startsWith(latlon, "???")) { waypoint.setCoords(new Geopoint(latlon)); } else { waypoint.setOriginalCoordsEmpty(true); } if (wpItems.length >= j) { final String[] wpNote = StringUtils.splitByWholeSeparator(wpItems[j + 1], "<td"); assert wpNote != null; if (wpNote.length < 4) { Log.d("GCParser.cacheParseFromText: not enough waypoint columns in table to extract note"); continue; } // waypoint note waypoint.setNote(TextUtils.getMatch(wpNote[3], GCConstants.PATTERN_WPNOTE, waypoint.getNote())); } cache.addOrChangeWaypoint(waypoint, false); } } } // last check for necessary cache conditions if (StringUtils.isBlank(cache.getGeocode())) { return UNKNOWN_PARSE_ERROR; } cache.setDetailedUpdatedNow(); return ImmutablePair.of(StatusCode.NO_ERROR, cache); } private static boolean containsStatus(final String status, @NonNull final List<String> patterns) { for (final String pattern : patterns) { if (StringUtils.contains(status, pattern)) { return true; } } return false; } @Nullable private static String getNumberString(final String numberWithPunctuation) { return StringUtils.replaceChars(numberWithPunctuation, ".,", ""); } @NonNull static String fullScaleImageUrl(@NonNull final String imageUrl) { // For images from geocaching.com: the original spoiler URL // (include .../display/... contains a low-resolution image // if we shorten the URL we get the original-resolution image return GCConstants.PATTERN_GC_HOSTED_IMAGE.matcher(imageUrl).find() ? imageUrl.replace("/display", "") : imageUrl; } @Nullable public static SearchResult searchByNextPage(final SearchResult search) { if (search == null) { return null; } final String url = search.getUrl(); if (StringUtils.isBlank(url)) { Log.e("GCParser.searchByNextPage: No url found"); return search; } final String[] viewstates = search.getViewstates(); if (GCLogin.isEmpty(viewstates)) { Log.e("GCParser.searchByNextPage: No viewstate given"); return search; } final Parameters params = new Parameters( "__EVENTTARGET", "ctl00$ContentBody$pgrBottom$ctl08", "__EVENTARGUMENT", ""); GCLogin.putViewstates(params, viewstates); final String page = GCLogin.getInstance().postRequestLogged(url, params); if (!GCLogin.getInstance().getLoginStatus(page)) { Log.e("GCParser.postLogTrackable: Can not log in geocaching"); return search; } if (StringUtils.isBlank(page)) { Log.e("GCParser.searchByNextPage: No data from server"); return search; } final SearchResult searchResult = parseSearch(url, page); if (searchResult == null || CollectionUtils.isEmpty(searchResult.getGeocodes())) { Log.w("GCParser.searchByNextPage: No cache parsed"); return search; } // search results don't need to be filtered so load GCVote ratings here GCVote.loadRatings(new ArrayList<>(searchResult.getCachesFromSearchResult(LoadFlags.LOAD_CACHE_OR_DB))); // save to application search.setError(searchResult.getError()); search.setViewstates(searchResult.getViewstates()); for (final String geocode : searchResult.getGeocodes()) { search.addGeocode(geocode); } return search; } /** * Possibly hide caches found or hidden by user. This mutates its params argument when possible. * * @param params the parameters to mutate, or null to create a new Parameters if needed * @param my {@code true} if the user's caches must be forcibly included regardless of their settings * @return the original params if not null, maybe augmented with f=1, or a new Parameters with f=1 or null otherwise */ private static Parameters addFToParams(final Parameters params, final boolean my) { if (!my && Settings.isExcludeMyCaches()) { if (params == null) { return new Parameters("f", "1"); } params.put("f", "1"); Log.i("Skipping caches found or hidden by user."); } return params; } @Nullable private static SearchResult searchByAny(@NonNull final CacheType cacheType, final boolean my, final Parameters params) { insertCacheType(params, cacheType); final String uri = "https://www.geocaching.com/seek/nearest.aspx"; final Parameters paramsWithF = addFToParams(params, my); final String page = GCLogin.getInstance().getRequestLogged(uri, paramsWithF); if (StringUtils.isBlank(page)) { Log.w("GCParser.searchByAny: No data from server"); return null; } assert page != null; final String fullUri = uri + "?" + paramsWithF; final SearchResult searchResult = parseSearch(fullUri, page); if (searchResult == null || CollectionUtils.isEmpty(searchResult.getGeocodes())) { Log.w("GCParser.searchByAny: No cache parsed"); return searchResult; } final SearchResult search = searchResult.filterSearchResults(Settings.isExcludeDisabledCaches(), cacheType); GCLogin.getInstance().getLoginStatus(page); return search; } public static SearchResult searchByCoords(@NonNull final Geopoint coords, @NonNull final CacheType cacheType) { final Parameters params = new Parameters("lat", Double.toString(coords.getLatitude()), "lng", Double.toString(coords.getLongitude())); return searchByAny(cacheType, false, params); } static SearchResult searchByKeyword(@NonNull final String keyword, @NonNull final CacheType cacheType) { if (StringUtils.isBlank(keyword)) { Log.e("GCParser.searchByKeyword: No keyword given"); return null; } final Parameters params = new Parameters("key", keyword); return searchByAny(cacheType, false, params); } private static boolean isSearchForMyCaches(final String userName) { if (userName.equalsIgnoreCase(Settings.getGcCredentials().getUserName())) { Log.i("Overriding users choice because of self search, downloading all caches."); return true; } return false; } public static SearchResult searchByUsername(final String userName, @NonNull final CacheType cacheType) { if (StringUtils.isBlank(userName)) { Log.e("GCParser.searchByUsername: No user name given"); return null; } final Parameters params = new Parameters("ul", userName); return searchByAny(cacheType, isSearchForMyCaches(userName), params); } public static SearchResult searchByPocketQuery(final String pocketGuid, @NonNull final CacheType cacheType) { if (StringUtils.isBlank(pocketGuid)) { Log.e("GCParser.searchByPocket: No guid name given"); return null; } final Parameters params = new Parameters("pq", pocketGuid); return searchByAny(cacheType, false, params); } public static SearchResult searchByOwner(final String userName, @NonNull final CacheType cacheType) { if (StringUtils.isBlank(userName)) { Log.e("GCParser.searchByOwner: No user name given"); return null; } final Parameters params = new Parameters("u", userName); return searchByAny(cacheType, isSearchForMyCaches(userName), params); } @Nullable public static Trackable searchTrackable(final String geocode, final String guid, final String id) { if (StringUtils.isBlank(geocode) && StringUtils.isBlank(guid) && StringUtils.isBlank(id)) { Log.w("GCParser.searchTrackable: No geocode nor guid nor id given"); return null; } final Parameters params = new Parameters(); if (StringUtils.isNotBlank(geocode)) { params.put("tracker", geocode); } else if (StringUtils.isNotBlank(guid)) { params.put("guid", guid); } else if (StringUtils.isNotBlank(id)) { params.put("id", id); } final String page = GCLogin.getInstance().getRequestLogged("https://www.geocaching.com/track/details.aspx", params); if (StringUtils.isBlank(page)) { Log.w("GCParser.searchTrackable: No data from server"); return null; } assert page != null; final Trackable trackable = parseTrackable(page, geocode); if (trackable == null) { Log.w("GCParser.searchTrackable: No trackable parsed"); return null; } return trackable; } /** * Observable that fetches a list of pocket queries. Returns a single element (which may be an empty list). * Executes on the network scheduler. */ public static final Observable<List<PocketQuery>> searchPocketQueryListObservable = Observable.defer(new Callable<Observable<List<PocketQuery>>>() { @Override public Observable<List<PocketQuery>> call() { final Parameters params = new Parameters(); final String page = GCLogin.getInstance().getRequestLogged("https://www.geocaching.com/pocket/default.aspx", params); if (StringUtils.isBlank(page)) { Log.e("GCParser.searchPocketQueryList: No data from server"); return Observable.just(Collections.<PocketQuery>emptyList()); } try { final Document document = Jsoup.parse(page); final List<PocketQuery> list = new ArrayList<>(); final Map<String, PocketQuery> downloadablePocketQueries = getDownloadablePocketQueries(document); list.addAll(downloadablePocketQueries.values()); final Elements rows = document.select("#pqRepeater tr:has(td)"); for (final Element row : rows) { if (row == rows.last()) { break; // skip footer } final Element link = row.select("td:eq(3) > a").first(); final Uri uri = Uri.parse(link.attr("href")); final String guid = uri.getQueryParameter("guid"); if (!downloadablePocketQueries.containsKey(guid)) { final String name = link.attr("title"); final PocketQuery pocketQuery = new PocketQuery(guid, name, -1, false, 0, -1); list.add(pocketQuery); } } Collections.sort(list, new Comparator<PocketQuery>() { @Override public int compare(final PocketQuery left, final PocketQuery right) { return TextUtils.COLLATOR.compare(left.getName(), right.getName()); } }); return Observable.just(list); } catch (final Exception e) { Log.e("GCParser.searchPocketQueryList: error parsing parsing html page", e); return Observable.error(e); } } }).subscribeOn(AndroidRxUtils.networkScheduler); /** * Reads the downloadable pocket queries from the uxOfflinePQTable * * @param document * the page as Document * * @return Map with downloadable PQs keyed by guid */ @NonNull private static Map<String, PocketQuery> getDownloadablePocketQueries(final Document document) throws Exception { final Map<String, PocketQuery> downloadablePocketQueries = new HashMap<>(); final Elements rows = document.select("#uxOfflinePQTable tr:has(td)"); for (final Element row : rows) { if (row == rows.last()) { break; // skip footer } final Elements cells = row.select("td"); if (cells.size() < 6) { Log.d("GCParser.getDownloadablePocketQueries: less than 6 table cells, looks like an empty table"); continue; } final Element link = cells.get(2).select("a").first(); if (link == null) { Log.w("GCParser.getDownloadablePocketQueries: Downloadlink not found"); continue; } final String name = link.text(); final String href = link.attr("href"); final Uri uri = Uri.parse(href); final String guid = uri.getQueryParameter("g"); final int count = Integer.parseInt(cells.get(4).text()); final MatcherWrapper matcherLastGeneration = new MatcherWrapper(GCConstants.PATTERN_PQ_LAST_GEN, cells.get(5).text()); long lastGeneration = 0; int daysRemaining = 0; if (matcherLastGeneration.find()) { final Date lastGenerationDate = GCLogin.parseGcCustomDate(matcherLastGeneration.group(1)); if (lastGenerationDate != null) { lastGeneration = lastGenerationDate.getTime(); } final String daysRemainingString = matcherLastGeneration.group(3); if (daysRemainingString != null) { daysRemaining = Integer.parseInt(daysRemainingString); } } final PocketQuery pocketQuery = new PocketQuery(guid, name, count, true, lastGeneration, daysRemaining); downloadablePocketQueries.put(guid, pocketQuery); } return downloadablePocketQueries; } @NonNull static ImmutablePair<StatusCode, String> postLog(final String geocode, final String cacheid, final String[] viewstates, final LogType logType, final int year, final int month, final int day, final String log, final List<TrackableLog> trackables, final boolean addToFavorites) { if (GCLogin.isEmpty(viewstates)) { Log.e("GCParser.postLog: No viewstate given"); return new ImmutablePair<>(StatusCode.LOG_POST_ERROR, ""); } if (StringUtils.isBlank(log)) { Log.w("GCParser.postLog: No log text given"); return new ImmutablePair<>(StatusCode.NO_LOG_TEXT, ""); } final String logInfo = log.replace("\n", "\r\n").trim(); // windows' eol and remove leading and trailing whitespaces Log.i("Trying to post log for cache #" + cacheid + " - action: " + logType + "; date: " + year + "." + month + "." + day + ", log: " + logInfo + "; trackables: " + (trackables != null ? trackables.size() : "0")); final Parameters params = new Parameters( "__EVENTTARGET", "", "__EVENTARGUMENT", "", "__LASTFOCUS", "", "ctl00$ContentBody$LogBookPanel1$ddLogType", Integer.toString(logType.id), "ctl00$ContentBody$LogBookPanel1$uxDateVisited", GCLogin.formatGcCustomDate(year, month, day), "ctl00$ContentBody$LogBookPanel1$uxDateVisited$Month", Integer.toString(month), "ctl00$ContentBody$LogBookPanel1$uxDateVisited$Day", Integer.toString(day), "ctl00$ContentBody$LogBookPanel1$uxDateVisited$Year", Integer.toString(year), "ctl00$ContentBody$LogBookPanel1$DateTimeLogged", String.format(Locale.ENGLISH, "%02d", month) + '/' + String.format(Locale.ENGLISH, "%02d", day) + '/' + String.format(Locale.ENGLISH, "%04d", year), "ctl00$ContentBody$LogBookPanel1$DateTimeLogged$Month", Integer.toString(month), "ctl00$ContentBody$LogBookPanel1$DateTimeLogged$Day", Integer.toString(day), "ctl00$ContentBody$LogBookPanel1$DateTimeLogged$Year", Integer.toString(year), "ctl00$ContentBody$LogBookPanel1$LogButton", "Submit Log Entry", "ctl00$ContentBody$LogBookPanel1$uxLogInfo", logInfo, "ctl00$ContentBody$LogBookPanel1$btnSubmitLog", "Submit Log Entry", "ctl00$ContentBody$LogBookPanel1$uxLogCreationSource", "Old", "ctl00$ContentBody$uxVistOtherListingGC", ""); if (addToFavorites) { params.put("ctl00$ContentBody$LogBookPanel1$chkAddToFavorites", "on"); } GCLogin.putViewstates(params, viewstates); if (trackables != null && !trackables.isEmpty()) { // we have some trackables to proceed final StringBuilder hdnSelected = new StringBuilder(); for (final TrackableLog tb : trackables) { if (tb.action != LogTypeTrackable.DO_NOTHING && tb.brand == TrackableBrand.TRAVELBUG) { hdnSelected.append(Integer.toString(tb.id)); hdnSelected.append(tb.action.action); hdnSelected.append(','); } } params.put("ctl00$ContentBody$LogBookPanel1$uxTrackables$hdnSelectedActions", hdnSelected.toString(), // selected trackables "ctl00$ContentBody$LogBookPanel1$uxTrackables$hdnCurrentFilter", ""); } final String uri = new Uri.Builder().scheme("https").authority("www.geocaching.com").path("/seek/log.aspx").encodedQuery("ID=" + cacheid).build().toString(); final GCLogin gcLogin = GCLogin.getInstance(); String page = gcLogin.postRequestLogged(uri, params); if (!gcLogin.getLoginStatus(page)) { Log.e("GCParser.postLog: Cannot log in geocaching"); return new ImmutablePair<>(StatusCode.NOT_LOGGED_IN, ""); } // maintenance, archived needs to be confirmed final MatcherWrapper matcher = new MatcherWrapper(GCConstants.PATTERN_MAINTENANCE, page); try { if (matcher.find()) { final String[] viewstatesConfirm = GCLogin.getViewstates(page); if (GCLogin.isEmpty(viewstatesConfirm)) { Log.e("GCParser.postLog: No viewstate for confirm log"); return new ImmutablePair<>(StatusCode.LOG_POST_ERROR, ""); } params.clear(); GCLogin.putViewstates(params, viewstatesConfirm); params.put("__EVENTTARGET", ""); params.put("__EVENTARGUMENT", ""); params.put("__LASTFOCUS", ""); params.put("ctl00$ContentBody$LogBookPanel1$btnConfirm", "Yes"); params.put("ctl00$ContentBody$LogBookPanel1$uxLogInfo", logInfo); params.put("ctl00$ContentBody$uxVistOtherListingGC", ""); if (trackables != null && !trackables.isEmpty()) { // we have some trackables to proceed final StringBuilder hdnSelected = new StringBuilder(); for (final TrackableLog tb : trackables) { final String action = Integer.toString(tb.id) + tb.action.action; final StringBuilder paramText = new StringBuilder("ctl00$ContentBody$LogBookPanel1$uxTrackables$repTravelBugs$ctl"); if (tb.ctl < 10) { paramText.append('0'); } paramText.append(tb.ctl).append("$ddlAction"); params.put(paramText.toString(), action); if (tb.action != LogTypeTrackable.DO_NOTHING) { hdnSelected.append(action); hdnSelected.append(','); } } params.put("ctl00$ContentBody$LogBookPanel1$uxTrackables$hdnSelectedActions", hdnSelected.toString()); // selected trackables params.put("ctl00$ContentBody$LogBookPanel1$uxTrackables$hdnCurrentFilter", ""); } page = Network.getResponseData(Network.postRequest(uri, params)); } } catch (final RuntimeException e) { Log.e("GCParser.postLog.confirm", e); } if (page == null) { Log.e("GCParser.postLog: didn't get response"); return new ImmutablePair<>(StatusCode.LOG_POST_ERROR, ""); } try { final MatcherWrapper matcherOk = new MatcherWrapper(GCConstants.PATTERN_OK1, page); if (matcherOk.find()) { Log.i("Log successfully posted to cache #" + cacheid); if (geocode != null) { final Calendar visitedDate = Calendar.getInstance(); visitedDate.set(year, month - 1, day); DataStore.saveVisitDate(geocode, visitedDate.getTimeInMillis()); } gcLogin.getLoginStatus(page); // the log-successful-page contains still the old value if (gcLogin.getActualCachesFound() >= 0) { gcLogin.setActualCachesFound(gcLogin.getActualCachesFound() + (logType.isFoundLog() ? 1 : 0)); } final String logID = TextUtils.getMatch(page, GCConstants.PATTERN_LOG_IMAGE_UPLOAD, ""); return new ImmutablePair<>(StatusCode.NO_ERROR, logID); } } catch (final Exception e) { Log.e("GCParser.postLog.check", e); } Log.e("GCParser.postLog: Failed to post log because of unknown error"); return new ImmutablePair<>(StatusCode.LOG_POST_ERROR, ""); } /** * Upload an image to a log that has already been posted * * @param logId * the ID of the log to upload the image to. Found on page returned when log is uploaded * @param image * The Image Object * @return status code to indicate success or failure */ @NonNull static ImmutablePair<StatusCode, String> uploadLogImage(final String logId, @NonNull final Image image) { final String uri = new Uri.Builder().scheme("https").authority("www.geocaching.com").path("/seek/upload.aspx").encodedQuery("LID=" + logId).build().toString(); final String page = GCLogin.getInstance().getRequestLogged(uri, null); if (StringUtils.isBlank(page)) { Log.e("GCParser.uploadLogImage: No data from server"); return new ImmutablePair<>(StatusCode.UNKNOWN_ERROR, null); } assert page != null; final String[] viewstates = GCLogin.getViewstates(page); final Parameters uploadParams = new Parameters( "__EVENTTARGET", "", "__EVENTARGUMENT", "", "ctl00$ContentBody$ImageUploadControl1$uxFileCaption", image.getTitle(), "ctl00$ContentBody$ImageUploadControl1$uxFileDesc", image.getDescription(), "ctl00$ContentBody$ImageUploadControl1$uxUpload", "Upload"); GCLogin.putViewstates(uploadParams, viewstates); final String response = Network.getResponseData(Network.postRequest(uri, uploadParams, "ctl00$ContentBody$ImageUploadControl1$uxFileUpload", "image/jpeg", image.getFile())); if (response == null) { Log.e("GCParser.uploadLogImage: didn't get response for image upload"); return ImmutablePair.of(StatusCode.LOGIMAGE_POST_ERROR, null); } final MatcherWrapper matcherUrl = new MatcherWrapper(GCConstants.PATTERN_IMAGE_UPLOAD_URL, response); if (matcherUrl.find()) { Log.i("Logimage successfully uploaded."); final String uploadedImageUrl = matcherUrl.group(1); return ImmutablePair.of(StatusCode.NO_ERROR, uploadedImageUrl); } Log.e("GCParser.uploadLogImage: Failed to upload image because of unknown error"); return ImmutablePair.of(StatusCode.LOGIMAGE_POST_ERROR, null); } /** * Post a log to GC.com. * * @return status code of the upload and ID of the log */ @NonNull public static StatusCode postLogTrackable(final String tbid, final String trackingCode, final String[] viewstates, final LogTypeTrackable logType, final int year, final int month, final int day, final String log) { if (GCLogin.isEmpty(viewstates)) { Log.e("GCParser.postLogTrackable: No viewstate given"); return StatusCode.LOG_POST_ERROR; } if (StringUtils.isBlank(log)) { Log.w("GCParser.postLogTrackable: No log text given"); return StatusCode.NO_LOG_TEXT; } Log.i("Trying to post log for trackable #" + trackingCode + " - action: " + logType + "; date: " + year + "." + month + "." + day + ", log: " + log); final String logInfo = log.replace("\n", "\r\n"); // windows' eol final Parameters params = new Parameters( "__EVENTTARGET", "", "__EVENTARGUMENT", "", "__LASTFOCUS", "", "ctl00$ContentBody$LogBookPanel1$ddLogType", Integer.toString(logType.id), "ctl00$ContentBody$LogBookPanel1$tbCode", trackingCode, "ctl00$ContentBody$LogBookPanel1$DateTimeLogged", Integer.toString(month) + "/" + Integer.toString(day) + "/" + Integer.toString(year), "ctl00$ContentBody$LogBookPanel1$uxDateVisited", GCLogin.formatGcCustomDate(year, month, day), "ctl00$ContentBody$LogBookPanel1$uxLogInfo", logInfo, "ctl00$ContentBody$LogBookPanel1$btnSubmitLog", "Submit Log Entry", "ctl00$ContentBody$uxVistOtherTrackableTB", ""); GCLogin.putViewstates(params, viewstates); final String uri = new Uri.Builder().scheme("https").authority("www.geocaching.com").path("/track/log.aspx").encodedQuery("wid=" + tbid).build().toString(); final String page = GCLogin.getInstance().postRequestLogged(uri, params); if (!GCLogin.getInstance().getLoginStatus(page)) { Log.e("GCParser.postLogTrackable: Cannot log in geocaching"); return StatusCode.NOT_LOGGED_IN; } try { final MatcherWrapper matcherOk = new MatcherWrapper(GCConstants.PATTERN_OK2, page); if (matcherOk.find()) { Log.i("Log successfully posted to trackable #" + trackingCode); return StatusCode.NO_ERROR; } } catch (final Exception e) { Log.e("GCParser.postLogTrackable.check", e); } Log.e("GCParser.postLogTrackable: Failed to post log because of unknown error"); return StatusCode.LOG_POST_ERROR; } /** * Adds the cache to the watchlist of the user. * * @param cache * the cache to add * @return {@code false} if an error occurred, {@code true} otherwise */ static boolean addToWatchlist(@NonNull final Geocache cache) { final String uri = "https://www.geocaching.com/my/watchlist.aspx?w=" + cache.getCacheId(); final String page = GCLogin.getInstance().postRequestLogged(uri, null); if (StringUtils.isBlank(page)) { Log.e("GCParser.addToWatchlist: No data from server"); return false; // error } final boolean guidOnPage = isGuidContainedInPage(cache, page); if (guidOnPage) { Log.i("GCParser.addToWatchlist: cache is on watchlist"); cache.setOnWatchlist(true); } else { Log.e("GCParser.addToWatchlist: cache is not on watchlist"); } // WatchListCount final String watchListPage = GCLogin.getInstance().postRequestLogged(cache.getLongUrl(), null); cache.setWatchlistCount(getWatchListCount(watchListPage)); return guidOnPage; // on watchlist (=added) / else: error } /** * This method extracts the amount of people watching on a geocache out of the HTMl website passed to it * @param page Page containing the information about howm many people watching on geocache * @return Number of people watching geocache, -1 when error */ static int getWatchListCount(final String page) { final String sCount = TextUtils.getMatch(page, GCConstants.PATTERN_WATCHLIST_COUNT, true, 1, "notFound", false); if ("notFound".equals(sCount)) { return -1; } try { return Integer.parseInt(sCount); } catch (final NumberFormatException nfe) { Log.e("Could not parse", nfe); return -1; } } /** * Removes the cache from the watch list * * @param cache * the cache to remove * @return {@code false} if an error occurred, {@code true} otherwise */ static boolean removeFromWatchlist(@NonNull final Geocache cache) { final String uri = "https://www.geocaching.com/my/watchlist.aspx?ds=1&action=rem&id=" + cache.getCacheId(); String page = GCLogin.getInstance().postRequestLogged(uri, null); if (StringUtils.isBlank(page)) { Log.e("GCParser.removeFromWatchlist: No data from server"); return false; // error } // removing cache from list needs approval by hitting "Yes" button final Parameters params = new Parameters( "__EVENTTARGET", "", "__EVENTARGUMENT", "", "ctl00$ContentBody$btnYes", "Yes"); GCLogin.transferViewstates(page, params); page = Network.getResponseData(Network.postRequest(uri, params)); final boolean guidOnPage = isGuidContainedInPage(cache, page); if (!guidOnPage) { Log.i("GCParser.removeFromWatchlist: cache removed from watchlist"); cache.setOnWatchlist(false); } else { Log.e("GCParser.removeFromWatchlist: cache not removed from watchlist"); } // WatchListCount final String watchListPage = GCLogin.getInstance().postRequestLogged(cache.getLongUrl(), null); cache.setWatchlistCount(getWatchListCount(watchListPage)); return !guidOnPage; // on watch list (=error) / not on watch list } /** * Checks if a page contains the guid of a cache * * @param cache the geocache * @param page * the page to search in, may be null * @return true if the page contains the guid of the cache, false otherwise */ private static boolean isGuidContainedInPage(@NonNull final Geocache cache, final String page) { if (StringUtils.isBlank(page) || StringUtils.isBlank(cache.getGuid())) { return false; } return Pattern.compile(cache.getGuid(), Pattern.CASE_INSENSITIVE).matcher(page).find(); } @Nullable static String requestHtmlPage(@Nullable final String geocode, @Nullable final String guid, final String log) { final Parameters params = new Parameters("decrypt", "y"); if (StringUtils.isNotBlank(geocode)) { params.put("wp", geocode); } else if (StringUtils.isNotBlank(guid)) { params.put("guid", guid); } params.put("log", log); params.put("numlogs", "0"); return GCLogin.getInstance().getRequestLogged("https://www.geocaching.com/seek/cache_details.aspx", params); } /** * Adds the cache to the favorites of the user. * * This must not be called from the UI thread. * * @param cache * the cache to add * @return {@code false} if an error occurred, {@code true} otherwise */ static boolean addToFavorites(@NonNull final Geocache cache) { return changeFavorite(cache, true); } private static boolean changeFavorite(@NonNull final Geocache cache, final boolean add) { final String userToken = getUserToken(cache); if (StringUtils.isEmpty(userToken)) { return false; } final String uri = "https://www.geocaching.com/datastore/favorites.svc/update?u=" + userToken + "&f=" + Boolean.toString(add); try { Network.completeWithSuccess(Network.postRequest(uri, null)); Log.i("GCParser.changeFavorite: cache added/removed to/from favorites"); cache.setFavorite(add); cache.setFavoritePoints(cache.getFavoritePoints() + (add ? 1 : -1)); return true; } catch (final Exception ignored) { Log.e("GCParser.changeFavorite: cache not added/removed to/from favorites"); return false; } } private static String getUserToken(@NonNull final Geocache cache) { return parseUserToken(requestHtmlPage(cache.getGeocode(), null, "n")); } private static String parseUserToken(final String page) { return TextUtils.getMatch(page, GCConstants.PATTERN_USERTOKEN, ""); } /** * Removes the cache from the favorites. * * This must not be called from the UI thread. * * @param cache * the cache to remove * @return {@code false} if an error occurred, {@code true} otherwise */ static boolean removeFromFavorites(@NonNull final Geocache cache) { return changeFavorite(cache, false); } /** * Parse a trackable HTML description into a Trackable object * * @param page * the HTML page to parse, already processed through {@link TextUtils#replaceWhitespace} * @return the parsed trackable, or null if none could be parsed */ static Trackable parseTrackable(final String page, final String possibleTrackingcode) { if (StringUtils.isBlank(page)) { Log.e("GCParser.parseTrackable: No page given"); return null; } if (page.contains(GCConstants.ERROR_TB_DOES_NOT_EXIST) || page.contains(GCConstants.ERROR_TB_ARITHMETIC_OVERFLOW) || page.contains(GCConstants.ERROR_TB_ELEMENT_EXCEPTION)) { return null; } final Trackable trackable = new Trackable(); trackable.forceSetBrand(TrackableBrand.TRAVELBUG); // trackable geocode trackable.setGeocode(TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_GEOCODE, true, StringUtils.upperCase(possibleTrackingcode))); if (trackable.getGeocode() == null) { Log.e("GCParser.parseTrackable: could not figure out trackable geocode"); return null; } // trackable id trackable.setGuid(TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_GUID, true, trackable.getGuid())); // trackable icon final String iconUrl = TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_ICON, true, trackable.getIconUrl()); trackable.setIconUrl(iconUrl.startsWith("/") ? "https://www.geocaching.com" + iconUrl : iconUrl); // trackable name trackable.setName(TextUtils.stripHtml(TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_NAME, true, ""))); // trackable type if (StringUtils.isNotBlank(trackable.getName())) { trackable.setType(TextUtils.stripHtml(TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_TYPE, true, trackable.getType()))); } // trackable owner name try { final MatcherWrapper matcherOwner = new MatcherWrapper(GCConstants.PATTERN_TRACKABLE_OWNER, page); if (matcherOwner.find()) { trackable.setOwnerGuid(matcherOwner.group(1)); trackable.setOwner(matcherOwner.group(2).trim()); } } catch (final RuntimeException e) { // failed to parse trackable owner name Log.w("GCParser.parseTrackable: Failed to parse trackable owner name", e); } // trackable origin trackable.setOrigin(TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_ORIGIN, true, trackable.getOrigin())); // trackable spotted try { final MatcherWrapper matcherSpottedCache = new MatcherWrapper(GCConstants.PATTERN_TRACKABLE_SPOTTEDCACHE, page); if (matcherSpottedCache.find()) { trackable.setSpottedGuid(matcherSpottedCache.group(1)); trackable.setSpottedName(matcherSpottedCache.group(2).trim()); trackable.setSpottedType(Trackable.SPOTTED_CACHE); } final MatcherWrapper matcherSpottedUser = new MatcherWrapper(GCConstants.PATTERN_TRACKABLE_SPOTTEDUSER, page); if (matcherSpottedUser.find()) { trackable.setSpottedGuid(matcherSpottedUser.group(1)); trackable.setSpottedName(matcherSpottedUser.group(2).trim()); trackable.setSpottedType(Trackable.SPOTTED_USER); } if (TextUtils.matches(page, GCConstants.PATTERN_TRACKABLE_SPOTTEDUNKNOWN)) { trackable.setSpottedType(Trackable.SPOTTED_UNKNOWN); } if (TextUtils.matches(page, GCConstants.PATTERN_TRACKABLE_SPOTTEDOWNER)) { trackable.setSpottedType(Trackable.SPOTTED_OWNER); } } catch (final RuntimeException e) { // failed to parse trackable last known place Log.w("GCParser.parseTrackable: Failed to parse trackable last known place", e); } // released date - can be missing on the page final String releaseString = TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_RELEASES, false, null); if (releaseString != null) { try { trackable.setReleased(DATE_TB_IN_1.parse(releaseString)); } catch (final ParseException ignored) { if (trackable.getReleased() == null) { try { trackable.setReleased(DATE_TB_IN_2.parse(releaseString)); } catch (final ParseException e) { Log.e("Could not parse trackable release " + releaseString, e); } } } } // trackable distance final String distance = TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_DISTANCE, false, null); if (distance != null) { try { trackable.setDistance(DistanceParser.parseDistance(distance, !Settings.useImperialUnits())); } catch (final NumberFormatException e) { Log.e("GCParser.parseTrackable: Failed to parse distance", e); } } // trackable goal trackable.setGoal(convertLinks(TextUtils.getMatch(page, GCConstants.PATTERN_TRACKABLE_GOAL, true, trackable.getGoal()))); // trackable details & image try { final MatcherWrapper matcherDetailsImage = new MatcherWrapper(GCConstants.PATTERN_TRACKABLE_DETAILSIMAGE, page); if (matcherDetailsImage.find()) { final String image = StringUtils.trim(matcherDetailsImage.group(3)); final String details = StringUtils.trim(matcherDetailsImage.group(4)); if (StringUtils.isNotEmpty(image)) { trackable.setImage(StringUtils.replace(image, "/display/", "/large/")); } if (StringUtils.isNotEmpty(details) && !StringUtils.equals(details, "No additional details available.")) { trackable.setDetails(convertLinks(details)); } } } catch (final RuntimeException e) { // failed to parse trackable details & image Log.w("GCParser.parseTrackable: Failed to parse trackable details & image", e); } if (StringUtils.isEmpty(trackable.getDetails()) && page.contains(GCConstants.ERROR_TB_NOT_ACTIVATED)) { trackable.setDetails(CgeoApplication.getInstance().getString(R.string.trackable_not_activated)); } // trackable may be locked if (page.contains(GCConstants.TRACKABLE_IS_LOCKED)) { trackable.setIsLocked(); } // trackable logs try { final MatcherWrapper matcherLogs = new MatcherWrapper(GCConstants.PATTERN_TRACKABLE_LOG, page); /* * 1. Type (image) * 2. Date * 3. Author * 4. Cache-GUID * 5. <ignored> (strike-through property for ancient caches) * 6. Cache-name * 7. Log text */ while (matcherLogs.find()) { long date = 0; try { date = GCLogin.parseGcCustomDate(matcherLogs.group(2)).getTime(); } catch (final ParseException ignored) { } final LogEntry.Builder logDoneBuilder = new LogEntry.Builder() .setAuthor(TextUtils.stripHtml(matcherLogs.group(3)).trim()) .setDate(date) .setLogType(LogType.getByIconName(matcherLogs.group(1))) .setLog(matcherLogs.group(7).trim()); if (matcherLogs.group(4) != null && matcherLogs.group(6) != null) { logDoneBuilder.setCacheGuid(matcherLogs.group(4)); logDoneBuilder.setCacheName(matcherLogs.group(6)); } // Apply the pattern for images in a trackable log entry against each full log (group(0)) final String logEntry = matcherLogs.group(0); final MatcherWrapper matcherLogImages = new MatcherWrapper(GCConstants.PATTERN_TRACKABLE_LOG_IMAGES, logEntry); /* * 1. Image URL * 2. Image title */ while (matcherLogImages.find()) { final Image logImage = new Image.Builder() .setUrl(matcherLogImages.group(1)) .setTitle(matcherLogImages.group(2)) .build(); logDoneBuilder.addLogImage(logImage); } trackable.getLogs().add(logDoneBuilder.build()); } } catch (final Exception e) { // failed to parse logs Log.w("GCParser.parseCache: Failed to parse cache logs", e); } // tracking code if (!StringUtils.equalsIgnoreCase(trackable.getGeocode(), possibleTrackingcode)) { trackable.setTrackingcode(possibleTrackingcode); } if (CgeoApplication.getInstance() != null) { DataStore.saveTrackable(trackable); } return trackable; } private static String convertLinks(final String input) { if (input == null) { return null; } return StringUtils.replace(input, "../", GCConstants.GC_URL); } private enum Logs { ALL(null), FRIENDS("sf"), OWN("sp"); final String paramName; Logs(final String paramName) { this.paramName = paramName; } private String getParamName() { return paramName; } } /** * Extract special logs (friends, own) through separate request. * * @param userToken * the user token extracted from the web page * @param logType * the logType to request * @return Observable<LogEntry> The logs */ private static Observable<LogEntry> getLogs(final String userToken, final Logs logType) { if (userToken.isEmpty()) { Log.e("GCParser.getLogs: unable to extract userToken"); return Observable.empty(); } return Observable.defer(new Callable<Observable<LogEntry>>() { @Override public Observable<LogEntry> call() { final Parameters params = new Parameters( "tkn", userToken, "idx", "1", "num", String.valueOf(GCConstants.NUMBER_OF_LOGS), "decrypt", "false"); // fetch encrypted logs as such if (logType != Logs.ALL) { params.add(logType.getParamName(), Boolean.toString(Boolean.TRUE)); } try { final InputStream responseStream = Network.getResponseStream(Network.getRequest("https://www.geocaching.com/seek/geocache.logbook", params)); if (responseStream == null) { Log.w("getLogs: no logs were returned"); return Observable.empty(); } return parseLogsAndClose(logType != Logs.ALL, responseStream); } catch (final Exception e) { Log.w("unable to read logs", e); return Observable.empty(); } } }).subscribeOn(AndroidRxUtils.networkScheduler); } private static Observable<LogEntry> parseLogsAndClose(final boolean markAsFriendsLog, @Nonnull final InputStream responseStream) { return Observable.create(new ObservableOnSubscribe<LogEntry>() { @Override public void subscribe(final ObservableEmitter<LogEntry> emitter) throws Exception { try { final ObjectNode resp = (ObjectNode) JsonUtils.reader.readTree(responseStream); if (!resp.path("status").asText().equals("success")) { Log.w("GCParser.parseLogsAndClose: status is " + resp.path("status").asText("[absent]")); emitter.onComplete(); return; } final ArrayNode data = (ArrayNode) resp.get("data"); for (final JsonNode entry: data) { final String logType = entry.path("LogType").asText(); final long date; try { date = GCLogin.parseGcCustomDate(entry.get("Visited").asText()).getTime(); } catch (ParseException | NullPointerException e) { Log.e("Failed to parse log date", e); continue; } // TODO: we should update our log data structure to be able to record // proper coordinates, and make them clickable. In the meantime, it is // better to integrate those coordinates into the text rather than not // display them at all. final String latLon = entry.path("LatLonString").asText(); final String logText = (StringUtils.isEmpty(latLon) ? "" : (latLon + "<br/><br/>")) + TextUtils.removeControlCharacters(entry.path("LogText").asText()); final LogEntry.Builder logDoneBuilder = new LogEntry.Builder() .setAuthor(TextUtils.removeControlCharacters(entry.path("UserName").asText())) .setDate(date) .setLogType(LogType.getByType(logType)) .setLog(logText) .setFound(entry.path("GeocacheFindCount").asInt()) .setFriend(markAsFriendsLog); final ArrayNode images = (ArrayNode) entry.get("Images"); for (final JsonNode image: images) { final String url = "https://imgcdn.geocaching.com/cache/log/large/" + image.path("FileName").asText(); final String title = TextUtils.removeControlCharacters(image.path("Name").asText()); String description = image.path("Descr").asText(); if (StringUtils.contains(description, "Geocaching®") && description.length() < 60) { description = null; } final Image logImage = new Image.Builder().setUrl(url).setTitle(title).setDescription(description).build(); logDoneBuilder.addLogImage(logImage); } emitter.onNext(logDoneBuilder.build()); } } catch (final IOException e) { Log.w("Failed to parse cache logs", e); } finally { IOUtils.closeQuietly(responseStream); } emitter.onComplete(); } }); } @NonNull static List<LogType> parseTypes(final String page) { if (StringUtils.isEmpty(page)) { return Collections.emptyList(); } final List<LogType> types = new ArrayList<>(); final MatcherWrapper typeBoxMatcher = new MatcherWrapper(GCConstants.PATTERN_TYPEBOX, page); if (typeBoxMatcher.find()) { final String typesText = typeBoxMatcher.group(1); final MatcherWrapper typeMatcher = new MatcherWrapper(GCConstants.PATTERN_TYPE2, typesText); while (typeMatcher.find()) { try { final int type = Integer.parseInt(typeMatcher.group(2)); if (type > 0) { types.add(LogType.getById(type)); } } catch (final NumberFormatException e) { Log.e("Error parsing log types", e); } } } // we don't support this log type types.remove(LogType.UPDATE_COORDINATES); return types; } @NonNull public static List<LogTypeTrackable> parseLogTypesTrackables(final String page) { if (StringUtils.isEmpty(page)) { return new ArrayList<>(); } final List<LogTypeTrackable> types = new ArrayList<>(); final MatcherWrapper typeBoxMatcher = new MatcherWrapper(GCConstants.PATTERN_TYPEBOX, page); if (typeBoxMatcher.find()) { final String typesText = typeBoxMatcher.group(1); final MatcherWrapper typeMatcher = new MatcherWrapper(GCConstants.PATTERN_TYPE2, typesText); while (typeMatcher.find()) { try { final int type = Integer.parseInt(typeMatcher.group(2)); if (type > 0) { types.add(LogTypeTrackable.getById(type)); } } catch (final NumberFormatException e) { Log.e("Error parsing trackable log types", e); } } } return types; } static List<TrackableLog> parseTrackableLog(final String page) { if (StringUtils.isEmpty(page)) { return Collections.emptyList(); } String table = StringUtils.substringBetween(page, "<table id=\"tblTravelBugs\"", "</table>"); // if no trackables are currently in the account, the table is not available, so return an empty list instead of null if (StringUtils.isBlank(table)) { return Collections.emptyList(); } table = StringUtils.substringBetween(table, "<tbody>", "</tbody>"); if (StringUtils.isBlank(table)) { Log.e("GCParser.parseTrackableLog: tbody not found on page"); return Collections.emptyList(); } final List<TrackableLog> trackableLogs = new ArrayList<>(); final MatcherWrapper trackableMatcher = new MatcherWrapper(GCConstants.PATTERN_TRACKABLE, page); while (trackableMatcher.find()) { final String trackCode = trackableMatcher.group(1); final String name = TextUtils.stripHtml(trackableMatcher.group(2)); try { final Integer ctl = Integer.valueOf(trackableMatcher.group(3)); final Integer id = Integer.valueOf(trackableMatcher.group(5)); if (trackCode != null && ctl != null && id != null) { final TrackableLog entry = new TrackableLog("", trackCode, name, id, ctl, TrackableBrand.TRAVELBUG); Log.i("Trackable in inventory (#" + entry.ctl + "/" + entry.id + "): " + entry.trackCode + " - " + entry.name); trackableLogs.add(entry); } } catch (final NumberFormatException e) { Log.e("GCParser.parseTrackableLog", e); } } return trackableLogs; } /** * Insert the right cache type restriction in parameters * * @param params * the parameters to insert the restriction into * @param cacheType * the type of cache, or null to include everything */ private static void insertCacheType(final Parameters params, final CacheType cacheType) { params.put("tx", cacheType.guid); } private static void getExtraOnlineInfo(@NonNull final Geocache cache, final String page, final DisposableHandler handler) { // This method starts the page parsing for logs in the background, as well as retrieve the friends and own logs // if requested. It merges them and stores them in the background, while the rating is retrieved if needed and // stored. Then we wait for the log merging and saving to be completed before returning. if (DisposableHandler.isDisposed(handler)) { return; } DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_logs); final String userToken = parseUserToken(page); final Observable<LogEntry> logs = getLogs(userToken, Logs.ALL); final Observable<LogEntry> ownLogs = getLogs(userToken, Logs.OWN).cache(); final Observable<LogEntry> specialLogs = Settings.isFriendLogsWanted() ? Observable.merge(getLogs(userToken, Logs.FRIENDS), ownLogs) : Observable.<LogEntry>empty(); final Single<List<LogEntry>> mergedLogs = Single.zip(logs.toList(), specialLogs.toList(), new BiFunction<List<LogEntry>, List<LogEntry>, List<LogEntry>>() { @Override public List<LogEntry> apply(final List<LogEntry> logEntries, final List<LogEntry> specialLogEntries) { mergeFriendsLogs(logEntries, specialLogEntries); return logEntries; } }).cache(); mergedLogs.subscribe(new Consumer<List<LogEntry>>() { @Override public void accept(final List<LogEntry> logEntries) { DataStore.saveLogs(cache.getGeocode(), logEntries); } }); if (cache.isFound() && cache.getVisitedDate() == 0) { ownLogs.subscribe(new Consumer<LogEntry>() { @Override public void accept(final LogEntry logEntry) { if (logEntry.getType().isFoundLog()) { cache.setVisitedDate(logEntry.date); } } }); } if (Settings.isRatingWanted() && !DisposableHandler.isDisposed(handler)) { DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_gcvote); final GCVoteRating rating = GCVote.getRating(cache.getGuid(), cache.getGeocode()); if (rating != null) { cache.setRating(rating.getRating()); cache.setVotes(rating.getVotes()); cache.setMyVote(rating.getMyVote()); } } // Wait for completion of logs parsing, retrieving and merging mergedLogs.toCompletable().blockingAwait(); } /** * Merge log entries and mark them as friends logs (personal and friends) to identify * them on friends/personal logs tab. * * @param mergedLogs * the list to merge logs with * @param logsToMerge * the list of logs to merge */ private static void mergeFriendsLogs(final List<LogEntry> mergedLogs, final Iterable<LogEntry> logsToMerge) { for (final LogEntry log : logsToMerge) { if (mergedLogs.contains(log)) { final LogEntry friendLog = mergedLogs.get(mergedLogs.indexOf(log)); final LogEntry updatedFriendLog = friendLog.buildUpon().setFriend(true).build(); mergedLogs.set(mergedLogs.indexOf(log), updatedFriendLog); } else { mergedLogs.add(log); } } } static boolean uploadModifiedCoordinates(@NonNull final Geocache cache, final Geopoint wpt) { return editModifiedCoordinates(cache, wpt); } static boolean deleteModifiedCoordinates(@NonNull final Geocache cache) { return editModifiedCoordinates(cache, null); } static boolean editModifiedCoordinates(@NonNull final Geocache cache, final Geopoint wpt) { final String userToken = getUserToken(cache); if (StringUtils.isEmpty(userToken)) { return false; } final ObjectNode jo = new ObjectNode(JsonUtils.factory); final ObjectNode dto = jo.putObject("dto").put("ut", userToken); if (wpt != null) { dto.putObject("data").put("lat", wpt.getLatitudeE6() / 1E6).put("lng", wpt.getLongitudeE6() / 1E6); } final String uriSuffix = wpt != null ? "SetUserCoordinate" : "ResetUserCoordinate"; final String uriPrefix = "https://www.geocaching.com/seek/cache_details.aspx/"; try { Network.completeWithSuccess(Network.postJsonRequest(uriPrefix + uriSuffix, jo)); Log.i("GCParser.editModifiedCoordinates - edited on GC.com"); return true; } catch (final Exception ignored) { Log.e("GCParser.deleteModifiedCoordinates - cannot delete modified coords"); return false; } } static boolean uploadPersonalNote(@NonNull final Geocache cache) { final String userToken = getUserToken(cache); if (StringUtils.isEmpty(userToken)) { return false; } final ObjectNode jo = new ObjectNode(JsonUtils.factory); jo.putObject("dto").put("et", StringUtils.defaultString(cache.getPersonalNote())).put("ut", userToken); final String uriSuffix = "SetUserCacheNote"; final String uriPrefix = "https://www.geocaching.com/seek/cache_details.aspx/"; try { Network.completeWithSuccess(Network.postJsonRequest(uriPrefix + uriSuffix, jo)); Log.i("GCParser.uploadPersonalNote - uploaded to GC.com"); return true; } catch (final Exception ignored) { Log.e("GCParser.uploadPersonalNote - cannot upload personal note"); return false; } } static boolean ignoreCache(@NonNull final Geocache cache) { final String uri = "https://www.geocaching.com/bookmarks/ignore.aspx?guid=" + cache.getGuid() + "&WptTypeID=" + cache.getType().wptTypeId; final String page = GCLogin.getInstance().postRequestLogged(uri, null); if (StringUtils.isBlank(page)) { Log.e("GCParser.ignoreCache: No data from server"); return false; } final String[] viewstates = GCLogin.getViewstates(page); final Parameters params = new Parameters( "__EVENTTARGET", "", "__EVENTARGUMENT", "", "ctl00$ContentBody$btnYes", "Yes. Ignore it."); GCLogin.putViewstates(params, viewstates); final String response = Network.getResponseData(Network.postRequest(uri, params)); return StringUtils.contains(response, "<p class=\"Success\">"); } public static int getFavoritePoints(final String page) { if (page != null) { final String favCount = TextUtils.getMatch(page, GCConstants.PATTERN_LOG_FAVORITE_POINTS, false, 1, null, true); if (favCount != null) { return Integer.parseInt(favCount); } } return 0; } }