/* Copyright (c) 2013, Sean Rees <sean@rees.us>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.runnerup.export;
import android.annotation.TargetApi;
import android.content.ContentValues;
import android.database.sqlite.SQLiteDatabase;
import android.os.Build;
import android.util.Log;
import android.util.Pair;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.runnerup.common.util.Constants.DB;
import org.runnerup.export.format.TCX;
import org.runnerup.export.util.Part;
import org.runnerup.export.util.StringWritable;
import org.runnerup.export.util.SyncHelper;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.StringWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@TargetApi(Build.VERSION_CODES.FROYO)
public class DigifitSynchronizer extends DefaultSynchronizer {
public static final String DIGIFIT_URL = "http://my.digifit.com";
public static final String NAME = "Digifit";
public static void main(String args[]) throws Exception {
if (args.length < 2) {
Log.e("DigifitSynchronizer", "usage: DigifitSynchronizer username password");
System.exit(1);
}
String username = args[0];
String password = args[1];
DigifitSynchronizer du = new DigifitSynchronizer(null);
du.init(username, password);
Log.e("DigifitSynchronizer", du.connect().toString());
}
private long _id;
private boolean _loggedin;
private String _password;
private String _username;
DigifitSynchronizer(SyncManager unused) {
}
private JSONObject buildRequest(String root, Map<String, String> requestParameters)
throws JSONException {
JSONObject json = new JSONObject();
JSONObject request = new JSONObject(requestParameters);
json.put(root, request);
return json;
}
private JSONObject callDigifitEndpoint(String url, JSONObject request) throws IOException,
MalformedURLException,
ProtocolException, JSONException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setDoOutput(true);
conn.setRequestMethod(RequestMethod.POST.name());
conn.addRequestProperty("Content-Type", "application/x-www-form-urlencoded");
addCookies(conn);
OutputStream out = conn.getOutputStream();
out.write(request.toString().getBytes());
out.flush();
out.close();
JSONObject response = null;
if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
try {
response = SyncHelper.parse(conn.getInputStream());
} finally {
conn.disconnect();
}
}
return response;
}
@Override
public boolean checkSupport(Feature f) {
switch (f) {
case UPLOAD:
return true;
case FEED:
case LIVE:
case WORKOUT_LIST: // list of prepared work outs (e.g an interval
// program)
case GET_WORKOUT: // download a prepared work out (e.g an interval
// program)
case SKIP_MAP:
return false;
}
return false;
}
@Override
public Status connect() {
if (!isConfigured()) {
// user/pass needed
Status s = Status.NEED_AUTH;
s.authMethod = Synchronizer.AuthMethod.USER_PASS;
return s;
}
if (_loggedin) {
return Synchronizer.Status.OK;
}
JSONObject credentials = new JSONObject();
try {
credentials.put("login", _username);
credentials.put("password", _password);
} catch (JSONException e) {
e.printStackTrace();
return Synchronizer.Status.INCORRECT_USAGE;
}
Status errorStatus = Status.ERROR;
try {
HttpURLConnection conn = (HttpURLConnection) new URL(DIGIFIT_URL + "/site/authenticate")
.openConnection();
conn.setDoOutput(true);
conn.setRequestMethod(RequestMethod.POST.name());
conn.addRequestProperty("Content-Type", "application/x-www-form-urlencoded");
OutputStream out = conn.getOutputStream();
out.write(credentials.toString().getBytes());
out.flush();
out.close();
/*
* A success message looks like:
* <response><result>success</result></response> A failure message
* looks like: <response><error code="1102"
* message="Login or Password is not correct" /></response> For
* flexibility (and ease), we won't do full XML parsing here. We'll
* simply look for a few key tokens and hope that's good enough.
*/
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line = in.readLine();
if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
if (line.contains("<result>success</result>")) {
// Store the authentication token.
getCookies(conn);
_loggedin = true;
return Status.OK;
} else {
Status s = Status.NEED_AUTH;
s.authMethod = Synchronizer.AuthMethod.USER_PASS;
Log.e(getName(), "Error: " + line);
return s;
}
}
conn.disconnect();
} catch (Exception ex) {
errorStatus.ex = ex;
ex.printStackTrace();
}
return errorStatus;
}
private void deleteFile(long fileId, String fileType) {
try {
String deleteUrl = DIGIFIT_URL + "/rpc/json/userfile/delete_workout?file_id=" + fileId
+ "&file_type="
+ fileType;
HttpURLConnection conn = (HttpURLConnection) new URL(deleteUrl).openConnection();
conn.setRequestMethod(RequestMethod.GET.name());
conn.addRequestProperty("Referer", DIGIFIT_URL + "/site/workoutimport");
addCookies(conn);
} catch (Exception ex) {
ex.printStackTrace();
}
}
public void downloadActivity(File dst, String key) throws Exception {
Map<String, String> exportParameters = new HashMap<String, String>();
exportParameters.put("id", key);
exportParameters.put("format", "tcx");
long fileId = 0;
int fileSize = 0;
try {
JSONObject exportRequest = buildRequest("workout", exportParameters);
callDigifitEndpoint(DIGIFIT_URL + "/rpc/json/workout/export_web", exportRequest);
// I have observed Digifit taking >15 seconds to generate a file.
for (int i = 0; i < 60; i++) {
JSONObject workoutFile = getWorkoutFileId(key);
if (workoutFile != null) {
fileId = workoutFile.getLong("file_id");
fileSize = workoutFile.getInt("file_size");
break;
}
Thread.sleep(500);
}
if (fileId == 0) {
Log.e(getName(), "export file not ready on Digifit within deadline");
return;
}
String downloadUrl = DIGIFIT_URL + "/workout/download/" + fileId;
HttpURLConnection conn = (HttpURLConnection) new URL(downloadUrl).openConnection();
conn.setRequestMethod(RequestMethod.GET.name());
addCookies(conn);
InputStream in = new BufferedInputStream(conn.getInputStream());
OutputStream out = new FileOutputStream(dst);
int cnt = 0, readLen = 0;
byte buf[] = new byte[1024];
while ((readLen = in.read(buf)) != -1) {
out.write(buf, 0, readLen);
cnt += readLen;
}
Log.e(getName(), "Expected " + fileSize + " bytes, got " + cnt + " bytes: "
+ (fileSize == cnt ? "OK" : "ERROR"));
in.close();
out.close();
conn.disconnect();
} catch (Exception ex) {
ex.printStackTrace();
} finally {
// If we error out above, try to ensure we clean up our mess.
if (fileId == 0) {
deleteFile(fileId, "export");
}
}
}
@Override
public String getAuthConfig() {
JSONObject json = new JSONObject();
try {
json.put("username", _username);
json.put("password", _password);
} catch (JSONException e) {
e.printStackTrace();
}
return json.toString();
}
@Override
public long getId() {
return _id;
}
@Override
public String getName() {
return NAME;
}
private String getUploadUrl() throws IOException, MalformedURLException, ProtocolException,
JSONException {
String getUploadUrl = DIGIFIT_URL + "/rpc/json/workout/import_workouts_url";
JSONObject response = callDigifitEndpoint(getUploadUrl, new JSONObject());
String uploadUrl = response.getJSONObject("response").getJSONObject("upload_url")
.getString("URL");
return uploadUrl;
}
private JSONObject getWorkoutFileId(String key) throws IOException, MalformedURLException,
ProtocolException,
JSONException {
JSONObject exportListResponse = callDigifitEndpoint(DIGIFIT_URL
+ "/rpc/json/workout/export_workouts_list",
new JSONObject());
Log.e(getName(), exportListResponse.toString());
JSONArray exportList = exportListResponse.getJSONObject("response").getJSONArray(
"export_list");
for (int idx = 0;; idx++) {
JSONObject export = exportList.optJSONObject(idx);
if (export == null) {
break;
}
long workoutId = export.getLong("workoutid");
if (("" + workoutId).equals(key)) {
return export;
}
}
return null;
}
@Override
public void init(ContentValues config) {
_id = config.getAsLong("_id");
String auth = config.getAsString(DB.ACCOUNT.AUTH_CONFIG);
if (auth != null) {
try {
JSONObject json = new JSONObject(auth);
String username = json.optString("username", null);
String password = json.optString("password", null);
init(username, password);
} catch (JSONException e) {
e.printStackTrace();
}
}
}
protected void init(String username, String password) {
_username = username;
_password = password;
}
@Override
public boolean isConfigured() {
return _username != null && _password != null;
}
public Status activityList(List<Pair<String, String>> list) {
Status errorStatus = Status.ERROR;
Map<String, String> requestParameters = new HashMap<String, String>();
DateFormat rfc3339fmt = new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss", Locale.US);
Date now = new Date();
/*
* For speed of loading (Digifit can be pokey), this month and last
* month.
*/
Calendar cal = Calendar.getInstance();
cal.setTime(now);
cal.add(Calendar.DATE, -30);
cal.set(Calendar.DAY_OF_MONTH, 1);
cal.set(Calendar.HOUR, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
Date from = cal.getTime();
requestParameters.put("sortOrder", "1"); // Reverse chronological.
requestParameters.put("dateTo", rfc3339fmt.format(now));
requestParameters.put("dateFrom", rfc3339fmt.format(from));
try {
JSONObject request = buildRequest("workout", requestParameters);
JSONObject response = callDigifitEndpoint(DIGIFIT_URL + "/rpc/json/workout/list",
request);
if (response == null)
return errorStatus;
JSONArray workouts = response.getJSONObject("response").getJSONArray("workouts");
for (int idx = 0;; idx++) {
JSONObject workout = workouts.optJSONObject(idx);
if (workout == null) {
break;
}
StringBuilder title = new StringBuilder(workout.getJSONObject("description")
.getString("title"));
String id = "" + workout.getLong("id");
String startTime = workout.getJSONObject("summary").getString("startTime");
// startTime is rfc3339, instead of parsing it, just strip
// everything but the date.
title.append(" (").append(startTime.substring(0, startTime.indexOf("T")))
.append(")");
list.add(new Pair<String, String>(id, title.toString()));
}
return Status.OK;
} catch (Exception ex) {
Log.e(getName(), ex.toString());
errorStatus.ex = ex;
}
return errorStatus;
}
@Override
public void reset() {
init(null, null);
_id = 0L;
_loggedin = false;
}
@Override
public Status upload(SQLiteDatabase db, long mID) {
Status s;
if ((s = connect()) != Status.OK) {
return s;
}
Status errorStatus = Status.ERROR;
TCX tcx = new TCX(db);
tcx.setAddGratuitousTrack(true);
try {
// I wonder why there's an API for getting a special upload path.
// This seems obtuse.
String uploadUrl = getUploadUrl();
Log.e(getName(), "Digifit returned uploadUrl = " + uploadUrl);
StringWriter wr = new StringWriter();
tcx.export(mID, wr);
uploadFileToDigifit(wr.toString(), uploadUrl);
// We're using the form endpoint for the browser rather than what
// the API does so we don't have reliable error information. The
// site returns 200 on both success and failure.
//
// TODO: capture traffic from the app in order to use a better API
// endpoint.
s = Status.OK;
s.activityId = mID;
return s;
} catch (Exception ex) {
errorStatus.ex = ex;
Log.e(getName(), "Digifit returned: " + ex);
}
return errorStatus;
}
private void uploadFileToDigifit(String payload, String uploadUrl) throws Exception {
HttpURLConnection conn = (HttpURLConnection) new URL(uploadUrl).openConnection();
conn.setDoOutput(true);
conn.setRequestMethod(RequestMethod.POST.name());
addCookies(conn);
String filename = "RunnerUp.tcx";
Part<StringWritable> themePart = new Part<StringWritable>("theme", new StringWritable(
SyncHelper.URLEncode("site")));
Part<StringWritable> payloadPart = new Part<StringWritable>("userFiles",
new StringWritable(payload));
payloadPart.setFilename(filename);
payloadPart.setContentType("application/octet-stream");
Part<?> parts[] = {
themePart, payloadPart
};
SyncHelper.postMulti(conn, parts);
int code = conn.getResponseCode();
if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_MOVED_TEMP) {
throw new Exception("got a " + code + " response code from upload");
}
try {
// Digifit takes a little while to process an import -- that is,
// the import we just did above won't show up in this list. In the
// general case, this will remove *old* imports from Digifit only
// leaving the user with ~1ish file of import cruft.
JSONObject response = callDigifitEndpoint(DIGIFIT_URL
+ "/rpc/json/workout/import_workouts_list",
new JSONObject());
JSONArray uploadList = response.getJSONObject("response").getJSONArray("upload_list");
for (int idx = 0;; idx++) {
JSONObject upload = uploadList.optJSONObject(idx);
if (upload == null) {
break;
}
// Only delete files we created.
if (upload.getString("file_name").equals(filename))
deleteFile(upload.getLong("file_id"), "import");
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}