package org.fluxtream.connectors.runkeeper;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.apache.log4j.Logger;
import org.codehaus.plexus.util.ExceptionUtils;
import org.fluxtream.core.connectors.Connector;
import org.fluxtream.core.connectors.annotations.Updater;
import org.fluxtream.core.connectors.location.LocationFacet;
import org.fluxtream.core.connectors.updaters.AbstractUpdater;
import org.fluxtream.core.connectors.updaters.AuthRevokedException;
import org.fluxtream.core.connectors.updaters.UpdateFailedException;
import org.fluxtream.core.connectors.updaters.UpdateInfo;
import org.fluxtream.core.domain.ApiKey;
import org.fluxtream.core.services.ApiDataService;
import org.fluxtream.core.services.JPADaoService;
import org.fluxtream.core.services.MetadataService;
import org.fluxtream.core.services.impl.BodyTrackHelper;
import org.fluxtream.core.utils.JPAUtils;
import org.fluxtream.core.utils.TimeUtils;
import org.joda.time.DateTimeConstants;
import org.joda.time.DateTimeZone;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.scribe.model.OAuthRequest;
import org.scribe.model.Response;
import org.scribe.model.Token;
import org.scribe.model.Verb;
import org.scribe.oauth.OAuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.*;
/**
*
* @author Candide Kemmler (candide@fluxtream.com)
*/
@Component
@Updater(prettyName = "RunKeeper", value = 35, updateStrategyType = Connector.UpdateStrategyType.INCREMENTAL,
objectTypes = {LocationFacet.class, RunKeeperFitnessActivityFacet.class}, defaultChannels = {"RunKeeper.totalCalories"})
public class RunKeeperUpdater extends AbstractUpdater {
Logger logger = Logger.getLogger(RunKeeperUpdater.class);
final String DEFAULT_ENDPOINT= "https://api.runkeeper.com";
@Autowired
RunKeeperController runKeeperController;
@Autowired
JPADaoService jpaDaoService;
@Autowired
BodyTrackHelper bodytrackHelper;
final DateTimeFormatter timeFormatter = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss");
final String barsStyle = "{\"styles\":[{\"type\":\"line\",\"show\":false,\"lineWidth\":1}," +
"{\"radius\":0,\"fill\":false,\"type\":\"lollipop\",\"show\":true,\"lineWidth\":4}," +
"{\"radius\":2,\"fill\":true,\"type\":\"point\",\"show\":false,\"lineWidth\":1}," +
"{\"marginWidth\":5,\"verticalOffset\":7," +
"\"numberFormat\":\"###,##0\",\"type\":\"value\",\"show\":false}]," +
"\"comments\":" +
"{\"styles\":[{\"radius\":3,\"fill\":true,\"type\":\"point\",\"show\":true,\"lineWidth\":1}]," +
"\"verticalMargin\":4,\"show\":true}}";
@Autowired
MetadataService metadataService;
@Override
protected void updateConnectorDataHistory(final UpdateInfo updateInfo) throws Exception {
updateData(updateInfo, 0);
}
@Override
protected void updateConnectorData(final UpdateInfo updateInfo) throws Exception {
final String entityName = JPAUtils.getEntityName(RunKeeperFitnessActivityFacet.class);
final List<RunKeeperFitnessActivityFacet> newest = jpaDaoService.executeQueryWithLimit(
"SELECT facet from " + entityName + " facet WHERE facet.apiKeyId=? ORDER BY facet.start DESC",
1,
RunKeeperFitnessActivityFacet.class, updateInfo.apiKey.getId());
// If there are existing runkeeper facets, start just after the end of the most recent one.
// If there are no existing runkeeper facets, start at 0 just like we would for an
// initial history update.
long lastUpdated = 0;
if (newest.size()>0) {
lastUpdated = newest.get(0).end;
System.out.println("Runkeeper: starting update from " + timeFormatter.print(lastUpdated) + ", guestId=" + updateInfo.getGuestId());
}
else {
System.out.println("Runkeeper has no existing facets. Starting update from time=0, guestId=" + updateInfo.getGuestId());
}
updateData(updateInfo, lastUpdated);
}
@Override
public void setDefaultChannelStyles(ApiKey apiKey) {
// Set the channel defaults for the Runkeeper datastore channels. Set most of the channels to
// default to show as bars rather than lines
bodytrackHelper.setBuiltinDefaultStyle(apiKey.getGuestId(), apiKey.getConnector().getName(), "minutesPerKilometer", barsStyle);
bodytrackHelper.setBuiltinDefaultStyle(apiKey.getGuestId(), apiKey.getConnector().getName(), "minutesPerMile", barsStyle);
bodytrackHelper.setBuiltinDefaultStyle(apiKey.getGuestId(), apiKey.getConnector().getName(), "totalCalories", barsStyle);
}
private void updateData(final UpdateInfo updateInfo, final long since) throws Exception {
String url = DEFAULT_ENDPOINT+"/user?oauth_token=";
final String accessToken = guestService.getApiKeyAttribute(updateInfo.apiKey, "accessToken");
final Token token = new Token(accessToken, guestService.getApiKeyAttribute(updateInfo.apiKey, "runkeeperConsumerSecret"));
final String userEndpoint = url + accessToken;
OAuthRequest request = new OAuthRequest(Verb.GET, userEndpoint);
request.addHeader("Accept", "application/vnd.com.runkeeper.User+json");
final OAuthService service = runKeeperController.getOAuthService();
service.signRequest(token, request);
Response response = request.send();
final int httpResponseCode = response.getCode();
long then = System.currentTimeMillis();
String body = response.getBody();
if (httpResponseCode==200) {
JSONObject jsonObject = JSONObject.fromObject(body);
String fitnessActivities = jsonObject.getString("fitness_activities");
List<String> activities = new ArrayList<String>();
String activityFeedURL = DEFAULT_ENDPOINT + fitnessActivities;
countSuccessfulApiCall(updateInfo.apiKey, updateInfo.objectTypes, then, request.getCompleteUrl());
getFitnessActivityFeed(updateInfo, service, token, activityFeedURL, 25, activities, since);
Collections.reverse(activities);
getFitnessActivities(updateInfo, service, token, activities);
} else {
countFailedApiCall(updateInfo.apiKey, updateInfo.objectTypes, then,
request.getCompleteUrl(), ExceptionUtils.getStackTrace(new Exception()),
httpResponseCode, body);
if (httpResponseCode==403) {
handleTokenRevocation(body);
}
if (httpResponseCode>=400&&httpResponseCode<500)
throw new UpdateFailedException("Unexpected response code: " + httpResponseCode, true,
ApiKey.PermanentFailReason.clientError(httpResponseCode));
else
throw new UpdateFailedException("Unexpected code: " + httpResponseCode);
}
}
private void handleTokenRevocation(final String responseBody) throws AuthRevokedException {
// let's try to parse this error's payload and be conservative about parsing errors here
boolean dataCleanupRequested = false;
if (responseBody!=null) {
try {
final JSONObject errorPayload = JSONObject.fromObject(responseBody);
if (errorPayload != null && errorPayload.has("reason")&&errorPayload.getString("reason").equalsIgnoreCase("Revoked")) {
if (errorPayload.has("delete_health")&&errorPayload.getBoolean("delete_health"))
dataCleanupRequested = true;
throw new AuthRevokedException(dataCleanupRequested);
}
} catch (AuthRevokedException t) {
throw t;
} catch (Throwable t) {
}
}
}
private void getFitnessActivities(final UpdateInfo updateInfo, final OAuthService service,
final Token token, final List<String> activities) throws Exception {
for (String activity : activities) {
if (guestService.getApiKey(updateInfo.apiKey.getId())==null)
break;
String activityURL = DEFAULT_ENDPOINT + activity;
OAuthRequest request = new OAuthRequest(Verb.GET, activityURL);
request.addQuerystringParameter("oauth_token", token.getToken());
request.addHeader("Accept", "application/vnd.com.runkeeper.FitnessActivity+json");
service.signRequest(token, request);
long then = System.currentTimeMillis();
Response response = request.send();
final int httpResponseCode = response.getCode();
String body = response.getBody();
if (httpResponseCode ==200) {
countSuccessfulApiCall(updateInfo.apiKey,
updateInfo.objectTypes, then, activityURL);
JSONObject jsonObject = JSONObject.fromObject(body);
createOrUpdateActivity(jsonObject, updateInfo);
} else {
countFailedApiCall(updateInfo.apiKey,
updateInfo.objectTypes, then, activityURL, ExceptionUtils.getStackTrace(new Exception()),
httpResponseCode, body);
if (httpResponseCode==403)
handleTokenRevocation(body);
if (httpResponseCode>=400&&httpResponseCode<500)
throw new UpdateFailedException("Unexpected response code: " + httpResponseCode, true, ApiKey.PermanentFailReason.clientError(httpResponseCode));
else
throw new UpdateFailedException("Unexpected code: " + httpResponseCode);
}
}
}
private void createOrUpdateActivity(final JSONObject jsonObject, final UpdateInfo updateInfo) throws Exception {
final String uri = jsonObject.getString("uri");
final ApiDataService.FacetQuery facetQuery = new ApiDataService.FacetQuery("e.apiKeyId=? AND e.uri=?",
updateInfo.apiKey.getId(), uri);
final ApiDataService.FacetModifier<RunKeeperFitnessActivityFacet> facetModifier = new ApiDataService.FacetModifier<RunKeeperFitnessActivityFacet>() {
@Override
public RunKeeperFitnessActivityFacet createOrModify(RunKeeperFitnessActivityFacet origFacet, final Long apiKeyId) {
try {
RunKeeperFitnessActivityFacet facet = origFacet;
if (facet==null) {
facet = new RunKeeperFitnessActivityFacet(updateInfo.apiKey.getId());
facet.uri = uri;
facet.api = updateInfo.apiKey.getConnector().value();
facet.guestId = updateInfo.apiKey.getGuestId();
facet.timeUpdated = System.currentTimeMillis();
}
boolean startTimeSet = false;
if (jsonObject.has("path")) {
final JSONArray path = jsonObject.getJSONArray("path");
List<LocationFacet> locationFacets = new ArrayList<LocationFacet>();
for (int i=0; i<path.size(); i++) {
JSONObject pathElement = path.getJSONObject(i);
LocationFacet locationFacet = new LocationFacet(updateInfo.apiKey.getId());
locationFacet.latitude = (float) pathElement.getDouble("latitude");
locationFacet.longitude = (float) pathElement.getDouble("longitude");
if (!startTimeSet) {
// we need to know the user's location in order to figure out
// his timezone
final String start_time = jsonObject.getString("start_time");
System.out.println("runkeeper activity start time: " + start_time + " (should be ascending), guestId=" + updateInfo.getGuestId());
final TimeZone timeZone = metadataService.getTimeZone(locationFacet.latitude, locationFacet.longitude);
facet.start = timeFormatter.withZone(DateTimeZone.forTimeZone(timeZone)).parseMillis(start_time);
facet.timeZone = timeZone.getID();
final int duration = jsonObject.getInt("duration");
facet.end = facet.start + duration*1000;
facet.duration = duration;
startTimeSet = true;
}
locationFacet.altitude = (int) pathElement.getDouble("altitude");
final long millisIncrement = (long)(pathElement.getDouble("timestamp") * 1000d);
locationFacet.timestampMs = facet.start + millisIncrement;
locationFacet.start = locationFacet.timestampMs;
locationFacet.end = locationFacet.timestampMs;
locationFacet.source = LocationFacet.Source.RUNKEEPER;
locationFacet.apiKeyId = updateInfo.apiKey.getId();
locationFacet.api = Connector.getConnector("runkeeper").value();
locationFacet.uri = uri;
locationFacets.add(locationFacet);
}
apiDataService.addGuestLocations(updateInfo.getGuestId(), locationFacets);
} else {
//TODO: abort elegantly if we don't have gps data as we are unable to figure out time
//in this case
return null;
}
facet.userID = jsonObject.getString("userID");
facet.duration = jsonObject.getInt("duration");
facet.type = jsonObject.getString("type");
facet.equipment = jsonObject.getString("equipment");
facet.total_distance = jsonObject.getDouble("total_distance");
facet.is_live = jsonObject.getBoolean("is_live");
facet.comments = jsonObject.getString("comments");
facet.uri = uri;
if (jsonObject.has("total_climb"))
facet.total_climb = jsonObject.getDouble("total_climb");
if (jsonObject.has("heart_rate")) {
final JSONArray heartRateArray = jsonObject.getJSONArray("heart_rate");
double totalHeartRate = 0d;
double totalTime = 0d;
double lastTimestamp = 0d;
for (int i=0; i<heartRateArray.size(); i++) {
JSONObject record = heartRateArray.getJSONObject(i);
double timestamp = record.getDouble("timestamp");
final double lap = timestamp - lastTimestamp;
totalHeartRate += record.getInt("heart_rate") * lap;
lastTimestamp = timestamp;
totalTime += lap;
}
facet.averageHeartRate = (int) (totalHeartRate/totalTime);
facet.heartRateStorage = heartRateArray.toString();
}
if (jsonObject.has("calories")) {
final JSONArray caloriesArray = jsonObject.getJSONArray("calories");
for (int i=0; i<caloriesArray.size(); i++) {
JSONObject record = caloriesArray.getJSONObject(i);
facet.totalCalories += record.getDouble("calories");
}
facet.caloriesStorage = caloriesArray.toString();
}
if (jsonObject.has("total_calories"))
facet.totalCalories = jsonObject.getDouble("total_calories");
if (jsonObject.has("distance")) {
final JSONArray distanceArray = jsonObject.getJSONArray("distance");
facet.distanceStorage = distanceArray.toString();
}
return facet;
} catch (Throwable t) {
logger.warn("could not import a Runkeeper Activity record: " + t.getMessage());
return origFacet;
}
}
};
final RunKeeperFitnessActivityFacet newFacet = apiDataService.createOrReadModifyWrite(RunKeeperFitnessActivityFacet.class, facetQuery, facetModifier, updateInfo.apiKey.getId());
if (newFacet!=null) {
bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(newFacet));
}
}
/**
* Get the feed of activities in a succint format. FitnessActivity info (with gps data etc) is fetched in a separate call
* (one per activity). We want to limit this feed to those activities that we haven't already stored of course but
* unfortunately the Runkeeper API call will by default retrieve the entire feed. Optional parameters
* (<code>noEarlierThan</code>, <code>noLaterThan</code>) are able to limit the dataset, but they will only accept dates specified in
* <code>yyyy-MM-DD</code> format, which obviously limits the boundary limits granularity to a day. Additionally, it is unclear
* what timezone is used to filter the dataset (is it GMT, that is then converted to the local time, or is the
* parameter given in local time?). Consequently, we use the <code>noEarlierThan</code> parameter with a one day padding and
* further filter the dataset using the list of activity that we already have data for (<code>activityIsAlreadyStored()</code>).
* @param updateInfo
* @param service
* @param token
* @param activityFeedURL
* @param pageSize
* @param activities
* @param since
*/
private void getFitnessActivityFeed(final UpdateInfo updateInfo, final OAuthService service,
final Token token, String activityFeedURL, final int pageSize,
List<String> activities, long since) throws UpdateFailedException, AuthRevokedException {
OAuthRequest request = new OAuthRequest(Verb.GET, activityFeedURL);
request.addQuerystringParameter("pageSize", String.valueOf(pageSize));
request.addQuerystringParameter("oauth_token", token.getToken());
request.addHeader("Accept", "application/vnd.com.runkeeper.FitnessActivityFeed+json");
final DateTimeFormatter dateFormatter = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'").withZone(DateTimeZone.forID("GMT"));
if (since>0) {
final String sinceFormatted = dateFormatter.print(since);
// add one day of padding to account for unknown timezone
final String noEarlierFormatted = TimeUtils.dateFormatterUTC.print(since-DateTimeConstants.MILLIS_PER_DAY);
request.addHeader("If-Modified-Since", sinceFormatted);
request.addQuerystringParameter("noEarlierThan", noEarlierFormatted);
}
service.signRequest(token, request);
long then = System.currentTimeMillis();
Response response = request.send();
final int httpResponseCode = response.getCode();
String body = response.getBody();
if (httpResponseCode ==200) {
JSONObject jsonObject = JSONObject.fromObject(body);
final JSONArray items = jsonObject.getJSONArray("items");
for(int i=0; i<items.size(); i++) {
JSONObject item = items.getJSONObject(i);
final String uri = item.getString("uri");
if (activityIsAlreadyStored(updateInfo, uri))
continue;
activities.add(uri);
}
countSuccessfulApiCall(updateInfo.apiKey,
updateInfo.objectTypes, then, activityFeedURL);
if (jsonObject.has("next")) {
activityFeedURL = DEFAULT_ENDPOINT + jsonObject.getString("next");
getFitnessActivityFeed(updateInfo, service, token, activityFeedURL, pageSize, activities, since);
}
} else if (httpResponseCode ==304) {
countSuccessfulApiCall(updateInfo.apiKey,
updateInfo.objectTypes, then, activityFeedURL);
} else {
countFailedApiCall(updateInfo.apiKey,
updateInfo.objectTypes, then, activityFeedURL, ExceptionUtils.getStackTrace(new Exception()),
httpResponseCode, body);
if (httpResponseCode==403)
handleTokenRevocation(body);
if (httpResponseCode>=400&&httpResponseCode<500)
throw new UpdateFailedException("Unexpected response code: " + httpResponseCode, true, ApiKey.PermanentFailReason.clientError(httpResponseCode));
else
throw new UpdateFailedException("Unexpected code: " + httpResponseCode);
}
}
private boolean activityIsAlreadyStored(final UpdateInfo updateInfo, final String uri) {
final String entityName = JPAUtils.getEntityName(RunKeeperFitnessActivityFacet.class);
final List<RunKeeperFitnessActivityFacet> facets =
jpaDaoService.executeQueryWithLimit(String.format("SELECT facet from %s facet WHERE facet.apiKeyId=? AND facet.uri=?", entityName),
1,
RunKeeperFitnessActivityFacet.class,
updateInfo.apiKey.getId(), uri);
return facets.size()>0;
}
}