package cgeo.geocaching.connector.gc; import cgeo.geocaching.CgeoApplication; import cgeo.geocaching.R; import cgeo.geocaching.connector.AbstractLogin; import cgeo.geocaching.enumerations.StatusCode; import cgeo.geocaching.network.HtmlImage; import cgeo.geocaching.network.Network; import cgeo.geocaching.network.Parameters; import cgeo.geocaching.settings.Credentials; import cgeo.geocaching.settings.Settings; import cgeo.geocaching.utils.Log; import cgeo.geocaching.utils.MatcherWrapper; import cgeo.geocaching.utils.TextUtils; import android.graphics.drawable.Drawable; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import java.io.IOException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.GregorianCalendar; import java.util.Locale; import io.reactivex.Observable; import io.reactivex.Single; import io.reactivex.functions.Consumer; import io.reactivex.functions.Function; import okhttp3.Response; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; public class GCLogin extends AbstractLogin { private static final String LOGIN_URI = "https://www.geocaching.com/account/login"; private static final String REQUEST_VERIFICATION_TOKEN = "__RequestVerificationToken"; private class StatusException extends RuntimeException { private static final long serialVersionUID = -597420116705938433L; final StatusCode statusCode; StatusException(final StatusCode statusCode) { super("Status code: " + statusCode); this.statusCode = statusCode; } } private GCLogin() { // singleton } public static GCLogin getInstance() { return SingletonHolder.INSTANCE; } private static class SingletonHolder { private static final GCLogin INSTANCE = new GCLogin(); } private static StatusCode resetGcCustomDate(final StatusCode statusCode) { Settings.setGcCustomDate(GCConstants.DEFAULT_GC_DATE); return statusCode; } @Override @NonNull protected StatusCode login(final boolean retry) { return login(retry, Settings.getCredentials(GCConnector.getInstance())); } @Override @NonNull protected StatusCode login(final boolean retry, @NonNull final Credentials credentials) { if (credentials.isInvalid()) { clearLoginInfo(); Log.w("Login.login: No login information stored"); return resetGcCustomDate(StatusCode.NO_LOGIN_INFO_STORED); } final String username = credentials.getUserName(); setActualStatus(CgeoApplication.getInstance().getString(R.string.init_login_popup_working)); try { final String tryLoggedInData = getLoginPage(); if (StringUtils.isBlank(tryLoggedInData)) { Log.w("Login.login: Failed to retrieve login page (1st)"); return StatusCode.CONNECTION_FAILED; // no login page } if (getLoginStatus(tryLoggedInData)) { Log.i("Already logged in Geocaching.com as " + username + " (" + Settings.getGCMemberStatus() + ')'); if (switchToEnglish(tryLoggedInData) && retry) { return login(false, credentials); } setHomeLocation(); refreshMemberStatus(); detectGcCustomDate(); return StatusCode.NO_ERROR; // logged in } final String requestVerificationToken = extractRequestVerificationToken(tryLoggedInData); if (StringUtils.isEmpty(requestVerificationToken)) { Log.w("GCLogin.login: failed to find request verification token"); return StatusCode.LOGIN_PARSE_ERROR; } final String loginData = postCredentials(credentials, requestVerificationToken); if (StringUtils.isBlank(loginData)) { Log.w("Login.login: Failed to retrieve login page (2nd)"); // FIXME: should it be CONNECTION_FAILED to match the first attempt? return StatusCode.COMMUNICATION_ERROR; // no login page } assert loginData != null; // Caught above if (getLoginStatus(loginData)) { if (switchToEnglish(loginData) && retry) { return login(false, credentials); } Log.i("Successfully logged in Geocaching.com as " + username + " (" + Settings.getGCMemberStatus() + ')'); setHomeLocation(); refreshMemberStatus(); detectGcCustomDate(); return StatusCode.NO_ERROR; // logged in } if (loginData.contains("your username or password is incorrect")) { Log.i("Failed to log in Geocaching.com as " + username + " because of wrong username/password"); return resetGcCustomDate(StatusCode.WRONG_LOGIN_DATA); // wrong login } if (loginData.contains("You must validate your account before you can log in.")) { Log.i("Failed to log in Geocaching.com as " + username + " because account needs to be validated first"); return resetGcCustomDate(StatusCode.UNVALIDATED_ACCOUNT); } Log.i("Failed to log in Geocaching.com as " + username + " for some unknown reason"); if (retry) { switchToEnglish(loginData); return login(false, credentials); } return resetGcCustomDate(StatusCode.UNKNOWN_ERROR); // can't login } catch (final StatusException status) { return status.statusCode; } catch (final Exception ignored) { Log.w("Login.login: communication error"); return StatusCode.CONNECTION_FAILED; } } public StatusCode logout() { try { getResponseBodyOrStatus(Network.postRequest("https://www.geocaching.com/account/logout", null).blockingGet()); } catch (final StatusException status) { return status.statusCode; } catch (final Exception ignored) { } resetLoginStatus(); return StatusCode.NO_ERROR; } private String getResponseBodyOrStatus(final Response response) { final String body; try { body = response.body().string(); } catch (final IOException ignore) { throw new StatusException(StatusCode.COMMUNICATION_ERROR); } if (response.code() == 503 && TextUtils.matches(body, GCConstants.PATTERN_MAINTENANCE)) { throw new StatusException(StatusCode.MAINTENANCE); } else if (!response.isSuccessful()) { throw new StatusException(StatusCode.COMMUNICATION_ERROR); } return body; } private String getLoginPage() { return getResponseBodyOrStatus(Network.getRequest(LOGIN_URI).blockingGet()); } @Nullable private String extractRequestVerificationToken(final String page) { final Document document = Jsoup.parse(page); final String value = document.select(".login > form > input[name=\"" + REQUEST_VERIFICATION_TOKEN + "\"]").attr("value"); return StringUtils.isNotEmpty(value) ? value : null; } private String postCredentials(final Credentials credentials, final String requestVerificationToken) { final Parameters params = new Parameters("Username", credentials.getUserName(), "Password", credentials.getPassword(), REQUEST_VERIFICATION_TOKEN, requestVerificationToken); return getResponseBodyOrStatus(Network.postRequest(LOGIN_URI, params).blockingGet()); } private static String removeDotAndComma(final String str) { return StringUtils.replaceChars(str, ".,", null); } /** * Check if the user has been logged in when he retrieved the data. * * @return {@code true} if user is logged in, {@code false} otherwise */ boolean getLoginStatus(@Nullable final String page) { if (StringUtils.isBlank(page)) { Log.w("Login.checkLogin: No page given"); return false; } assert page != null; if (TextUtils.matches(page, GCConstants.PATTERN_MAP_LOGGED_IN)) { return true; } setActualStatus(CgeoApplication.getInstance().getString(R.string.init_login_popup_ok)); // on every page except login page setActualLoginStatus(TextUtils.matches(page, GCConstants.PATTERN_LOGIN_NAME)); if (isActualLoginStatus()) { setActualUserName(TextUtils.stripHtml(TextUtils.getMatch(page, GCConstants.PATTERN_LOGIN_NAME, true, "???"))); int cachesCount = 0; try { cachesCount = Integer.parseInt(removeDotAndComma(TextUtils.getMatch(page, GCConstants.PATTERN_CACHES_FOUND, true, "0"))); } catch (final NumberFormatException e) { Log.e("getLoginStatus: bad cache count", e); } setActualCachesFound(cachesCount); return true; } // login page setActualLoginStatus(TextUtils.matches(page, GCConstants.PATTERN_LOGIN_NAME_LOGIN_PAGE)); if (isActualLoginStatus()) { setActualUserName(Settings.getUserName()); // number of caches found is not part of this page return true; } setActualStatus(CgeoApplication.getInstance().getString(R.string.init_login_popup_failed)); return false; } private boolean isLanguageEnglish(@NonNull final String page) { final Element languageElement = Jsoup.parse(page).select("div.language-dropdown > select > option[selected=\"selected\"]").first(); return languageElement != null && StringUtils.equals(languageElement.text(), "English"); } /** * Ensure that the web site is in English. * * @param previousPage * the content of the last loaded page * @return {@code true} if a switch was necessary and successfully performed (non-English -> English) */ private boolean switchToEnglish(final String previousPage) { if (previousPage != null && isLanguageEnglish(previousPage)) { Log.i("Geocaching.com language already set to English"); // get find count getLoginStatus(previousPage); } else { try { final String page = Network.getResponseData(Network.getRequest("https://www.geocaching.com/play/culture/set?model.SelectedCultureCode=en-US")); Log.i("changed language on geocaching.com to English"); getLoginStatus(page); return true; } catch (final Exception ignored) { Log.e("Failed to set geocaching.com language to English"); } } return false; } /** * Retrieve avatar url from GC * * @return the avatar url */ public String getAvatarUrl() { try { final String responseData = StringUtils.defaultString(Network.getResponseData(Network.getRequest("https://www.geocaching.com/my/"))); final String profile = TextUtils.replaceWhitespace(responseData); setActualCachesFound(Integer.parseInt(removeDotAndComma(TextUtils.getMatch(profile, GCConstants.PATTERN_CACHES_FOUND, true, "-1")))); final String avatarURL = TextUtils.getMatch(profile, GCConstants.PATTERN_AVATAR_IMAGE_PROFILE_PAGE, false, null); if (avatarURL != null) { return avatarURL.replace("avatar", "user/large"); } // No match? There may be no avatar set by user. Log.d("No avatar set for user"); } catch (final Exception e) { Log.w("Error when retrieving user avatar url", e); } return StringUtils.EMPTY; } /** * Download the avatar * * @return the avatar drawable */ public Observable<Drawable> downloadAvatar() { try { final String avatarURL = getAvatarUrl(); if (!avatarURL.isEmpty()) { final HtmlImage imgGetter = new HtmlImage(HtmlImage.SHARED, false, false, false); return imgGetter.fetchDrawable(avatarURL).cast(Drawable.class); } } catch (final Exception e) { Log.w("Error when retrieving user avatar", e); } return null; } /** * Retrieve the home location * * @return a Single containing the home location, or IOException */ static Single<String> retrieveHomeLocation() { return Network.getResponseDocument(Network.getRequest("https://www.geocaching.com/account/settings/homelocation")) .map(new Function<Document, String>() { @Override public String apply(final Document document) { return document.select("input.search-coordinates").attr("value"); } }); } private static void setHomeLocation() { retrieveHomeLocation().subscribe(new Consumer<String>() { @Override public void accept(final String homeLocationStr) throws Exception { if (StringUtils.isNotBlank(homeLocationStr) && !StringUtils.equals(homeLocationStr, Settings.getHomeLocation())) { assert homeLocationStr != null; Log.i("Setting home location to " + homeLocationStr); Settings.setHomeLocation(homeLocationStr); } } }, new Consumer<Throwable>() { @Override public void accept(final Throwable throwable) throws Exception { Log.w("Unable to retrieve the home location"); } }); } private static void refreshMemberStatus() { Network.getResponseDocument(Network.getRequest("https://www.geocaching.com/account/settings/membership")) .subscribe(new Consumer<Document>() { @Override public void accept(final Document document) throws Exception { final Element membership = document.select("dl.membership-details > dd:eq(3)").first(); if (membership != null) { final GCMemberState memberState = GCMemberState.fromString(membership.text()); Log.d("Setting member status to " + memberState); Settings.setGCMemberStatus(memberState); } else { Log.w("Cannot determine member status"); } } }, new Consumer<Throwable>() { @Override public void accept(final Throwable throwable) throws Exception { Log.w("Unable to retrieve member status", throwable); } }); } /** * Detect user date settings on geocaching.com */ private static void detectGcCustomDate() { try { final Document document = Network.getResponseDocument(Network.getRequest("https://www.geocaching.com/account/settings/preferences")).blockingGet(); final String customDate = document.select("select#SelectedDateFormat option[selected]").attr("value"); if (StringUtils.isNotBlank(customDate)) { Log.d("Setting GC custom date to " + customDate); Settings.setGcCustomDate(customDate); } else { Settings.setGcCustomDate(GCConstants.DEFAULT_GC_DATE); Log.w("cannot find custom date format in geocaching.com preferences page, using default"); } } catch (final Exception e) { Settings.setGcCustomDate(GCConstants.DEFAULT_GC_DATE); Log.w("cannot set custom date from geocaching.com preferences page, using default", e); } } public static Date parseGcCustomDate(final String input, final String format) throws ParseException { return new SimpleDateFormat(format, Locale.ENGLISH).parse(input.trim()); } static Date parseGcCustomDate(final String input) throws ParseException { return parseGcCustomDate(input, Settings.getGcCustomDate()); } static String formatGcCustomDate(final int year, final int month, final int day) { return new SimpleDateFormat(Settings.getGcCustomDate(), Locale.ENGLISH).format(new GregorianCalendar(year, month - 1, day).getTime()); } /** * checks if an Array of Strings is empty or not. Empty means: * - Array is null * - or all elements are null or empty strings */ public static boolean isEmpty(final String[] a) { if (a == null) { return true; } for (final String s : a) { if (StringUtils.isNotEmpty(s)) { return false; } } return true; } /** * read all viewstates from page * * @return String[] with all view states */ public static String[] getViewstates(final String page) { // Get the number of viewstates. // If there is only one viewstate, __VIEWSTATEFIELDCOUNT is not present if (page == null) { // no network access return null; } int count = 1; final MatcherWrapper matcherViewstateCount = new MatcherWrapper(GCConstants.PATTERN_VIEWSTATEFIELDCOUNT, page); if (matcherViewstateCount.find()) { try { count = Integer.parseInt(matcherViewstateCount.group(1)); } catch (final NumberFormatException e) { Log.e("getViewStates", e); } } final String[] viewstates = new String[count]; // Get the viewstates final MatcherWrapper matcherViewstates = new MatcherWrapper(GCConstants.PATTERN_VIEWSTATES, page); while (matcherViewstates.find()) { final String sno = matcherViewstates.group(1); // number of viewstate int no; if (StringUtils.isEmpty(sno)) { no = 0; } else { try { no = Integer.parseInt(sno); } catch (final NumberFormatException e) { Log.e("getViewStates", e); no = 0; } } viewstates[no] = matcherViewstates.group(2); } if (viewstates.length != 1 || viewstates[0] != null) { return viewstates; } // no viewstates were present return null; } /** * put viewstates into request parameters */ static void putViewstates(final Parameters params, final String[] viewstates) { if (ArrayUtils.isEmpty(viewstates)) { return; } params.put("__VIEWSTATE", viewstates[0]); if (viewstates.length > 1) { for (int i = 1; i < viewstates.length; i++) { params.put("__VIEWSTATE" + i, viewstates[i]); } params.put("__VIEWSTATEFIELDCOUNT", String.valueOf(viewstates.length)); } } /** * transfers the viewstates variables from a page (response) to parameters * (next request) */ static void transferViewstates(final String page, final Parameters params) { putViewstates(params, getViewstates(page)); } /** * POST HTTP request. Do the request a second time if the user is not logged in * */ String postRequestLogged(final String uri, final Parameters params) { final String data = Network.getResponseData(Network.postRequest(uri, params)); if (getLoginStatus(data)) { return data; } if (login() == StatusCode.NO_ERROR) { return Network.getResponseData(Network.postRequest(uri, params)); } Log.i("Working as guest."); return data; } /** * GET HTTP request. Do the request a second time if the user is not logged in * */ @Nullable String getRequestLogged(@NonNull final String uri, @Nullable final Parameters params) { try { final Response response = Network.getRequest(uri, params).blockingGet(); final String data = Network.getResponseData(response, canRemoveWhitespace(uri)); // A page not found will not be found if the user logs in either if (response.code() == 404 || getLoginStatus(data)) { return data; } if (login() == StatusCode.NO_ERROR) { return Network.getResponseData(Network.getRequest(uri, params), canRemoveWhitespace(uri)); } Log.w("Working as guest."); return data; } catch (final Exception ignored) { // FIXME: propagate the exception instead return null; } } /** * Unfortunately the cache details page contains user generated whitespace in the personal note, therefore we cannot * remove the white space from cache details pages. * */ private static boolean canRemoveWhitespace(final String uri) { return !StringUtils.contains(uri, "cache_details"); } /** * Get user session & session token from the Live Map. Needed for following requests. * * @return first is user session, second is session token */ @NonNull public MapTokens getMapTokens() { final String data = getRequestLogged(GCConstants.URL_LIVE_MAP, null); final String userSession = TextUtils.getMatch(data, GCConstants.PATTERN_USERSESSION, ""); final String sessionToken = TextUtils.getMatch(data, GCConstants.PATTERN_SESSIONTOKEN, ""); return new MapTokens(userSession, sessionToken); } }