package cgeo.geocaching.connector.gc;
import cgeo.geocaching.R;
import cgeo.geocaching.SearchResult;
import cgeo.geocaching.activity.ActivityMixin;
import cgeo.geocaching.connector.AbstractConnector;
import cgeo.geocaching.connector.ConnectorFactory;
import cgeo.geocaching.connector.ILoggingManager;
import cgeo.geocaching.connector.UserAction;
import cgeo.geocaching.connector.capability.FieldNotesCapability;
import cgeo.geocaching.connector.capability.ICredentials;
import cgeo.geocaching.connector.capability.ILogin;
import cgeo.geocaching.connector.capability.ISearchByCenter;
import cgeo.geocaching.connector.capability.ISearchByFinder;
import cgeo.geocaching.connector.capability.ISearchByGeocode;
import cgeo.geocaching.connector.capability.ISearchByKeyword;
import cgeo.geocaching.connector.capability.ISearchByNextPage;
import cgeo.geocaching.connector.capability.ISearchByOwner;
import cgeo.geocaching.connector.capability.ISearchByViewPort;
import cgeo.geocaching.connector.capability.IgnoreCapability;
import cgeo.geocaching.connector.capability.PersonalNoteCapability;
import cgeo.geocaching.connector.capability.PgcChallengeCheckerCapability;
import cgeo.geocaching.connector.capability.Smiley;
import cgeo.geocaching.connector.capability.SmileyCapability;
import cgeo.geocaching.connector.capability.WatchListCapability;
import cgeo.geocaching.enumerations.CacheType;
import cgeo.geocaching.enumerations.StatusCode;
import cgeo.geocaching.location.Geopoint;
import cgeo.geocaching.location.Viewport;
import cgeo.geocaching.log.LogCacheActivity;
import cgeo.geocaching.log.LogType;
import cgeo.geocaching.models.Geocache;
import cgeo.geocaching.network.Network;
import cgeo.geocaching.network.Parameters;
import cgeo.geocaching.settings.Credentials;
import cgeo.geocaching.settings.Settings;
import cgeo.geocaching.settings.SettingsActivity;
import cgeo.geocaching.storage.DataStore;
import cgeo.geocaching.utils.DisposableHandler;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.functions.Action1;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
public class GCConnector extends AbstractConnector implements ISearchByGeocode, ISearchByCenter, ISearchByNextPage, ISearchByViewPort, ISearchByKeyword, ILogin, ICredentials, ISearchByOwner, ISearchByFinder, FieldNotesCapability, IgnoreCapability, WatchListCapability, PersonalNoteCapability, SmileyCapability, PgcChallengeCheckerCapability {
@NonNull
private static final String CACHE_URL_SHORT = "https://coord.info/";
// Double slash is used to force open in browser
@NonNull
private static final String CACHE_URL_LONG = "https://www.geocaching.com/seek/cache_details.aspx?wp=";
/**
* Pocket queries downloaded from the website use a numeric prefix. The pocket query creator Android app adds a
* verbatim "pocketquery" prefix.
*/
@NonNull
private static final Pattern GPX_ZIP_FILE_PATTERN = Pattern.compile("((\\d{7,})|(pocketquery))" + "(_.+)?" + "\\.zip", Pattern.CASE_INSENSITIVE);
/**
* Pattern for GC codes
*/
@NonNull
private static final Pattern PATTERN_GC_CODE = Pattern.compile("GC[0-9A-Z&&[^ILOSU]]+", Pattern.CASE_INSENSITIVE);
private GCConnector() {
// singleton
}
/**
* initialization on demand holder pattern
*/
private static class Holder {
@NonNull private static final GCConnector INSTANCE = new GCConnector();
}
@NonNull
public static GCConnector getInstance() {
return Holder.INSTANCE;
}
@Override
public boolean canHandle(@NonNull final String geocode) {
return PATTERN_GC_CODE.matcher(geocode).matches();
}
@Override
@NonNull
public String getLongCacheUrl(@NonNull final Geocache cache) {
return CACHE_URL_LONG + cache.getGeocode();
}
@Override
@NonNull
public String getCacheUrl(@NonNull final Geocache cache) {
return CACHE_URL_SHORT + cache.getGeocode();
}
@Override
public boolean canAddPersonalNote(@NonNull final Geocache cache) {
return Settings.isGCPremiumMember();
}
@Override
public boolean supportsOwnCoordinates() {
return true;
}
@Override
public boolean canAddToWatchList(@NonNull final Geocache cache) {
return true;
}
@Override
public boolean supportsLogging() {
return true;
}
@Override
public boolean supportsLogImages() {
return true;
}
@Override
@NonNull
public ILoggingManager getLoggingManager(@NonNull final LogCacheActivity activity, @NonNull final Geocache cache) {
return new GCLoggingManager(activity, cache);
}
@Override
public boolean canLog(@NonNull final Geocache cache) {
return StringUtils.isNotBlank(cache.getCacheId());
}
@Override
@NonNull
public String getName() {
return "geocaching.com";
}
@Override
@NonNull
public String getNameAbbreviated() {
return "GC";
}
@Override
@NonNull
public String getHost() {
return "www.geocaching.com";
}
@Override
@NonNull
public String getTestUrl() {
return "https://" + getHost() + "/play";
}
@Override
public SearchResult searchByGeocode(@Nullable final String geocode, @Nullable final String guid, final DisposableHandler handler) {
DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_loadpage);
final String page = GCParser.requestHtmlPage(geocode, guid, "y");
if (StringUtils.isEmpty(page)) {
final SearchResult search = new SearchResult();
if (DataStore.isThere(geocode, guid, false)) {
if (StringUtils.isBlank(geocode) && StringUtils.isNotBlank(guid)) {
Log.i("Loading old cache from cache.");
search.addGeocode(DataStore.getGeocodeForGuid(guid));
} else {
search.addGeocode(geocode);
}
search.setError(StatusCode.NO_ERROR);
return search;
}
Log.e("GCConnector.searchByGeocode: No data from server");
search.setError(StatusCode.COMMUNICATION_ERROR);
return search;
}
assert page != null;
final SearchResult searchResult = GCParser.parseCache(page, handler);
if (searchResult == null || CollectionUtils.isEmpty(searchResult.getGeocodes())) {
Log.w("GCConnector.searchByGeocode: No cache parsed");
return searchResult;
}
// do not filter when searching for one specific cache
return searchResult;
}
@Override
public SearchResult searchByNextPage(final SearchResult search) {
return GCParser.searchByNextPage(search);
}
@Override
@NonNull
public SearchResult searchByViewport(@NonNull final Viewport viewport, @Nullable final MapTokens tokens) {
return GCMap.searchByViewport(viewport, tokens);
}
@Override
public boolean isZippedGPXFile(@NonNull final String fileName) {
return GPX_ZIP_FILE_PATTERN.matcher(fileName).matches();
}
@Override
public boolean isReliableLatLon(final boolean cacheHasReliableLatLon) {
return cacheHasReliableLatLon;
}
@Override
public boolean isOwner(@NonNull final Geocache cache) {
final String user = Settings.getUserName();
return StringUtils.isNotEmpty(user) && StringUtils.equalsIgnoreCase(cache.getOwnerUserId(), user);
}
@Override
public boolean addToWatchlist(@NonNull final Geocache cache) {
final boolean added = GCParser.addToWatchlist(cache);
if (added) {
DataStore.saveChangedCache(cache);
}
return added;
}
@Override
public boolean removeFromWatchlist(@NonNull final Geocache cache) {
final boolean removed = GCParser.removeFromWatchlist(cache);
if (removed) {
DataStore.saveChangedCache(cache);
}
return removed;
}
/**
* Add a cache to the favorites list.
*
* This must not be called from the UI thread.
*
* @param cache
* the cache to add
* @return {@code true} if the cache was successfully added, {@code false} otherwise
*/
public static boolean addToFavorites(final Geocache cache) {
final boolean added = GCParser.addToFavorites(cache);
if (added) {
DataStore.saveChangedCache(cache);
}
return added;
}
/**
* Remove a cache from the favorites list.
*
* This must not be called from the UI thread.
*
* @param cache
* the cache to add
* @return {@code true} if the cache was successfully added, {@code false} otherwise
*/
public static boolean removeFromFavorites(final Geocache cache) {
final boolean removed = GCParser.removeFromFavorites(cache);
if (removed) {
DataStore.saveChangedCache(cache);
}
return removed;
}
@Override
public boolean uploadModifiedCoordinates(@NonNull final Geocache cache, @NonNull final Geopoint wpt) {
final boolean uploaded = GCParser.uploadModifiedCoordinates(cache, wpt);
if (uploaded) {
DataStore.saveChangedCache(cache);
}
return uploaded;
}
@Override
public boolean deleteModifiedCoordinates(@NonNull final Geocache cache) {
final boolean deleted = GCParser.deleteModifiedCoordinates(cache);
if (deleted) {
DataStore.saveChangedCache(cache);
}
return deleted;
}
@Override
public boolean uploadPersonalNote(@NonNull final Geocache cache) {
final boolean uploaded = GCParser.uploadPersonalNote(cache);
if (uploaded) {
DataStore.saveChangedCache(cache);
}
return uploaded;
}
@Override
public SearchResult searchByCenter(@NonNull final Geopoint center) {
return GCParser.searchByCoords(center, Settings.getCacheType());
}
@Override
public boolean supportsFavoritePoints(@NonNull final Geocache cache) {
return !cache.getType().isEvent();
}
@Override
public boolean supportsAddToFavorite(final Geocache cache, final LogType type) {
return cache.supportsFavoritePoints() && Settings.isGCPremiumMember() && !cache.isOwner() && type.isFoundLog();
}
@Override
@NonNull
protected String getCacheUrlPrefix() {
return StringUtils.EMPTY; // UNUSED
}
@Override
@Nullable
public String getGeocodeFromUrl(@NonNull final String url) {
final String noQueryString = StringUtils.substringBefore(url, "?");
// coord.info URLs
final String afterCoord = StringUtils.substringAfterLast(noQueryString, "coord.info/");
if (canHandle(afterCoord)) {
return afterCoord;
}
// expanded geocaching.com URLs
final String afterGeocache = StringUtils.substringBetween(noQueryString, "/geocache/", "_");
if (afterGeocache != null && canHandle(afterGeocache)) {
return afterGeocache;
}
return null;
}
@Override
public boolean isActive() {
return Settings.isGCConnectorActive();
}
@Override
public int getCacheMapMarkerId(final boolean disabled) {
if (disabled) {
return R.drawable.marker_disabled;
}
return R.drawable.marker;
}
@Override
public boolean login(final Handler handler, @Nullable final Activity fromActivity) {
// login
final StatusCode status = GCLogin.getInstance().login();
if (ConnectorFactory.showLoginToast && handler != null) {
handler.sendMessage(handler.obtainMessage(0, status));
ConnectorFactory.showLoginToast = false;
// invoke settings activity to insert login details
if (status == StatusCode.NO_LOGIN_INFO_STORED && fromActivity != null) {
SettingsActivity.openForScreen(R.string.preference_screen_gc, fromActivity);
}
}
return status == StatusCode.NO_ERROR;
}
@Override
public void logout() {
GCLogin.getInstance().logout();
}
@Override
public String getUserName() {
return GCLogin.getInstance().getActualUserName();
}
@Override
public Credentials getCredentials() {
return Settings.getCredentials(R.string.pref_username, R.string.pref_password);
}
@Override
public int getCachesFound() {
return GCLogin.getInstance().getActualCachesFound();
}
@Override
public String getLoginStatusString() {
return GCLogin.getInstance().getActualStatus();
}
@Override
public boolean isLoggedIn() {
return GCLogin.getInstance().isActualLoginStatus();
}
@Override
public String getWaypointGpxId(final String prefix, @NonNull final String geocode) {
String gpxId = prefix;
if (StringUtils.isNotBlank(geocode) && geocode.length() > 2) {
gpxId += geocode.substring(2);
}
return gpxId;
}
@Override
@NonNull
public String getWaypointPrefix(final String name) {
String prefix = name;
if (StringUtils.isNotBlank(prefix) && prefix.length() >= 2) {
prefix = name.substring(0, 2);
}
return prefix;
}
@Override
public SearchResult searchByKeyword(@NonNull final String keyword) {
return GCParser.searchByKeyword(keyword, Settings.getCacheType());
}
@Override
public int getUsernamePreferenceKey() {
return R.string.pref_username;
}
@Override
public int getPasswordPreferenceKey() {
return R.string.pref_password;
}
@Override
public int getAvatarPreferenceKey() {
return R.string.pref_gc_avatar;
}
@NonNull
@Override
public List<UserAction> getUserActions() {
final List<UserAction> actions = super.getUserActions();
actions.add(new UserAction(R.string.user_menu_open_browser, new Action1<UserAction.Context>() {
@Override
public void call(final UserAction.Context context) {
context.activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.geocaching.com/profile/?u=" + Network.encode(context.userName))));
}
}));
actions.add(new UserAction(R.string.user_menu_send_message, new Action1<UserAction.Context>() {
@Override
public void call(final UserAction.Context context) {
try {
context.activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.geocaching.com/email/?u=" + Network.encode(context.userName))));
} catch (final ActivityNotFoundException e) {
Log.e("Cannot find suitable activity", e);
ActivityMixin.showToast(context.activity, R.string.err_application_no);
}
}
}));
return actions;
}
@Override
public SearchResult searchByOwner(@NonNull final String username) {
return GCParser.searchByOwner(username, Settings.getCacheType());
}
@Override
public SearchResult searchByFinder(@NonNull final String username) {
return GCParser.searchByUsername(username, Settings.getCacheType());
}
@Override
public boolean uploadFieldNotes(@NonNull final File exportFile) {
if (!GCLogin.getInstance().isActualLoginStatus()) {
// no need to upload (possibly large file) if we're not logged in
final StatusCode loginState = GCLogin.getInstance().login();
if (loginState != StatusCode.NO_ERROR) {
Log.e("FieldNoteExport.ExportTask upload: Login failed");
return false;
}
}
final String uri = "https://www.geocaching.com/my/uploadfieldnotes.aspx";
final String page = GCLogin.getInstance().getRequestLogged(uri, null);
if (StringUtils.isBlank(page)) {
Log.e("FieldNoteExport.ExportTask get page: No data from server");
return false;
}
final String[] viewstates = GCLogin.getViewstates(page);
final Parameters uploadParams = new Parameters(
"__EVENTTARGET", "",
"__EVENTARGUMENT", "",
"ctl00$ContentBody$btnUpload", "Upload Field Note");
GCLogin.putViewstates(uploadParams, viewstates);
Network.getResponseData(Network.postRequest(uri, uploadParams, "ctl00$ContentBody$FieldNoteLoader", "text/plain", exportFile));
if (StringUtils.isBlank(page)) {
Log.e("FieldNoteExport.ExportTask upload: No data from server");
return false;
}
return true;
}
@Override
public boolean canIgnoreCache(@NonNull final Geocache cache) {
return StringUtils.isNotEmpty(cache.getType().wptTypeId) && Settings.isGCPremiumMember();
}
@Override
public void ignoreCache(@NonNull final Geocache cache) {
GCParser.ignoreCache(cache);
}
@Override
@Nullable
public String getCreateAccountUrl() {
return "https://www.geocaching.com/account/register";
}
@Override
public List<Smiley> getSmileys() {
return GCSmileysProvider.getSmileys();
}
@Override
public boolean isChallengeCache(@NonNull final Geocache cache) {
return cache.getType() == CacheType.MYSTERY && StringUtils.containsIgnoreCase(cache.getName(), "challenge");
}
@Override
public List<LogType> getPossibleLogTypes(final Geocache geocache) {
final List<LogType> result = super.getPossibleLogTypes(geocache);
// since May 2017 finding own caches is not allowed (except for events)
if (geocache.isOwner()) {
result.removeAll(Arrays.asList(LogType.FOUND_IT, LogType.DIDNT_FIND_IT, LogType.WEBCAM_PHOTO_TAKEN, LogType.NEEDS_ARCHIVE, LogType.NEEDS_MAINTENANCE));
}
// since May 2017 only one found log is allowed
if (geocache.isFound()) {
result.removeAll(Arrays.asList(LogType.FOUND_IT, LogType.ATTENDED, LogType.WEBCAM_PHOTO_TAKEN));
}
return result;
}
}