package org.fluxtream.connectors.fitbit; import net.sf.json.JSONArray; import net.sf.json.JSONObject; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.fluxtream.core.aspects.FlxLogger; import org.fluxtream.core.connectors.Autonomous; import org.fluxtream.core.connectors.ObjectType; import org.fluxtream.core.connectors.annotations.Updater; import org.fluxtream.core.connectors.updaters.*; import org.fluxtream.core.domain.AbstractFacet; import org.fluxtream.core.domain.AbstractLocalTimeFacet; import org.fluxtream.core.domain.ApiKey; import org.fluxtream.core.domain.Notification; import org.fluxtream.core.services.ApiDataService; import org.fluxtream.core.services.MetadataService; import org.fluxtream.core.services.NotificationsService; import org.fluxtream.core.services.impl.BodyTrackHelper; import org.fluxtream.core.utils.JPAUtils; import org.fluxtream.core.utils.TimeUtils; import org.fluxtream.core.utils.Utils; import org.joda.time.*; import org.joda.time.format.DateTimeFormat; import org.joda.time.format.DateTimeFormatter; import org.joda.time.format.ISODateTimeFormat; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.stereotype.Controller; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Field; import java.net.HttpURLConnection; import java.net.URL; import java.util.*; /** * @author candide * */ @Component @Controller @Updater(prettyName = "Fitbit", value = 7, objectTypes = { FitbitTrackerActivityFacet.class, FitbitLoggedActivityFacet.class, FitbitSleepFacet.class, FitbitWeightFacet.class, FitbitFoodLogSummaryFacet.class, FitbitFoodLogEntryFacet.class}, userProfile = FitbitUserProfile.class, bodytrackResponder = FitbitBodytrackResponder.class, defaultChannels = {"Fitbit.steps","Fitbit.caloriesOut", "Fitbit.loggedActivitesDistance", "caloriesIn"}) public class FitBitTSUpdater extends AbstractUpdater implements Autonomous { private static final String IS_GUEST_SUBSCRIBED_TO_FITBIT_NOTIFICATIONS_ATT_KEY = "isGuestSubscribedToFitbitNotifications"; private static final String CALORIES_IN_HISTORY_IMPORTED_ATT_KEY = "caloriesInHistoryImported"; private static final String HAS_FITBIT_USER_PROFILE_ATT_KEY = "hasFitbitUserProfile"; private static final String BACKSYNC_DAYS_GOAL_ATT_KEY = "backsync.date.goal"; private static final String BACKSYNC_START_DATE_ATT_KEY = "backsync.start.date"; FlxLogger logger = FlxLogger.getLogger(FitBitTSUpdater.class); @Autowired FitbitPersistenceHelper fitbitPersistenceHelper; @Autowired MetadataService metadataService; @Autowired ApiDataService apiDataService; @Autowired NotificationsService notificationsService; @Autowired BodyTrackHelper bodyTrackHelper; @Autowired FitbitOAuthController controller; public static final String GET_STEPS_CALL = "FITBIT_GET_STEPS_TIMESERIES_CALL"; public static final String GET_USER_PROFILE_CALL = "FITBIT_GET_USER_PROFILE_CALL"; public static final String GET_USER_DEVICES_CALL = "FITBIT_GET_USER_DEVICES_CALL"; public static final String SUBSCRIBE_TO_FITBIT_NOTIFICATIONS_CALL = "SUBSCRIBE_TO_FITBIT_NOTIFICATIONS_CALL"; final ObjectType sleepOT = ObjectType.getObjectType(connector(), "sleep"); final ObjectType weightOT = ObjectType.getObjectType(connector(), "weight"); final ObjectType activityOT = ObjectType.getObjectType(connector(), "activity_summary"); final ObjectType loggedActivityOT = ObjectType.getObjectType( connector(), "logged_activity"); final ObjectType foodLogEntryOT = ObjectType.getObjectType(connector(), "food_log_entry"); final ObjectType foodLogSummaryOT = ObjectType.getObjectType(connector(), "food_log_summary"); static { ObjectType.registerCustomObjectType(GET_STEPS_CALL); ObjectType.registerCustomObjectType(GET_USER_PROFILE_CALL); ObjectType.registerCustomObjectType(GET_USER_DEVICES_CALL); ObjectType.registerCustomObjectType(SUBSCRIBE_TO_FITBIT_NOTIFICATIONS_CALL); } public FitBitTSUpdater() { super(); } @Override public void updateConnectorDataHistory(UpdateInfo updateInfo) throws Exception { // sleep loadTimeSeries("sleep/timeInBed", updateInfo, sleepOT, "timeInBed"); loadTimeSeries("sleep/startTime", updateInfo, sleepOT, "startTime"); loadTimeSeries("sleep/minutesAsleep", updateInfo, sleepOT, "minutesAsleep"); loadTimeSeries("sleep/minutesAwake", updateInfo, sleepOT, "minutesAwake"); loadTimeSeries("sleep/minutesToFallAsleep", updateInfo, sleepOT, "minutesToFallAsleep"); loadTimeSeries("sleep/minutesAfterWakeup", updateInfo, sleepOT, "minutesAfterWakeup"); loadTimeSeries("sleep/awakeningsCount", updateInfo, sleepOT, "awakeningsCount"); // activities loadTimeSeries("activities/tracker/calories", updateInfo, activityOT, "caloriesOut"); loadTimeSeries("activities/tracker/steps", updateInfo, activityOT, "steps"); loadTimeSeries("activities/tracker/distance", updateInfo, activityOT, "totalDistance"); // The floors and elevation APIs report 400 errors if called on // an account which has never been bound to a Fitbit device which // has an altimeter, such as the Fitbit Ultra. For now, disable // reading these APIs. In the future, perhaps check the device // type and conditionally call these APIs. loadTimeSeries("activities/tracker/floors", updateInfo, activityOT, "floors"); loadTimeSeries("activities/tracker/elevation", updateInfo, activityOT, "elevation"); loadTimeSeries("activities/tracker/minutesSedentary", updateInfo, activityOT, "sedentaryMinutes"); loadTimeSeries("activities/tracker/minutesLightlyActive", updateInfo, activityOT, "lightlyActiveMinutes"); loadTimeSeries("activities/tracker/minutesFairlyActive", updateInfo, activityOT, "fairlyActiveMinutes"); loadTimeSeries("activities/tracker/minutesVeryActive", updateInfo, activityOT, "veryActiveMinutes"); loadTimeSeries("activities/tracker/activeScore", updateInfo, activityOT, "activeScore"); loadTimeSeries("activities/tracker/activityCalories", updateInfo, activityOT, "activityCalories"); // weight // Store the time when we're asking about the weight in case this // account doesn't have a hardware scale associated with it long weightRequestMillis = System.currentTimeMillis(); loadTimeSeries("body/weight", updateInfo, weightOT, "weight"); loadTimeSeries("body/bmi", updateInfo, weightOT, "bmi"); loadTimeSeries("body/fat", updateInfo, weightOT, "fat"); jpaDaoService.execute("DELETE FROM Facet_FitbitSleep sleep WHERE sleep.start=0"); final JSONArray deviceStatusesArray = getDeviceStatusesArray(updateInfo); // Store TRACKER.lastSyncDate long trackerLastSyncDate = -1; try { trackerLastSyncDate = getLastServerSyncMillis(deviceStatusesArray, "TRACKER"); } catch (Throwable t) { logger.info("guestId=" + updateInfo.getGuestId() + " connector=fitbit action=updateConnectorDataHistory " + " message=\"Error getting TRACKER.lastSyncDate\" stackTrace=<![CDATA[\"" + t.getStackTrace() + "]]>"); } if (trackerLastSyncDate == -1) { // Default to yesterday if no better value is available trackerLastSyncDate = System.currentTimeMillis() - DateTimeConstants.MILLIS_PER_DAY; } guestService.setApiKeyAttribute(updateInfo.apiKey, "TRACKER.lastSyncDate", String.valueOf(trackerLastSyncDate)); // Store SCALE.lastSyncDate long scaleLastSyncDate = -1; try { scaleLastSyncDate = getLastServerSyncMillis(deviceStatusesArray, "SCALE"); } catch (Throwable t) { logger.info("guestId=" + updateInfo.getGuestId() + " connector=fitbit action=updateConnectorDataHistory " + " message=\"Error getting SCALE.lastSyncDate\" stackTrace=<![CDATA[\"" + t.getStackTrace() + "]]>"); } // In the case that the scale doesn't have a valid scaleLastSyncDate, store // the timestamp for when we asked about the weight for use in doing incremental // weight updates later on if (scaleLastSyncDate == -1) { guestService.setApiKeyAttribute(updateInfo.apiKey, "SCALE.lastSyncDate", String.valueOf(weightRequestMillis)); } else { guestService.setApiKeyAttribute(updateInfo.apiKey, "SCALE.lastSyncDate", String.valueOf(scaleLastSyncDate)); } // Flush the initial fitbit history data to the datastore. // This is handled automatically by the incremental updates because // it uses the apiDataService.cacheApiDataJSON APIs. However, // the above code does not do that so we explicity send the // Fitbit facet data to the datastore here. bodyTrackStorageService.storeInitialHistory(updateInfo.apiKey); checkLateAdditions(updateInfo); } public long getLastWeighingTime(final UpdateInfo updateInfo) { final FitbitWeightFacet weightFacet = jpaDaoService.findOne("fitbit.weight.latest", FitbitWeightFacet.class, updateInfo.apiKey.getId()); if(weightFacet!=null) { return weightFacet.start; } else { return -1; } } public void loadTimeSeries(String uri, UpdateInfo updateInfo, ObjectType objectType, String fieldName) throws RateLimitReachedException { String json = ""; try { json = makeRestCall(updateInfo, uri.hashCode(), "https://api.fitbit.com/1/user/-/" + uri + "/date/today/max.json"); } catch (Throwable t) { // elevation and floors are not available for earlier trackers, so we can safely ignore them if (fieldName.equals("elevation")||fieldName.equals("floors")) { logger.info("guestId=" + updateInfo.apiKey.getGuestId() + " connector=fitbit action=loadTimeSeries message=\"Could not load timeseries for " + fieldName); return; } } JSONObject timeSeriesJson; try { timeSeriesJson = JSONObject.fromObject(json); } catch (Throwable t) { logger.warn("Could not load time series, objectType=" + objectType.getName() + ", fieldName=" + fieldName); return; } String resourceName = uri.replace('/', '-'); JSONArray timeSeriesArray = timeSeriesJson.getJSONArray(resourceName); for (int i = 0; i < timeSeriesArray.size(); i++) { JSONObject entry = timeSeriesArray.getJSONObject(i); String date = entry.getString("dateTime"); if (objectType == sleepOT) { FitbitSleepFacet facet = getSleepFacet(updateInfo.apiKey.getId(), date); if (facet == null) { facet = new FitbitSleepFacet(updateInfo.apiKey.getId()); facet.isEmpty = true; facet.date = date; facet.api = connector().value(); facet.guestId = updateInfo.apiKey.getGuestId(); facetDao.persist(facet); } addToSleepFacet(facet, entry, fieldName); } else if (objectType == activityOT) { FitbitTrackerActivityFacet facet = getActivityFacet( updateInfo.apiKey.getId(), date); if (facet == null) { facet = new FitbitTrackerActivityFacet(updateInfo.apiKey.getId()); setCommonFacetProperties(updateInfo, date, facet); facetDao.persist(facet); } addToActivityFacet(facet, entry, fieldName); } else if (objectType == weightOT) { FitbitWeightFacet facet = getWeightFacet(updateInfo.apiKey.getId(), date); if (facet == null) { facet = new FitbitWeightFacet(updateInfo.apiKey.getId()); setCommonFacetProperties(updateInfo, date, facet); facetDao.persist(facet); } addToWeightFacet(facet, entry, fieldName); } else if (objectType == foodLogSummaryOT) { FitbitFoodLogSummaryFacet facet = getFoodLogSummaryFacet(updateInfo.apiKey.getId(), date); if (facet == null) { facet = new FitbitFoodLogSummaryFacet(updateInfo.apiKey.getId()); setCommonFacetProperties(updateInfo, date, facet); } setFieldValue(facet, fieldName, entry.getString("value")); facetDao.merge(facet); } } } private void setCommonFacetProperties(UpdateInfo updateInfo, String date, AbstractLocalTimeFacet facet) { facet.date = date; facet.api = connector().value(); facet.guestId = updateInfo.apiKey.getGuestId(); final DateTime dateTime = TimeUtils.dateFormatterUTC.parseDateTime(date); facet.start = dateTime.getMillis(); facet.end = dateTime.getMillis() + DateTimeConstants.MILLIS_PER_DAY - 1; facet.startTimeStorage = date + "T00:00:00.000"; facet.endTimeStorage = date + "T23:59:59.999"; } @Transactional(readOnly = false) private void addToWeightFacet(FitbitWeightFacet facet, JSONObject entry, String fieldName) { setFieldValue(facet, fieldName, entry.getString("value")); facetDao.merge(facet); } private FitbitWeightFacet getWeightFacet(final long apiKeyId, String date) { return jpaDaoService.findOne("fitbit.weight.byDate", FitbitWeightFacet.class, apiKeyId, date); } private FitbitTrackerActivityFacet getActivityFacet(long apiKeyId, String date) { return jpaDaoService.findOne("fitbit.activity_summary.byDate", FitbitTrackerActivityFacet.class, apiKeyId, date); } private FitbitSleepFacet getSleepFacet(long apiKeyId, String date) { return jpaDaoService.findOne("fitbit.sleep.byDate", FitbitSleepFacet.class, apiKeyId, date); } private FitbitFoodLogSummaryFacet getFoodLogSummaryFacet(final long apiKeyId, String date) { return jpaDaoService.findOne("fitbit.foodLog.summary.byDate", FitbitFoodLogSummaryFacet.class, apiKeyId, date); } @Transactional(readOnly = false) private void addToSleepFacet(FitbitSleepFacet facet, JSONObject entry, String fieldName) { if (fieldName.equals("startTime")) { storeTime(entry.getString("value"), facet); // let's delete empty sleep entries as soon as we can if (facet.start==0) facetDao.delete(facet); else { facet.isEmpty = false; facetDao.merge(facet); } } else { setFieldValue(facet, fieldName, entry.getString("value")); facetDao.merge(facet); } } private final static DateTimeFormatter format = DateTimeFormat .forPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"); private void storeTime(String bedTimeString, FitbitSleepFacet facet) { if (bedTimeString.equals("")) // bedTimeString EST TOUJOURS EGAL A ""!!! return; if (bedTimeString.length() == 5) bedTimeString = facet.date + "T" + bedTimeString + ":00.000"; // using UTC just to have a reference point in order to // compute riseTime with a duration delta from bedTime facet.start = format.withZoneUTC().parseMillis(bedTimeString); facet.end = facet.start + facet.timeInBed * 60000; } @Transactional(readOnly = false) private void addToActivityFacet(FitbitTrackerActivityFacet facet, JSONObject entry, String fieldName) { setFieldValue(facet, fieldName, entry.getString("value")); facetDao.merge(facet); } private void setFieldValue(Object o, String fieldName, String stringValue) { try { Field field = o.getClass().getField(fieldName); Class<?> type = field.getType(); Object value = null; if (type == String.class) value = stringValue; else if (type == Integer.TYPE) value = Integer.valueOf(stringValue); else if (type == Double.TYPE) value = Double.valueOf(stringValue); else if (type == Float.TYPE) value = Float.valueOf(stringValue); field.set(o, value); } catch (Exception e) { e.printStackTrace(); } } public List<String> getDaysToSync(UpdateInfo updateInfo, final String deviceType, long trackerLastServerSyncMillis, long scaleLastServerSyncMillis) throws RateLimitReachedException { ApiKey apiKey = updateInfo.apiKey; long lastStoredSyncMillis = 0; if (deviceType.equals("TRACKER")) { // TRACKER.lastSyncDate is actually used to record our progress in where we got to at the end of the last // successful incremental update. It should be <= the actual "lastSyncTime" returned by the server // during the last update try { final String trackerLastStoredSyncMillis = guestService.getApiKeyAttribute(apiKey, "TRACKER.lastSyncDate"); lastStoredSyncMillis = Long.valueOf(trackerLastStoredSyncMillis); } catch (Throwable t) { // As a fallback, get the latest facet in the DB and use the start time from it as lastStoredSyncMillis final String entityName = JPAUtils.getEntityName(FitbitTrackerActivityFacet.class); final List<FitbitTrackerActivityFacet> newest = jpaDaoService.executeQueryWithLimit( "SELECT facet from " + entityName + " facet WHERE facet.apiKeyId=? ORDER BY facet.start DESC", 1, FitbitTrackerActivityFacet.class, updateInfo.apiKey.getId()); // If there are existing fitbit facets, use the start field of the most recent one. // If there are no existing fitbit facets, just start with today if (newest.size()>0) { lastStoredSyncMillis = newest.get(0).start; logger.info("Fitbit: guestId=" + updateInfo.getGuestId() + ", using DB for lastStoredSyncMillis=" + lastStoredSyncMillis); } else { logger.info("Fitbit: guestId=" + updateInfo.getGuestId() + ", nothing in DB for lastStoredSyncMillis, default to yesterday"); lastStoredSyncMillis = System.currentTimeMillis()-DateTimeConstants.MILLIS_PER_DAY; } // Now make sure we're getting at least one day of data by setting lastStoredSyncMillis to // trackerLastServerSyncMillis if it's not already less if(trackerLastServerSyncMillis < lastStoredSyncMillis) { lastStoredSyncMillis = trackerLastServerSyncMillis; } logger.info("guestId=" + updateInfo.getGuestId() + " connector=fitbit action=getDaysToSync deviceType="+ deviceType + " message=\"Error parsing TRACKER.lastSyncDate, using default of " + lastStoredSyncMillis + "\" error=" + t.getMessage() + " stackTrace=<![CDATA[\"" + Utils.stackTrace(t) + "]]>"); } } else { final String scaleLastSyncMillis = guestService.getApiKeyAttribute(apiKey, "SCALE.lastSyncDate"); lastStoredSyncMillis = Long.valueOf(scaleLastSyncMillis); } if (deviceType.equals("TRACKER")&&lastStoredSyncMillis<= trackerLastServerSyncMillis) { // For the tracker, we only want to update if the device has really updated and we want to update // from the date corresponding to trackerLastStoredSyncMillis // to the date of scaleLastServerSyncMillis return getListOfDatesBetween(updateInfo, lastStoredSyncMillis, trackerLastServerSyncMillis); } else if (deviceType.equals("SCALE")) { // In the case of an account without a hardware scale, the server never returns a valid scale sync time, // so just use the dates between the day before the last time we did an update and now, ignoring scaleLastServerSyncMillis. // This means that we'll never get account-linked scale data that was delayed by more than a day, but that's the best // we can do for now. long endMillis = scaleLastServerSyncMillis; if(scaleLastServerSyncMillis==-1) { // No hardware scale. Check if lastStoredSyncMillis is also -1, which will happen for // accounts last updated with a version prior to 0.9.0022 without a hardware scale. // In that case, start from the date of the last stored weight data in the facet DB. // This is a hack, but it's the best I can think of for now. if(lastStoredSyncMillis == -1) { lastStoredSyncMillis = getLastWeighingTime(updateInfo); } if(lastStoredSyncMillis == -1) { // If we don't have any weight data at all, just start with yesterday. lastStoredSyncMillis = System.currentTimeMillis()-DateTimeConstants.MILLIS_PER_DAY; } // Use now as the end time, since we don't have better info from the server about when the // most recent data point might be endMillis = System.currentTimeMillis(); } else if(scaleLastServerSyncMillis == lastStoredSyncMillis) { // We have a hardware scale and it hasn't updated since last time, don't need to sync at all return new ArrayList<String>(); } return getListOfDatesBetween(updateInfo, lastStoredSyncMillis, endMillis); } return new ArrayList<String>(); } private long getLastServerSyncMillis(JSONArray devices, String device) { try { for (int i=0; i<devices.size(); i++) { JSONObject deviceStatus = devices.getJSONObject(i); String type = deviceStatus.getString("type"); String dateTime = deviceStatus.getString("lastSyncTime"); long ts = AbstractLocalTimeFacet.timeStorageFormat.parseMillis(dateTime); if (type.equalsIgnoreCase(device)) { return ts; } } } catch (Throwable t) { logger.info("connector=fitbit action=getLastServerSyncMillis message=\"Error parsing lastSyncTime from fitbit json\" devices=\"" + devices.toString() + "\" stackTrace=<![CDATA[\"" + Utils.stackTrace(t) + "]]>"); } return -1; } private List<String> getListOfDatesBetween(final UpdateInfo updateInfo, final long startMillis, final long endMillis) { List<String> dates = new ArrayList<String>(); // TODO: what really matters here is the date that the Fitbit server uses for the // tracker update times, so this isn't quite right final DateTimeZone zone = getFitbitUserTimezone(updateInfo); final LocalDate startDate = new LocalDate(startMillis, zone); // in order to have the date of endMillis in the resulting list we need to add a // a day final long dayAfterEnd = endMillis + DateTimeConstants.MILLIS_PER_DAY; int days = Days.daysBetween(startDate, new LocalDate(dayAfterEnd, zone)).getDays(); for (int i = 0; i < days; i++) { LocalDate d = startDate.withFieldAdded(DurationFieldType.days(), i); String dateString = TimeUtils.dateFormatter.print(d.toDateTimeAtStartOfDay().getMillis()); dates.add(dateString); } return dates; } private JSONArray getDeviceStatusesArray(final UpdateInfo updateInfo) throws RateLimitReachedException, UpdateFailedException, AuthExpiredException, UnexpectedResponseCodeException { String urlString = "https://api.fitbit.com/1/user/-/devices.json"; final ObjectType customObjectType = ObjectType.getCustomObjectType(GET_USER_DEVICES_CALL); final int getUserDevicesObjectTypeID = customObjectType.value(); String json = makeRestCall(updateInfo, getUserDevicesObjectTypeID, urlString); return JSONArray.fromObject(json); } public void updateConnectorData(UpdateInfo updateInfo) throws Exception { checkLateAdditions(updateInfo); if (updateInfo.getUpdateType()==UpdateInfo.UpdateType.PUSH_TRIGGERED_UPDATE) { JSONObject jsonParams = JSONObject.fromObject(updateInfo.jsonParams); String formattedDate = jsonParams.getString("date"); switch(updateInfo.objectTypes) { case 3: // activities loadActivityDataForOneDay(updateInfo, formattedDate); final List<AbstractFacet> facetsByDates = facetDao.getFacetsByDates(updateInfo.apiKey, activityOT, Arrays.asList(formattedDate), null); if (facetsByDates.size()>0) { final FitbitTrackerActivityFacet activityFacet = (FitbitTrackerActivityFacet) facetsByDates.get(0); updateIntradayMetrics(updateInfo, activityFacet); } break; case 4: // sleep loadSleepDataForOneDay(updateInfo, formattedDate); break; case 8: // body final JSONArray deviceStatusesArray = getDeviceStatusesArray(updateInfo); // will return -1 if there is no scale in the devicesStatuses, which is interpreted as // a manual weight entry in the updateOneDayOfScaleData method final long scaleLastServerSyncMillis = getLastServerSyncMillis(deviceStatusesArray, "SCALE"); updateOneDayOfScaleData(updateInfo, formattedDate, scaleLastServerSyncMillis); break; case 16+32: // foods loadFoodDataForOneDay(updateInfo, formattedDate); break; } return; } final JSONArray deviceStatusesArray = getDeviceStatusesArray(updateInfo); final long trackerLastServerSyncMillis = getLastServerSyncMillis(deviceStatusesArray, "TRACKER"); final long scaleLastServerSyncMillis = getLastServerSyncMillis(deviceStatusesArray, "SCALE"); if (trackerLastServerSyncMillis > -1) { final List<String> trackerDaysToSync = getDaysToSync(updateInfo, "TRACKER", trackerLastServerSyncMillis, scaleLastServerSyncMillis); if (trackerDaysToSync.size() > 0) { updateTrackerListOfDays(updateInfo, trackerDaysToSync, trackerLastServerSyncMillis); guestService.setApiKeyAttribute(updateInfo.apiKey, "TRACKER.lastSyncDate", String.valueOf(trackerLastServerSyncMillis)); } } // Update the scale final List<String> scaleDaysToSync = getDaysToSync(updateInfo, "SCALE", trackerLastServerSyncMillis, scaleLastServerSyncMillis); if (scaleDaysToSync.size() > 0) { long weightRequestMillis = System.currentTimeMillis(); updateScaleListOfDays(updateInfo, scaleDaysToSync, scaleLastServerSyncMillis); // Update SCALE.lastSyncDate: In the case that the scale doesn't have a valid scaleLastSyncDate, store // the timestamp for when we asked about the weight for use in doing incremental // weight updates later on if(scaleLastServerSyncMillis == -1) { guestService.setApiKeyAttribute(updateInfo.apiKey, "SCALE.lastSyncDate", String.valueOf(weightRequestMillis)); } else { guestService.setApiKeyAttribute(updateInfo.apiKey, "SCALE.lastSyncDate", String.valueOf(scaleLastServerSyncMillis)); } } // now let's backsync logged activities, sleep and food which can be manually entered and potentially // not caught by the subscription/notification mechanism // note: we know that updateInfo's remainingAPICalls property has been set since there is always a mandatory // call to getDeviceStatusesArray() (https://api.fitbit.com/1/user/-/devices.json) // We are incrementally going farther back in time up to a given backfill limit ("fitbit.backsync.days"), // keeping track of up to when we've got to and starting from there next time LocalDate startDate = getBackSyncStartDate(updateInfo); String backSyncDateGoal = getBackSyncDateGoal(updateInfo); while (updateInfo.getRemainingAPICalls("fitbit")>50) { String formattedDate = TimeUtils.dateFormatter.print(startDate); // System.out.println("backsynching, we have " + updateInfo.getRemainingAPICalls("fitbit") + // " API calls left, apiKeyId=" + updateInfo.apiKey.getId() + ", startDate=" + formattedDate + ", goal=" + backSyncDateGoal); loadActivityDataForOneDay(updateInfo, formattedDate); if (updateInfo.getRemainingAPICalls("fitbit")<=50) break; loadSleepDataForOneDay(updateInfo, formattedDate); if (updateInfo.getRemainingAPICalls("fitbit")<=50) break; loadFoodDataForOneDay(updateInfo, formattedDate); if (updateInfo.getRemainingAPICalls("fitbit")<=50) break; loadIntradayDataForOneDay(updateInfo, formattedDate); // start over when we have reached our goal if (isGoalReached(startDate, backSyncDateGoal)) { guestService.removeApiKeyAttribute(updateInfo.apiKey.getId(), BACKSYNC_START_DATE_ATT_KEY); guestService.removeApiKeyAttribute(updateInfo.apiKey.getId(), BACKSYNC_DAYS_GOAL_ATT_KEY); startDate = getBackSyncStartDate(updateInfo); backSyncDateGoal = getBackSyncDateGoal(updateInfo); } else { startDate = startDate.minusDays(1); // remember up to when we were able to sync back so far guestService.setApiKeyAttribute(updateInfo.apiKey, BACKSYNC_START_DATE_ATT_KEY, TimeUtils.dateFormatter.print(startDate)); } } } public void afterHistoryUpdate(final UpdateInfo updateInfo) { scheduleAggressiveBackSync(updateInfo, System.currentTimeMillis()); } public void afterConnectorUpdate(final UpdateInfo updateInfo) { scheduleAggressiveBackSync(updateInfo, System.currentTimeMillis() + DateTimeConstants.MILLIS_PER_HOUR); } // This allows to exceptionnally override the standard update scheduling mechanism: fitbit doesn't support updatedSince // semantics meaning we have to be aggressive about synching private void scheduleAggressiveBackSync(final UpdateInfo updateInfo, final long when) { logger.info("scheduling next backsynching operations , apiKeyId=" + updateInfo.apiKey.getId()); connectorUpdateService.scheduleUpdate(updateInfo.apiKey, 0, UpdateInfo.UpdateType.INCREMENTAL_UPDATE, when); } static boolean isGoalReached(LocalDate startDate, String backSyncDateGoal) { LocalDate backSyncDate = new LocalDate(backSyncDateGoal); if (backSyncDate.isEqual(startDate)||backSyncDate.isAfter(startDate)) return true; return false; } private LocalDate getBackSyncStartDate(final UpdateInfo updateInfo) { // return either today (and store it as an ApiKeyAttribute) or what we have already saved from a previous run final String backSyncStartDateAtt = guestService.getApiKeyAttribute(updateInfo.apiKey, BACKSYNC_START_DATE_ATT_KEY); if (backSyncStartDateAtt!=null) { return new LocalDate(backSyncStartDateAtt); } else { DateTimeZone zone = getFitbitUserTimezone(updateInfo); LocalDate backSyncStartDate = new LocalDate(zone); guestService.setApiKeyAttribute(updateInfo.apiKey, BACKSYNC_START_DATE_ATT_KEY, TimeUtils.dateFormatter.print(backSyncStartDate)); return backSyncStartDate; } } private String getBackSyncDateGoal(final UpdateInfo updateInfo) { // either use the date we had computed on a previous run or subtract 'fitbit.backsync.days' from today and // store that as an ApiKeyAttribute String backSyncDateGoal = guestService.getApiKeyAttribute(updateInfo.apiKey, BACKSYNC_DAYS_GOAL_ATT_KEY); if (backSyncDateGoal==null) { int fitbitBacksyncDays = 30; final String fitbitBacksyncDaysProperty = env.get("fitbit.backsync.days"); if (fitbitBacksyncDaysProperty!=null) fitbitBacksyncDays = Integer.valueOf(fitbitBacksyncDaysProperty); DateTimeZone fitbitUserTimezone = getFitbitUserTimezone(updateInfo); LocalDate today = new LocalDate(fitbitUserTimezone); String computedBackSyncDateGoal = ISODateTimeFormat.date().print(today.minusDays(fitbitBacksyncDays)); String memberSince = getMemberSince(updateInfo); backSyncDateGoal = mostRecentDate(computedBackSyncDateGoal, memberSince); guestService.setApiKeyAttribute(updateInfo.apiKey, BACKSYNC_DAYS_GOAL_ATT_KEY, backSyncDateGoal); } return backSyncDateGoal; } private String mostRecentDate(String computedBackSyncDateGoal, String memberSince) { LocalDate computedDate = ISODateTimeFormat.date().parseLocalDate(computedBackSyncDateGoal); LocalDate memberSinceDate = ISODateTimeFormat.date().parseLocalDate(memberSince); return computedDate.isAfter(memberSinceDate) ? computedBackSyncDateGoal : memberSince; } private String getMemberSince(UpdateInfo updateInfo) { FitbitUserProfile userProfile = jpaDaoService.findOne("fitbitUser.byApiKeyId", FitbitUserProfile.class, updateInfo.apiKey.getId()); return userProfile.memberSince; } private DateTimeZone getFitbitUserTimezone(UpdateInfo updateInfo) { DateTimeZone zone = DateTimeZone.UTC; FitbitUserProfile userProfile = jpaDaoService.findOne("fitbitUser.byApiKeyId", FitbitUserProfile.class, updateInfo.apiKey.getId()); if (userProfile!=null) try { zone = DateTimeZone.forID(userProfile.timezone);} catch(Exception e){} return zone; } private boolean isIntradayDataEnabled() { final String intradayEnabledProperty = env.get("fitbit.intraday.enabled"); if (intradayEnabledProperty !=null && Boolean.valueOf(intradayEnabledProperty)) return true; return false; } private void checkLateAdditions(UpdateInfo updateInfo) throws RateLimitReachedException, UpdateFailedException, AuthExpiredException, NoSuchFieldException, IllegalAccessException, UnexpectedResponseCodeException { // 10/27/2014 Adding support for food logging. This requires Fitbit's User info to be persisted // so we can retrieve a guest ID with Fitbit's 'encodedId' hash maybeRetrieveFitbitUserInfo(updateInfo); // Also, food logging requires that the user subscribe to fitbit's notifications // maybeSubscribeToFitbitNotifications(updateInfo); // We want to fill-in the historical caloriesIn data maybeImportCaloriesInHistory(updateInfo); } private void maybeImportCaloriesInHistory(UpdateInfo updateInfo) throws RateLimitReachedException { if (guestService.getApiKeyAttribute(updateInfo.apiKey, CALORIES_IN_HISTORY_IMPORTED_ATT_KEY)!=null) return; loadTimeSeries("foods/log/caloriesIn", updateInfo, foodLogSummaryOT, "calories"); // add in water intake for good measure loadTimeSeries("foods/log/water", updateInfo, foodLogSummaryOT, "water"); bodyTrackStorageService.storeInitialHistory(updateInfo.apiKey, foodLogSummaryOT.value()); guestService.setApiKeyAttribute(updateInfo.apiKey, CALORIES_IN_HISTORY_IMPORTED_ATT_KEY, String.valueOf(true)); } private void maybeSubscribeToFitbitNotifications(UpdateInfo updateInfo) throws UpdateFailedException, RateLimitReachedException, AuthExpiredException { if (guestService.getApiKeyAttribute(updateInfo.apiKey, IS_GUEST_SUBSCRIBED_TO_FITBIT_NOTIFICATIONS_ATT_KEY)!=null) return; try { String fitbitSubscriberId = env.get("fitbitSubscriberId"); makeRestCall(updateInfo, ObjectType.getCustomObjectType(SUBSCRIBE_TO_FITBIT_NOTIFICATIONS_CALL).value(), "https://api.fitbit.com/1/user/-/apiSubscriptions/" + fitbitSubscriberId + ".json", "POST"); } catch (UnexpectedResponseCodeException e) { if (e.responseCode==409) { notificationsService.addNamedNotification(updateInfo.getGuestId(), Notification.Type.WARNING, "Subscription Conflict", "It looks like you are already subscribed to notifications with this key on another server (http error code: " + e.responseCode + ")"); } logger.warn("Could not subscribe guest " + updateInfo.getGuestId() + " to fitbit notifications (Fitbit API's HTTP code: " + e.responseCode + ")"); return; } guestService.setApiKeyAttribute(updateInfo.apiKey, IS_GUEST_SUBSCRIBED_TO_FITBIT_NOTIFICATIONS_ATT_KEY, String.valueOf(true)); } private void maybeRetrieveFitbitUserInfo(UpdateInfo updateInfo) throws AuthExpiredException, RateLimitReachedException, UpdateFailedException, UnexpectedResponseCodeException { if (guestService.getApiKeyAttribute(updateInfo.apiKey, HAS_FITBIT_USER_PROFILE_ATT_KEY)!=null) return; final String userProfileJson = makeRestCall(updateInfo, ObjectType.getCustomObjectType(GET_USER_PROFILE_CALL).value(), "https://api.fitbit.com/1/user/-/profile.json"); JSONObject json = JSONObject.fromObject(userProfileJson); if (!json.has("user")) { logger.warn("No Fitbit user profile, retrieved json: " + userProfileJson); return; } JSONObject user = json.getJSONObject("user"); FitbitUserProfile fitbitUserProfile = new FitbitUserProfile(); fitbitUserProfile.encodedId = user.getString("encodedId"); fitbitUserProfile.apiKeyId = updateInfo.apiKey.getId(); if (user.has("aboutMe")) fitbitUserProfile.aboutMe = user.getString("aboutMe"); if (user.has("city")) fitbitUserProfile.city = user.getString("city"); if (user.has("country")) fitbitUserProfile.country = user.getString("country"); if (user.has("dateOfBirth")) fitbitUserProfile.dateOfBirth = user.getString("dateOfBirth"); if (user.has("displayName")) fitbitUserProfile.displayName = user.getString("displayName"); if (user.has("fullName")) fitbitUserProfile.fullName = user.getString("fullName"); if (user.has("gender")) fitbitUserProfile.gender = user.getString("gender"); if (user.has("height")) fitbitUserProfile.height = user.getDouble("height"); if (user.has("nickname")) fitbitUserProfile.nickname = user.getString("nickname"); if (user.has("offsetFromUTCMillis")) fitbitUserProfile.offsetFromUTCMillis = user.getLong("offsetFromUTCMillis"); if (user.has("state")) fitbitUserProfile.state = user.getString("state"); if (user.has("strideLengthRunning")) fitbitUserProfile.strideLengthRunning = user.getDouble("strideLengthRunning");; if (user.has("strideLengthWalking")) fitbitUserProfile.strideLengthWalking = user.getDouble("strideLengthWalking"); if (user.has("timezone")) fitbitUserProfile.timezone = user.getString("timezone"); if (user.has("weight")) fitbitUserProfile.weight = user.getDouble("weight"); if (user.has("memberSince")) fitbitUserProfile.memberSince = user.getString("memberSince"); if (user.has("weightUnit")) fitbitUserProfile.weightUnit = user.getString("weightUnit"); if (user.has("heightUnit")) fitbitUserProfile.heightUnit = user.getString("heightUnit"); if (user.has("glucoseUnit")) fitbitUserProfile.glucoseUnit = user.getString("glucoseUnit"); if (user.has("waterUnit")) fitbitUserProfile.waterUnit = user.getString("waterUnit"); if (user.has("avatar")) fitbitUserProfile.avatar = user.getString("avatar"); if (user.has("avatar150")) fitbitUserProfile.avatar150 = user.getString("avatar150"); if (user.has("startDayOfWeek")) fitbitUserProfile.startDayOfWeek = user.getString("startDayOfWeek"); facetDao.persist(fitbitUserProfile); guestService.setApiKeyAttribute(updateInfo.apiKey, HAS_FITBIT_USER_PROFILE_ATT_KEY, String.valueOf(true)); } private void loadIntradayDataForOneDay(UpdateInfo updateInfo, String dateString) throws AuthExpiredException, RateLimitReachedException, UpdateFailedException, NoSuchFieldException, IllegalAccessException, UnexpectedResponseCodeException { if (!isIntradayDataEnabled()) return; final List<AbstractFacet> facetsByDates = facetDao.getFacetsByDates(updateInfo.apiKey, ObjectType.getObjectType(connector(), 1), Arrays.asList(dateString), null); if (facetsByDates.size()>0) { final AbstractFacet facet = facetsByDates.get(0); if (facet!=null) { FitbitTrackerActivityFacet activityFacet = (FitbitTrackerActivityFacet) facet; updateIntradayMetrics(updateInfo, activityFacet); bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(facet)); } else { logger.warn("TRYING TO UPDATE INTRADAY DATA OF AN UNEXISTING FACET, dateString=" + dateString); } } } private void updateIntradayMetrics(UpdateInfo updateInfo, FitbitTrackerActivityFacet activityFacet) throws RateLimitReachedException, AuthExpiredException, UpdateFailedException, UnexpectedResponseCodeException { try { List<String> wantedMetrics = Arrays.asList("steps", "calories", "distance", "floors", "elevation"); final Object wantedMetricsProperty = env.oauth.getProperty("fitbit.intraday.metrics.wanted"); if (wantedMetricsProperty!=null&&wantedMetricsProperty instanceof List) { List<String> wantedMetricsConfig = (List<String>) wantedMetricsProperty; if (wantedMetricsConfig.size()>0) wantedMetrics = wantedMetricsConfig; } for (String metric : wantedMetrics) { updateIntradayMetric(activityFacet, updateInfo, metric); } // following exception should never happen } catch (IllegalAccessException e) { throw new UpdateFailedException(); } catch (NoSuchFieldException e) { throw new UpdateFailedException(); } } public void updateIntradayMetric(final FitbitTrackerActivityFacet facet, final UpdateInfo updateInfo, final String metric) throws RateLimitReachedException, AuthExpiredException, UpdateFailedException, IllegalAccessException, NoSuchFieldException, UnexpectedResponseCodeException { Field field = FitbitTrackerActivityFacet.class.getField(metric + "Json"); if (facet.date != null) { String json = makeRestCall(updateInfo, String.format("activities/log/%s/date", metric).hashCode(), String.format("https://api.fitbit.com/1/user/-/activities/log/%s/date/", metric) + facet.date + "/1d.json"); field.set(facet, json); facetDao.merge(facet); } else { logger.warn("guestId=" + updateInfo.getGuestId() + " connector=fitbit action=updateIntradayMetric metric=" + metric + "message=facet date is null"); } } private void updateTrackerListOfDays(final UpdateInfo updateInfo, final List<String> trackerDaysToSync, final long trackerLastServerSyncMillis) throws Exception { for (String dateString : trackerDaysToSync) updateOneDayOfTrackerData(updateInfo, dateString, trackerLastServerSyncMillis); } private void updateScaleListOfDays(final UpdateInfo updateInfo, final List<String> scaleDaysToSync, final long scaleLastServerSyncMillis) throws Exception { for (String dateString : scaleDaysToSync) updateOneDayOfScaleData(updateInfo, dateString, scaleLastServerSyncMillis); } private void updateOneDayOfTrackerData(final UpdateInfo updateInfo, final String dateString, final long trackerLastServerSyncMillis) throws Exception { updateInfo.setContext("date", dateString); logger.info("guestId=" + updateInfo.getGuestId() + " objectType=sleep" + " connector=fitbit action=updateOneDayOfData date=" + dateString); apiDataService.eraseApiData(updateInfo.apiKey, sleepOT, Arrays.asList(dateString)); loadSleepDataForOneDay(updateInfo, dateString); logger.info("guestId=" + updateInfo.getGuestId() + " objectType=activity" + " connector=fitbit action=updateOneDayOfData date=" + dateString); apiDataService.eraseApiData(updateInfo.apiKey, activityOT, Arrays.asList(dateString)); apiDataService.eraseApiData(updateInfo.apiKey, loggedActivityOT, Arrays.asList(dateString)); loadActivityDataForOneDay(updateInfo, dateString); loadIntradayDataForOneDay(updateInfo, dateString); // If all that succeeded, record the minimum of the end of the day "date" and scaleLastServerSyncMillis // as TRACKER.lastSyncDate. If scaleLastServerSyncMillis> the end of the day "date" then we already know // we have complete data for "date", and if we fail we should start with the day after that. // If scaleLastServerSyncMillis < the end of the day "date" then we have incomplete data for today // and will need to try it again next time. // Compute the start and end of that date in milliseconds for comparing // and truncating start/end DateTimeZone dateTimeZone = DateTimeZone.UTC; LocalDate localDate = LocalDate.parse(dateString); long dateEndMillis = localDate.toDateTimeAtStartOfDay(dateTimeZone).getMillis() + DateTimeConstants.MILLIS_PER_DAY; if(dateEndMillis<trackerLastServerSyncMillis) { guestService.setApiKeyAttribute(updateInfo.apiKey, "TRACKER.lastSyncDate", String.valueOf(dateEndMillis)); } } private void updateOneDayOfScaleData(final UpdateInfo updateInfo, final String dateString, final long scaleLastServerSyncMillis) throws Exception { updateInfo.setContext("date", dateString); logger.info("guestId=" + updateInfo.getGuestId() + " objectType=weight" + " connector=fitbit action=updateOneDayOfData date=" + dateString); apiDataService.eraseApiData(updateInfo.apiKey, weightOT, Arrays.asList(dateString)); loadWeightDataForOneDay(updateInfo, dateString); // If that succeeded, update where to start next time. If scaleLastServerSyncMillis is not -1, // meaning we have a hardware scale, record the minimum of the end of the day "date" and scaleLastServerSyncMillis // as SCALE.lastSyncDate. If scaleLastServerSyncMillis> the end of the day "date" then we already know // we have complete data for "date", and if we fail we should start with the day after that. // If scaleLastServerSyncMillis < the end of the day "date" then we have incomplete data for today // and will need to try it again next time. If scaleLastServerSyncMillis is -1, we don't have a // hardware scale. Just record the min of the end of this date and when we just asked. // Compute the start and end of that date in milliseconds for comparing // and truncating start/end DateTimeZone dateTimeZone = DateTimeZone.UTC; LocalDate localDate = LocalDate.parse(dateString); long dateEndMillis = localDate.toDateTimeAtStartOfDay(dateTimeZone).getMillis() + DateTimeConstants.MILLIS_PER_DAY; if(scaleLastServerSyncMillis==-1 || dateEndMillis< scaleLastServerSyncMillis) { guestService.setApiKeyAttribute(updateInfo.apiKey, "SCALE.lastSyncDate", String.valueOf(dateEndMillis)); } } private void loadWeightDataForOneDay(UpdateInfo updateInfo, String formattedDate) throws Exception { String json = getWeightData(updateInfo, formattedDate); String fatJson = getBodyFatData(updateInfo, formattedDate); JSONObject jsonWeight = JSONObject.fromObject(json); JSONObject jsonFat = JSONObject.fromObject(fatJson); json = mergeWeightInfos(jsonWeight, jsonFat); logger.info("guestId=" + updateInfo.getGuestId() + " connector=fitbit action=loadWeightDataForOneDay json=" + json); apiDataService.eraseApiData(updateInfo.apiKey, weightOT, Arrays.asList(formattedDate)); if (json != null) { JSONObject fitbitResponse = JSONObject.fromObject(json); final List<FitbitWeightFacet> createdOrUpdatedWeightFacets = fitbitPersistenceHelper.createOrUpdateWeightFacets(updateInfo, fitbitResponse); bodyTrackStorageService.storeApiData(updateInfo.apiKey, createdOrUpdatedWeightFacets); } } private static String mergeWeightInfos(final JSONObject jsonWeight, final JSONObject jsonFat) { JSONArray weightArray = jsonWeight.getJSONArray("weight"); JSONArray fatArray = jsonFat.getJSONArray("fat"); for(int i=0; i<weightArray.size(); i++) { JSONObject weightJSON = weightArray.getJSONObject(i); long logId = weightJSON.getLong("logId"); for(int j=0; j<fatArray.size(); j++) { JSONObject fatJSON = fatArray.getJSONObject(j); long otherLogId = fatJSON.getLong("logId"); if (otherLogId==logId) { double fat = fatJSON.getDouble("fat"); weightJSON.put("fat", fat); } } } return jsonWeight.toString(); } private void loadActivityDataForOneDay(UpdateInfo updateInfo, String formattedDate) throws Exception { updateInfo.setContext("date", formattedDate); String json = getActivityData(updateInfo, formattedDate); logger.info("guestId=" + updateInfo.getGuestId() + " connector=fitbit action=loadActivityDataForOneDay json=" + json); if (json != null) { JSONObject fitbitResponse = JSONObject.fromObject(json); fitbitPersistenceHelper.createOrUpdateLoggedActivities(updateInfo, fitbitResponse); final FitbitTrackerActivityFacet createdOrUpdatedActivitySummary = fitbitPersistenceHelper.createOrUpdateActivitySummary(updateInfo, fitbitResponse); bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(createdOrUpdatedActivitySummary)); } } private void loadSleepDataForOneDay(UpdateInfo updateInfo, String formattedDate) throws Exception { updateInfo.setContext("date", formattedDate); String json = getSleepData(updateInfo, formattedDate); apiDataService.eraseApiData(updateInfo.apiKey, sleepOT, Arrays.asList(formattedDate)); if (json != null) { JSONObject fitbitResponse = JSONObject.fromObject(json); fitbitPersistenceHelper.createOrUpdateSleepFacets(updateInfo, fitbitResponse); } } private void loadFoodDataForOneDay(UpdateInfo updateInfo, String formattedDate) throws Exception { updateInfo.setContext("date", formattedDate); final int objectTypes = foodLogEntryOT.value() + foodLogSummaryOT.value(); String urlString = "https://api.fitbit.com/1/user/-/foods/log/date/" + formattedDate + ".json"; String json = makeRestCall(updateInfo, objectTypes, urlString); apiDataService.eraseApiData(updateInfo.apiKey, foodLogEntryOT, Arrays.asList(formattedDate)); apiDataService.eraseApiData(updateInfo.apiKey, foodLogSummaryOT, Arrays.asList(formattedDate)); if (json != null) { JSONObject fitbitResponse = JSONObject.fromObject(json); JSONArray foodEntries = fitbitResponse.getJSONArray("foods"); if (foodEntries == null || foodEntries.size() == 0) return; Iterator iterator = foodEntries.iterator(); // use createOrUpdate for food log entries (can't rely on date boundaries for unicity) while (iterator.hasNext()) { JSONObject foodEntry = (JSONObject) iterator.next(); fitbitPersistenceHelper.createOrUpdateFoodEntry(updateInfo, foodEntry); } final FitbitFoodLogSummaryFacet createdOrUpdatedFoodLogSummaryFacet = fitbitPersistenceHelper.createOrUpdateFoodLogSummaryFacet(updateInfo, fitbitResponse); bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(createdOrUpdatedFoodLogSummaryFacet)); } } private String getSleepData(UpdateInfo updateInfo, String formattedDate) throws RateLimitReachedException, UnexpectedResponseCodeException, UpdateFailedException, AuthExpiredException { String urlString = "https://api.fitbit.com/1/user/-/sleep/date/" + formattedDate + ".json"; String json = makeRestCall(updateInfo, sleepOT.value(), urlString); return json; } private String getWeightData(UpdateInfo updateInfo, String formattedDate) throws RateLimitReachedException, UnexpectedResponseCodeException, UpdateFailedException, AuthExpiredException { String urlString = "https://api.fitbit.com/1/user/-/body/log/weight/date/" + formattedDate + ".json"; String json = makeRestCall(updateInfo, weightOT.value(), urlString); return json; } private String getBodyFatData(UpdateInfo updateInfo, String formattedDate) throws RateLimitReachedException, UnexpectedResponseCodeException, UpdateFailedException, AuthExpiredException { String urlString = "https://api.fitbit.com/1/user/-/body/log/fat/date/" + formattedDate + ".json"; String json = makeRestCall(updateInfo, weightOT.value(), urlString); return json; } private String getActivityData(UpdateInfo updateInfo, String formattedDate) throws RateLimitReachedException, UnexpectedResponseCodeException, UpdateFailedException, AuthExpiredException { String urlString = "https://api.fitbit.com/1/user/-/activities/date/" + formattedDate + ".json"; String json = makeRestCall(updateInfo, activityOT.value() + loggedActivityOT.value(), urlString); return json; } @RequestMapping("/fitbit/notify") public void notifyMeasurement( HttpServletResponse response, @RequestBody String updatesString) throws Exception { if (StringUtils.isEmpty(updatesString)) { response.sendError(400); } String lines[] = updatesString.split("\\r?\\n"); for (String line : lines) { if (line.startsWith("[{\"collectionType")) { updatesString = line; break; } } logger.info("action=apiNotification connector=fitbit message=" + updatesString); try { JSONArray updatesArray = JSONArray.fromObject(updatesString); for (int i = 0; i < updatesArray.size(); i++) { JSONObject jsonUpdate = updatesArray.getJSONObject(i); String collectionType = jsonUpdate.getString("collectionType"); // warning: 'body' doesn't have a date!!! if (collectionType.equals("body")) continue; String dateString = jsonUpdate.getString("date"); String ownerId = jsonUpdate.getString("ownerId"); String subscriptionId = jsonUpdate.getString("subscriptionId"); FitbitUserProfile userProfile = jpaDaoService.findOne("fitbitUser.byEncodedId", FitbitUserProfile.class, ownerId); int objectTypes = 0; if (collectionType.equals("activities")) { objectTypes = 3; } else if (collectionType.equals("sleep")) { objectTypes = 4; } else if (collectionType.equals("body")) { objectTypes = 8; } else if (collectionType.equals("foods")) { objectTypes = 16+32; } ApiKey apiKey = guestService.getApiKey(userProfile.apiKeyId); connectorUpdateService.addApiNotification(connector(), apiKey.getGuestId(), updatesString); JSONObject jsonParams = new JSONObject(); jsonParams.accumulate("date", dateString) .accumulate("ownerId", ownerId) .accumulate("subscriptionId", subscriptionId); logger.info("action=scheduleUpdate connector=fitbit collectionType=" + collectionType); connectorUpdateService.scheduleUpdate(apiKey, objectTypes, UpdateInfo.UpdateType.PUSH_TRIGGERED_UPDATE, System.currentTimeMillis(), jsonParams.toString()); } } catch (Exception e) { e.printStackTrace(); logger.warn("Could not parse fitbit notification: " + Utils.stackTrace(e)); } } private final String makeRestCall(final UpdateInfo updateInfo, final int objectTypes, final String urlString, final String...method) throws RateLimitReachedException, UpdateFailedException, AuthExpiredException, UnexpectedResponseCodeException { if (guestService.getApiKeyAttribute(updateInfo.apiKey, FitbitOAuthController.HAS_OAUTH2)==null) controller.upgrade2OAuth2(updateInfo.apiKey); // if have already called the API from within this thread, the allowed remaining API calls will be saved // in the updateInfo final Integer remainingAPICalls = updateInfo.getRemainingAPICalls("fitbit"); if (remainingAPICalls==null) { // otherwise, it means this is the first time we are calling the API // from within this update. It is possible that a previous update has consumed the entire API quota. // In this case, it has saved Fitbit's reset time as an ApiKey attribute. If however, this is the // very first API call for this connector, we are most probably not rate limited and so we can continue. String apiKeyAttResetTime = guestService.getApiKeyAttribute(updateInfo.apiKey, "resetTime"); if (apiKeyAttResetTime!=null) { long resetTime = Long.valueOf(apiKeyAttResetTime); if (resetTime>System.currentTimeMillis()) { // reset updateInfo's reset time to this stored value so we don't delay next update to a default amount updateInfo.setResetTime("fitbit", resetTime); throw new RateLimitReachedException(); } } } if (remainingAPICalls!=null&&remainingAPICalls<1) throw new RateLimitReachedException(); try { long then = System.currentTimeMillis(); URL url = new URL(urlString); HttpURLConnection request = (HttpURLConnection) url.openConnection(); if (method!=null && method.length>0) request.setRequestMethod(method[0]); String accessToken = controller.getAccessToken(updateInfo.apiKey); request.setRequestProperty("Authorization", "Bearer " + accessToken); request.connect(); final int httpResponseCode = request.getResponseCode(); final String httpResponseMessage = request.getResponseMessage(); // retrieve and save rate limiting metadata final String remainingCalls = request.getHeaderField("Fitbit-Rate-Limit-Remaining"); if (remainingCalls!=null) { updateInfo.setRemainingAPICalls("fitbit", Integer.valueOf(remainingCalls)); guestService.setApiKeyAttribute(updateInfo.apiKey, "remainingCalls", remainingCalls); if (Integer.valueOf(remainingCalls)==0) setResetTime(updateInfo, request); } if (httpResponseCode == 200 || httpResponseCode == 201) { String json = IOUtils.toString(request.getInputStream()); connectorUpdateService.addApiUpdate(updateInfo.apiKey, objectTypes, then, System.currentTimeMillis() - then, urlString, true, httpResponseCode, httpResponseMessage); // logger.info(updateInfo.apiKey.getGuestId(), "REST call success: " + // urlString); return json; } else { connectorUpdateService.addApiUpdate(updateInfo.apiKey, objectTypes, then, System.currentTimeMillis() - then, urlString, false, httpResponseCode, httpResponseMessage); // try to retrieve the response body, but don't despair if we fail in doing so String responseBody = null; try { responseBody = IOUtils.toString(request.getInputStream()); logger.warn(String.format("There was a problem calling the Fitbit API (%s, %s):\n", httpResponseCode, httpResponseMessage) + responseBody); } catch (Throwable t) {logger.warn(String.format("Error calling the Fitbit API (%s, %s). Could not retrieve response body.", httpResponseCode, httpResponseMessage));} // Check for response code 429 which is Fitbit's over rate limit error if(httpResponseCode == 429) { logger.warn("Darn, we hit Fitbit's rate limit again! url=" + urlString + ", guest=" + updateInfo.getGuestId()); // try to retrieve the reset time from Fitbit, otherwise default to a one hour delay // also, set resetTime as an apiKey attribute so we don't retry calling the API too soon setResetTime(updateInfo, request); throw new RateLimitReachedException(); } else { if (httpResponseCode == 409) { // this is to account for this method being called when adding an api Subscription throw new UnexpectedResponseCodeException(httpResponseCode, httpResponseMessage, urlString); } // Otherwise throw the same error that SignpostOAuthHelper used to throw if (httpResponseCode == 401) { try { boolean isOauth2ExpiredToken = false; JSONObject errorJson = JSONObject.fromObject(responseBody); JSONArray jsonErrors = errorJson.getJSONArray("errors"); for (int i=0; i<jsonErrors.size(); i++) { JSONObject jsonError = jsonErrors.getJSONObject(i); if (jsonError.has("errorType")&&jsonError.get("errorType").equals("expired_token")) { isOauth2ExpiredToken = true; break; } } if (isOauth2ExpiredToken) { logger.warn("oauth2 token expired, this is a bug as it should have been handled in controller's getAccessToken method"); } else { logger.debug("Fitbit 401, error: " + responseBody); } throw new AuthExpiredException(); } catch (Throwable e) { throw new AuthExpiredException(); } } else if (httpResponseCode >= 400 && httpResponseCode < 500) { String message = "Unexpected response code: " + httpResponseCode; if (responseBody!=null) message += "\nMessage from server:\n" + responseBody; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.clientError(httpResponseCode)); } String reason = "Error: " + httpResponseCode; if (responseBody!=null) reason += "\nMessage from server:\n" + responseBody; throw new UpdateFailedException(false, reason); } } } catch (IOException exc) { throw new RuntimeException("IOException trying to make rest call: " + exc.getMessage()); } } private void setResetTime(UpdateInfo updateInfo, HttpURLConnection request) { final String rateLimitResetSeconds = request.getHeaderField("Fitbit-Rate-Limit-Reset"); if (rateLimitResetSeconds!=null) { int millisUntilReset = Integer.valueOf(rateLimitResetSeconds)*1000; // delay by one minute to compensate for clock desynchronisation final long resetTime = System.currentTimeMillis() + millisUntilReset + DateTimeConstants.MILLIS_PER_MINUTE; guestService.setApiKeyAttribute(updateInfo.apiKey, "resetTime", String.valueOf(resetTime)); updateInfo.setResetTime("fitbit", resetTime); } else { final long resetTime = System.currentTimeMillis() + 60 * DateTimeConstants.MILLIS_PER_HOUR; guestService.setApiKeyAttribute(updateInfo.apiKey, "resetTime", String.valueOf(resetTime)); updateInfo.setResetTime("fitbit", resetTime); } } @Override public void setDefaultChannelStyles(ApiKey apiKey) { BodyTrackHelper.ChannelStyle channelStyle = new BodyTrackHelper.ChannelStyle(); channelStyle.timespanStyles = new BodyTrackHelper.MainTimespanStyle(); channelStyle.timespanStyles.defaultStyle = new BodyTrackHelper.TimespanStyle(); channelStyle.timespanStyles.defaultStyle.fillColor = "#21b5cf"; channelStyle.timespanStyles.defaultStyle.borderColor = "#21b5cf"; channelStyle.timespanStyles.defaultStyle.borderWidth = 2; channelStyle.timespanStyles.defaultStyle.top = 0.0; channelStyle.timespanStyles.defaultStyle.bottom = 1.0; channelStyle.timespanStyles.values = new HashMap(); BodyTrackHelper.TimespanStyle stylePart = new BodyTrackHelper.TimespanStyle(); stylePart.top = 0.25; stylePart.bottom = 0.75; stylePart.fillColor = "#21b5cf"; stylePart.borderColor = "#21b5cf"; channelStyle.timespanStyles.values.put("on",stylePart); bodyTrackHelper.setBuiltinDefaultStyle(apiKey.getGuestId(), apiKey.getConnector().getName(),"sleep",channelStyle); } }