package com.awsmithson.tcx2nikeplus.http;
import com.awsmithson.tcx2nikeplus.jaxb.JAXBObject;
import com.awsmithson.tcx2nikeplus.nike.NikeActivityData;
import com.awsmithson.tcx2nikeplus.nike.NikePlusSyncData;
import com.awsmithson.tcx2nikeplus.util.Log;
import com.awsmithson.tcx2nikeplus.util.Util;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.gson.Gson;
import com.topografix.gpx._1._1.ObjectFactory;
import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.CookieStore;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.cookie.Cookie;
import org.apache.http.cookie.SetCookie;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.cookie.BasicClientCookie;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.logging.Level;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.xml.bind.JAXBException;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
public class NikePlus {
private static final @Nonnull Log logger = Log.getInstance();
// Load "nikeplus.properties" file.
private static final @Nonnull Properties nikePlusProperties = new Properties();
static {
String propertiesFile = "/nikeplus.properties";
logger.out(Level.FINER, "loading %s", propertiesFile);
try (InputStream inputStream = NikePlus.class.getResourceAsStream(propertiesFile)) {
nikePlusProperties.load(inputStream);
}
catch (IOException ioe) {
throw new ExceptionInInitializerError(ioe);
}
}
private static final @Nonnull String URL_LOGIN_DOMAIN = "secure-nikeplus.nike.com";
private static final @Nonnull String URL_LOGIN = String.format("https://%s/login/loginViaNike.do?mode=login", URL_LOGIN_DOMAIN);
private static final @Nonnull String URL_DATA_SYNC = "https://api.nike.com/v2.0/me/sync?access_token=%s";
private static final @Nonnull String URL_DATA_SYNC_COMPLETE_ACCESS_TOKEN = "https://api.nike.com/v2.0/me/sync/complete";
private static final @Nonnull String USER_AGENT = "NPConnect";
public static final @Nonnull String INVALID_PASSWORD_ERROR_MESSAGE = "Nike+'s upload service does not support passwords containing the following characters:" +
"<pre>\" & ' < ></pre>" +
"<br />Please change your password on the Nike+ website, apologies this is out of my control";
private static final int URL_DATA_SYNC_SUCCESS = HttpStatus.SC_OK;
// From http://support-en-us.nikeplus.com/app/answers/detail/a_id/31352/p/3169,3195
// If you receive a notification that "Your Email or Password Was Entered Incorrectly", make sure your password
// does not contain a greater than symbol, ampersand or apostrophe (>, & or `). If your password contains any of
// these symbols, reset your password.
// That message seems to be incorrect. In reality, they are unable to process the "Predefined entities in XML":
// http://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references#Predefined_entities_in_XML
private static final @Nonnull Pattern INVALID_NIKEPLUS_PASSWORD = Pattern.compile("[\"&'<>]");
private static final @Nonnull Predicate<char[]> PASSWORD_INVALID = new Predicate<char[]>() {
@Override
public boolean apply(@Nullable char[] password) {
return password == null || INVALID_NIKEPLUS_PASSWORD.matcher(new String(password)).find();
}
};
static @Nonnull UrlEncodedFormEntity generateFormNameValuePairs(@Nonnull String... inputKeyValues) {
Preconditions.checkNotNull(inputKeyValues, "inputKeyValues argument is null.");
int inputLength = inputKeyValues.length;
Preconditions.checkArgument(inputLength > 0, "No input key/values specified.");
Preconditions.checkArgument((inputLength % 2) == 0, String.format("Odd number of name-value pairs: %d.", inputLength));
List<NameValuePair> formParams = new ArrayList<>();
for (int i = 0; i < inputLength;) {
formParams.add(new BasicNameValuePair(inputKeyValues[i++], inputKeyValues[i++]));
}
return new UrlEncodedFormEntity(formParams, StandardCharsets.UTF_8);
}
private static @Nonnull SetCookie createCookie(@Nonnull String key, @Nonnull String value) {
SetCookie cookie = new BasicClientCookie(key, value);
cookie.setPath("/");
cookie.setDomain(URL_LOGIN_DOMAIN);
return cookie;
}
public static boolean isPasswordValid(@Nonnull char[] nikePassword) {
Preconditions.checkNotNull(nikePassword, "nikePassword argument is null.");
return (!PASSWORD_INVALID.apply(nikePassword));
}
/**
* Performs a login to nike+, returning the nike+ access_token.
* @param nikeEmail The users nike+ email address.
* @param nikePassword The users nike+ password.
* @return nike+ access_token..
* @throws IOException If we are unable to successfully authenticate with Nike+.
*/
public static @Nonnull String login(@Nonnull String nikeEmail, @Nonnull char[] nikePassword) throws IOException {
Preconditions.checkNotNull(nikeEmail, "nikeEmail argument is null.");
Preconditions.checkNotNull(nikePassword, "nikePassword argument is null.");
Preconditions.checkArgument(isPasswordValid(nikePassword), INVALID_PASSWORD_ERROR_MESSAGE);
// Create CookieStore for the nikeEmail request.
CookieStore cookieStore = new BasicCookieStore();
cookieStore.addCookie(createCookie("app", nikePlusProperties.getProperty("NIKEPLUS_APP")));
cookieStore.addCookie(createCookie("client_id", nikePlusProperties.getProperty("NIKEPLUS_CLIENT_ID")));
cookieStore.addCookie(createCookie("client_secret", nikePlusProperties.getProperty("NIKEPLUS_CLIENT_SECRET")));
// Create the HttpClient, setting the cookie store.
try (CloseableHttpClient client = HttpClients.createDefaultHttpClientBuilder()
.setDefaultCookieStore(cookieStore)
.build()) {
// Create the HttpPost, set the user-agent and nike+ credentials.
HttpPost post = new HttpPost(URL_LOGIN);
post.addHeader("user-agent", USER_AGENT);
post.setEntity(generateFormNameValuePairs("email", nikeEmail, "password", new String(nikePassword)));
// Send the HTTP request.
HttpClientContext httpClientContext = HttpClientContext.create();
logger.out("Authenticating against Nike+");
try (CloseableHttpResponse response = client.execute(post, httpClientContext)) {
// Consume the response
EntityUtils.consumeQuietly(response.getEntity());
// Iterate through the cookies for "access_token".
for (Cookie cookie : httpClientContext.getCookieStore().getCookies()) {
if (cookie.getName().equals("access_token")) {
return cookie.getValue();
}
}
}
}
// If we reach here, we haven't got an access-token back for whatever reason.
throw new IOException("Unable to authenticate with Nike+.<br />Please check email and nikePassword.");
}
/**
* Perform a full synchronisation cycle (check-pin-status, sync, end-sync) with nike+ for the given credentials and garmin activities.
* @param nikeEmail The users nike+ email address.
* @param nikePassword The users nike+ password.
* @param nikeActivitiesData Nike activities data to upload.
* @throws IOException If there was a problem communicating with nike+.
*/
@Deprecated
public static void fullSync(@Nonnull String nikeEmail, @Nonnull char[] nikePassword, @Nonnull NikeActivityData... nikeActivitiesData) throws IOException {
Preconditions.checkNotNull(nikeEmail, "nikeEmail argument is null.");
Preconditions.checkNotNull(nikePassword, "nikePassword argument is null.");
Preconditions.checkNotNull(nikeActivitiesData, "garminActivitiesData argument is null.");
logger.out("Uploading to Nike+...");
logger.out(" - Authenticating...");
String nikeAccessToken = login(nikeEmail, nikePassword);
fullSync(nikeAccessToken, nikeActivitiesData);
}
@Deprecated
public static void fullSync(@Nonnull String nikeAccessToken, @Nonnull NikeActivityData... nikeActivitiesData) throws IOException {
logger.out(" - Syncing data...");
for (NikeActivityData nikeActivityData : nikeActivitiesData) {
if (!syncData(nikeAccessToken, nikeActivityData)) {
throw new IOException("There was a problem uploading to nike+. Please try again later, if the problem persists contact me with details of the activity-id or tcx file.");
}
}
}
@Deprecated
private static boolean syncData(@Nonnull String accessToken, @Nonnull NikeActivityData nikeActivityData) throws IOException {
try (CloseableHttpClient client = HttpClients.createDefaultHttpClientBuilder().build()) {
HttpPost post = new HttpPost(String.format(URL_DATA_SYNC, accessToken));
post.addHeader("user-agent", USER_AGENT);
post.addHeader("appid", "NIKEPLUSGPS");
post.addHeader("Accept", "application/json");
// Add "runXML" data to the request.
MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create()
.addPart("runXML", new SpoofFileBody(Util.documentToString(nikeActivityData.getRunXML()), "runXML.xml"));
// If we have GPX data, add it to the request.
if (nikeActivityData.getGpxXML() != null) {
multipartEntityBuilder.addPart("gpxXML", new SpoofFileBody(Util.documentToString(nikeActivityData.getGpxXML()), "gpxXML.xml"));
}
post.setEntity(multipartEntityBuilder.build());
try (CloseableHttpResponse response = client.execute(post)) {
EntityUtils.consumeQuietly(response.getEntity());
int statusCode = response.getStatusLine().getStatusCode();
logger.out(Level.FINE, " - response code: %d", statusCode);
return (URL_DATA_SYNC_SUCCESS == statusCode);
}
}
}
public static boolean syncData(@Nonnull String accessToken, @Nonnull NikePlusSyncData... nikePlusSyncDatas) throws IOException, JAXBException {
Preconditions.checkNotNull(accessToken, "accessToken argument is null.");
Preconditions.checkNotNull(nikePlusSyncDatas, "nikePlusSyncDatas argument is null.");
Preconditions.checkArgument(nikePlusSyncDatas.length > 0, "No nikePlusSyncData to sync");
// TODO: we must return some object which details which workouts succeeded/failed.
boolean success = true;
for (NikePlusSyncData nikePlusSyncData : nikePlusSyncDatas) {
try (CloseableHttpClient client = HttpClients.createDefaultHttpClientBuilder().build()) {
HttpPost post = new HttpPost(String.format(URL_DATA_SYNC, accessToken));
post.addHeader("user-agent", USER_AGENT);
post.addHeader("appid", "NIKEPLUSGPS");
post.addHeader("Accept", "application/json");
try (StringWriter stringWriter = new StringWriter()) {
JAXBObject.GPX_TYPE.marshal(new ObjectFactory().createGpx(nikePlusSyncData.getGpxXML()), stringWriter);
HttpEntity httpEntity = MultipartEntityBuilder.create()
.addTextBody("run", new Gson().toJson(nikePlusSyncData.getRunJson()), ContentType.APPLICATION_JSON)
.addTextBody("gpxXML", stringWriter.toString(), ContentType.TEXT_PLAIN)
.build();
post.setEntity(httpEntity);
logger.out("Posting to Nike+");
try (CloseableHttpResponse response = client.execute(post)) {
int statusCode = response.getStatusLine().getStatusCode();
nikePlusSyncData.setResponseEntityContent(EntityUtils.toString(response.getEntity()));
nikePlusSyncData.setResponseStatusCode(statusCode);
EntityUtils.consumeQuietly(response.getEntity());
logger.out(Level.FINE, " - response code: %d", statusCode);
if (statusCode != URL_DATA_SYNC_SUCCESS) {
success = false;
}
}
}
}
}
return success;
}
public static void endSync(@Nonnull String accessToken) throws IOException {
Preconditions.checkNotNull(accessToken, "accessToken argument is null.");
try (CloseableHttpClient client = HttpClients.createDefaultHttpClientBuilder()
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectTimeout(16_000)
.setConnectionRequestTimeout(16_000)
.setSocketTimeout(32_000)
.build())
.build()) {
HttpPost post = new HttpPost(String.format("%s?%s", URL_DATA_SYNC_COMPLETE_ACCESS_TOKEN, Util.generateHttpParameter("access_token", accessToken)));
post.addHeader("user-agent", USER_AGENT);
post.addHeader("appId", "NIKEPLUSGPS");
logger.out("Ending Nike+ sync");
try (CloseableHttpResponse response = client.execute(post)) {
logger.out(Level.FINE, " - response code: %d", response.getStatusLine().getStatusCode());
HttpEntity httpEntity = response.getEntity();
if (httpEntity != null) {
try (InputStream inputStream = httpEntity.getContent()) {
Document outDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputStream);
outDoc.normalize();
logger.out(Level.FINER, "\t%s", Util.documentToString(outDoc));
} catch (ParserConfigurationException | SAXException e) {
logger.out(e);
} finally {
EntityUtils.consumeQuietly(httpEntity);
}
} else {
throw new NullPointerException("Http response empty");
}
}
}
}
}