Tracks2Miles 1.1.0

The latest Tracks2Miles update

Added a timeline view to see what you friends have been up to.

*** Edit ***

I’ve just noticed that on some phones that after the update the launch icon will still open the Activity for publishing a new workout rather than the Timeline Activity. This is a “feature” of some of the launchers, you may need to restart your phone to get it to see the update.

*** Edit ***

Fixed up a couple of race condition type problems.

Updated the code to import from My Tracks to use the patch that has been accepted for the next release.

My Tracks Patched

The guys at My Tracks have integrated the changes I sent to enable more data to be passed with the GPX file. There are some slight changes needed to the currently released version of Tracks2Miles but I will push a new version in the next couple of days to be ready for the next My Tracks drop.

I don’t know when the next version of My Tracks will ship yet, but we will be ready,

In the meantime here are the details of the change so others can use it to build similar add-ons for any other site (why they would want to use anything but dailymile?).

As mentioned back in the first post on this subject your going to need an Intent filter that will trap when My Tracks is looking to export a GPX file (the changes to My Tracks apply to all file types, but I’m only going to talk about GPX here). The following is what I’ve been using for Tracks2Miles:

 <activity android:name=".Export"
      android:label="@string/app_name" android:exported="true">
   <intent-filter>
     <action android:name="android.intent.action.SEND" />
     <category android:name="android.intent.category.DEFAULT" />
     <data android:mimeType="application/gpx+xml" />
   </intent-filter>
 </activity>

This hooks the “android.intent.action.SEND” action and filters it for the mimeType “application/gpx+xml”. So in the Activity that gets called when an intent matching this is broadcast you will need a onCreate method that looks something like this.

public void onCreate(Bundle savedInstanceState) {
  Intent intent = getIntent();

  if (intent.getAction().equals(Intent.ACTION_SEND)) {
    if (intent.hasExtra("com.google.android.apps.mytracks.TRACK")) {
      Track track = (Track) intent.getParcelableExtra("com.google.android.apps.mytracks.TRACK");
      ...
    }

    Uri gpxURI = (Uri)intent.getExtras().get(Intent.EXTRA_STREAM);
    ...
  }
}

Where Track is an instance of com.google.android.apps.mytracks.content.Track. You will need to grab this and the 2 classes in the package com.google.android.apps.mytracks.stats so they can be on the classpath for getParcelableExtra to find them. From the Track object you can get hold of name, time and distance of the workout and also there is a Statistics field that has info on average/max speed, elevation information.

I’m planning on writing a My Tracks wiki page about this some time soon where I’ll go in to a little bit more detail but this should get most people started.

Tracks2Miles 1.0.4

A quick update about the 1.0.4 * update release of Tracks2Miles.

  • Added some better error path handling to make it a bit more graceful should it fall over.
  • Added a local DB to cache route information, this is for 2 reasons. Firstly to make it quicker to just pick a route without having to wait for the list to be downloaded and secondly as a work around for the missing distance info in routes uploaded via the API. One of the side effects of this is that previously uploaded routes with no distance will not be shown in the list. Look at the end of the post for a way to fix these routes.
  • Added a bit more validation before posting, this should make it harder to post routes without titles or time and distances. A dialogue will ask if you really want to do this before posting.

I’ve also found a way to fix the uploaded routes missing distances. If you view the routes on the dailymile website, then hit the edit link at the top it will open the route editor. At this point it will recalculate the distance and you can just hit the Update button in the bottom right hand corner. The route should now have a valid distance.

Based on Dan’s comments on the announcement entry, I’m planning on looking to see if I can identify an existing route before posting a new one from My Tracks, this will reduce upload time and allow for dailymile to track your best time. To keep things easy it will probably just be based on the name of the track and distance.

I have added the following short video to the Android Market entry.

YouTube Preview Image

* 1.0.2 & 1.0.3 wouldn’t work on 2.1 due to using a method in API v8, I’ll remember to check 2.1 in the emulator before uploading next time

Announcing Tracks2Miles

OK, so the last 3* posts have been about the development of this so I guess you all knew it was coming, but now after some kind folk (Thanks @davidgeorgeuk, @bodonovan, @TheMightyAl & @ragtag) beta tested it for me it’s time to release it to the world.

A Friday afternoon chat between myself, Karl Roche, Brian O’Donovan and Dale Lane about tracking exercise habits with the website dailymile led to wondering why there was no Android app for it yet. A quick look at the API suggested it shouldn’t be too hard run something up. A short weekend of hacking later the majority of Tracks2Miles was done.
Tracks2Miles Icon
Tracks2Miles is an Android application to take workouts recorded with My Tracks and uploads them dailymile.

Rather than reinvent the wheel and write a full GPS tracking app I decided to see how easy it would be to extend one of the existing apps. My Tracks was one of the first training apps released for Android so it seamed like a good place to start. My Tracks have released a library to allow embedding the GPS tracking into new apps, but even this looked like a lot of work, but My Tracks also has a feature to “Share with friends…” which uses the android.intent.action.SEND intent action to allow other apps to forward on the data in a number of formats, one of which is GPX. This made writing the Tracks2Miles very easy.

Current Features:

  • Export routes from My Tracks – use the “Share with friends…” menu option to share the current track
  • Post workouts without using My Tracks – Tracks2Miles can be launched directly to upload a workout without uploading a new track e.g. treadmill seasons
  • Existing route reuse – start Tracks2Miles from the launcher and hit the “Use existing route” button and you can use routes already uploaded to dailymile

Possible Upcoming Features:

  • Support other apps – add support for exporting from apps like Endomondo or Runkeeper if they can export GPX files
  • Export any GPX – search the file system and upload any GPX found
  • dailymile timeline – a way to see what everybody else has been up to and add comments and motivation
  • Offline caching – save on bandwidth and API calls
  • User preferences – store defaults for Rating and workout type type
  • Contact syncing – add your running buddies to your phones contact list
  • Retry on network loss – uploading long routes can take a while over slow networks so a bit more fault tolerance would be good
  • Widget – because every app needs a widget

Known issues:

  • Facebook login not working – When authorising Tracks2Miles using an account created using your Facebook account it says password is wrong. This looks like either a problem on the dailymile or Facebook side of things, I have reported it
  • Time and distance not filled in when exporting from My Tracks – a patch to make this happen have been submitted to My Tracks, hopefully it will make it into the next release.
  • Reused routes have zero length – Some routes returned by dailymile are shown to have zero length so the distance is not filled in when using them. I have reported this to dailymile.

I’ve tested this on a HTC Desire, a ZTE Blade (Orange San Francisco) (updated to 2.2), a Samsung Galaxy and a Motorola Defy devices along with in the emulator running 2.1, 2.2 and 2.3. It should run on Android 2.1 or newer.

The Android Market URL is https://market.android.com/details?id=uk.me.hardill.dailymile or us the following QR code:

*

  1. My Tracks to dailymile GPX route exporter – part 1
  2. My Tracks to dailymile GPX route exporter – part 2
  3. My Tracks to dailymile GPX route exporter – part 3

My Tracks to dailymile GPX route exporter – part 3

So I’ve got the oAuth 2.0 all sorted it’s time to write the code to do the actual publish to dailymile.

First up I need a UI to capture all the other info about the workout other than the route. Not all of the options on the website are available via the API, but there are enough make a useful post.

I wanted to fill in as many of the fields as possible, so the route name comes from the GPX file name. When My Tracks creates the intent to share the GPX file it adds 2 extras which map to the equivalent of an email subject and email some body text to go with the file, but none of this contains data like the distance covered or the time. This information isn’t explicitly in the default GPX files either.

I could parse the GPX file and generate this info, but it’s going to be a heavy weight process and the data is something My Tracks should already have. I had a quick look at some of the other formats that My Tracks can export and found a the track description field in the KML version which holds the info I’m looking for.


<desc><![CDATA[Created by <a href='http://mytracks.appspot.com'>My Tracks</a< on Android.<p>Total Distance: 223.32 km (138.8 mi)<br>Total Time: 1:57<br>Moving Time: 0:20<br>Average Speed: 6866.26 km/h (4266.5 mi/h)<br> Average Moving Speed: 40197.14 km/h (24977.3 mi/h)<br> Max Speed: 0.00 km/h (0.0 mi/h)<br>Min Elevation: 0 m (0 ft)<br>Max Elevation: 0 m (0 ft)<br>Elevation Gain: 0 m (0 ft)<br>Max Grade: 0 %<br>Min Grade: 0 %<br>Recorded: Tue Apr 12 18:53:08 GMT+00:00 2011<br>Activity type: -<br><img border="0" src="http://chart.apis.google.com/chart?&chs=600x350&cht=lxy&chtt=Elevation&chxt=x,y&chxr=0,0,223,37|1,0.0,100.0,25&chco=009A00&chm=B,00AA00,0,0,0&chg=100000,25.0,1,0&chd=e:,"/>]]></desc>

There is a similar field in the GPX file that is currently empty. A quick look at the code showed that a 5 line patch should populate that field with the same data. I’ve sent the patch off to the My Tracks guys, but in the mean time if the field is empty the user will have to fill in the time and distance by hand.

With the UI sorted, the publishing is done in a service as it’s a multi-stage process including uploading the GPX file which could take a while over mobile networks. It’s basically 3 dailymile API calls

  1. Create a new route – POST https://api.dailymile.com/routes.json
  2. Upload GPS data to form the route – PUT https://api.dailymile.com/entries/id/track.json
  3. Create new workout entry – POST https://api.dailymile.com/entries.json

Interspersing these with Notification updates so the user can see how far through the process it is.

 

private void upload() {
HttpResponse resp;
final HttpClient httpClient = new DefaultHttpClient();</code>

String ns = Context.NOTIFICATION_SERVICE;
NotificationManager notificationManager = (NotificationManager) getSystemService(ns);

Notification notification = new Notification(R.drawable.icon, "Uploading Workout", System.currentTimeMillis());
Context context = getApplicationContext();

Intent notificationIntent = new Intent();
PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);

notification.setLatestEventInfo(context, "Uploading Workout", "Creating new Route", contentIntent);
notification.flags |= Notification.FLAG_ONGOING_EVENT;
notification.flags |= Notification.FLAG_NO_CLEAR;
notificationManager.notify(1, notification);
Log.i("DailyMile",Constants.NEW_ROUTE_URL + authToken);
final HttpPost post = new HttpPost(Constants.NEW_ROUTE_URL + authToken);
List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(2);
nameValuePairs.add(new BasicNameValuePair("name", routeName));
nameValuePairs.add(new BasicNameValuePair("activity_type", routeType));
try {
post.setEntity(new UrlEncodedFormEntity(nameValuePairs));
} catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
String routeID = "";
try {
resp = httpClient.execute(post);
if (resp.getStatusLine().getStatusCode() == 201) {
InputStream in = resp.getEntity().getContent();
StringBuffer buffer = new StringBuffer();
for (int i = in.read(); i != -1; i = in.read()) {
buffer.append((char)i);
}
JSONObject jsObject = new JSONObject(new JSONTokener(buffer.toString()));
routeID = jsObject.getString("id");
Log.i("DailyMile", routeID);
} else {
Log.i("DailyMile","" + resp.getStatusLine().getStatusCode());
notification.setLatestEventInfo(context, "Uploading Workout", "Failed to create a new route", contentIntent);
notification.flags |= Notification.FLAG_AUTO_CANCEL;
notificationManager.notify(1, notification);
//barf
return;
}
} catch (ClientProtocolException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
catch (JSONException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
notification.setLatestEventInfo(context, "Uploading Workout", "Uploading Route", contentIntent);
notificationManager.notify(1, notification);
//{"id":622197,"activity_type":"Fitness","name":"test"}

String putURL = Constants.UPDATE_ROUTE_URL.replace("{id}", routeID) + authToken;
Log.i("DailyMile", putURL);
final HttpPut put = new HttpPut(putURL);
try {
ParcelFileDescriptor pfd = this.getContentResolver().openFileDescriptor(gpxURI, "r");
InputStream in = this.getContentResolver().openInputStream(gpxURI);
InputStreamEntity entity = new InputStreamEntity(in, pfd.getStatSize());
entity.setContentType("application/gpx+xml");
put.setEntity(entity);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

try {
resp = httpClient.execute(put);
if (resp.getStatusLine().getStatusCode() != 201) {
Log.i("DailyMile","" + resp.getStatusLine().getStatusCode());
notification.setLatestEventInfo(context, "Uploading Workout", "Failed to upload new route", contentIntent);
notification.flags |= Notification.FLAG_AUTO_CANCEL;
notificationManager.notify(1, notification);
//barf
return;
}
} catch (ClientProtocolException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

notification.setLatestEventInfo(context, "Uploading Workout", "Adding new workout", contentIntent);
notificationManager.notify(1, notification);

final HttpPost postWorkOut = new HttpPost(Constants.NEW_ENTRY_URL+authToken);
StringEntity entity = null;
JSONObject json = new JSONObject();
JSONObject workout = new JSONObject();
JSONObject distance = new JSONObject();
try {
json.put("message",workoutMessage);
workout.put("duration",workoutDuration);
workout.put("activity_type", routeType);
workout.put("felt", workoutRating.toLowerCase());
workout.put("route_id",Integer.parseInt(routeID));
distance.put("units", workoutDistanceUnits);
distance.put("value",workoutDistance);
workout.put("distance", distance);
json.put("workout", workout);
Log.i("DailyMile",json.toString());
entity = new StringEntity(json.toString());
entity.setContentType("application/json");
} catch (JSONException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
postWorkOut.setEntity(entity);

try {
resp = httpClient.execute(postWorkOut);
if (resp.getStatusLine().getStatusCode() != 201) {
Log.i("DailyMile","" + resp.getStatusLine().getStatusCode());
notification.flags |= Notification.FLAG_AUTO_CANCEL;
notification.setLatestEventInfo(context, "Uploading Workout", "Failed to upload new workout", contentIntent);
notificationManager.notify(1, notification);
//barf
return;
}
} catch (ClientProtocolException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

notification.setLatestEventInfo(context, "Uploading Workout", "All Done", contentIntent);
notification.flags |= Notification.FLAG_AUTO_CANCEL;
notificationManager.notify(1, notification);

}

 

So it’s all done now, just got to jump trough a few hopes to get it sign off to publish it in the Android Market and hopefully the My Tracks guys will include my patch in the next drop.

My Tracks to dailymile GPX route exporter – part 2

In my last post I talked about a project I’m working on to export GPS tracks from My Tracks directly to dailymile.

I was having problems getting the mobile client flow for oAuth 2.0 to work with dailymile. As a work around I decided to see if I could get the User-Agent flow to work for this application.

At the end of the previous post I suggested using an Intent filter to catch a redirect to a custom URI schema, while I got this to work it was messy as it left the browser app on the activity stack during the authentication. While looking for a way round this I found the documentation for the WebView widget and came up a much better solution. The WebView widget allows you to embed HTML content in your application so it should be possible to embed the web based authorisation set into a Activity.


<pre>
getWindow().requestFeature(Window.FEATURE_PROGRESS);
WebView webView = new WebView(this);
setContentView(webView);
final Activity activity = this;
webView.loadUrl(Constants.AUTH_URL);
</pre>

In the onCreate method of the Activity I have done away with a normal layout and just used a full screen WebView and asked it to load the authentication URL from the User-Agent Flow

https://api.dailymile.com/oauth/authorize?response_type=token&client_id=<CLIENT_ID>&redirect_uri=dm://locahost

Login Authorise

The callback redirect URI (dm://localhost) still has a custom schema similar to the one suggested in the previous post. Assuming the user allows access to their dailymile profile we can intercept the redirect by overriding the WebViewClient on the Webview which will look a bit like this:

dm://localhost#access_token=[ACCESS_TOKEN]

webView.setWebViewClient(new WebViewClient() {
  @Override
  public boolean shouldOverrideUrlLoading(WebView view, String url) {
    if (url.startsWith("dm://")) {
      String summary = "<html><body>Grabbing Account info</body></html>";
      view.loadData(summary, "text/html", "utf-8");
      String token = url.substring(url.lastIndexOf("=")+1);
      User user = Utilities.getAccountDetails(token, activity, handler);
      if (user != null) {
        user.setToken(token);
        finishLogin(user);
      }
      return false;
    }
    view.loadUrl(url);
    return true;
}});

Now I’ve got the AuthToken I can tuck it away in the Android AccountManager so I can grab it later when I want to upload a training session.

My Tracks to dailymile GPX route exporter – part 1

On Friday afternoon a few people I know were talking about a web site for people who like to record their running and cycling and share it with their friends, the site is called dailymile. Dailymile allows you to upload GPS recordings of your routes so you can work out how far you’ve gone and also share routes with others.

Those of us that have Android phones tend to use an app called My Tracks to do the GPS recording and time our excursions. At the moment you either have to email the GPX files to yourself or if you have the Dropbox application installed, save them to the cloud storage before you can use dailymile’s web interface to upload them to the site.

Dailymile have recently published a REST API which includes an end point to upload new routes to, so it looked like it shouldn’t be too hard to put something together allow direct exporting of training sessions.

Getting something into the My Track’s “Share with Friends…” list is just a case of setting the right Intent Filter

 <activity android:name=".Export"
      android:label="@string/app_name" android:exported="true">
   <intent-filter>
     <action android:name="android.intent.action.SEND" />
     <category android:name="android.intent.category.DEFAULT" />
     <data android:mimeType="application/gpx+xml" />
   </intent-filter>
 </activity>

Share with Friends...
Share with Friends...

I had already been playing with adding Account mangers to the Accounts & Sync section under the Android settings for a work project and this seamed like the best way to manage the OAuth requirements for the dailymile API. So I set about writing a SyncAdapter based on the previous code I already had. The problem is that dailymile have chosen to use OAuth 2.0 which is still a draft, so while there are a load of Java libraries out there that have support for 1.0 there are only a couple that have varying levels of support for different levels of draft for 2.0. The other problem is that there is no example on the dailymile api page of how to do OAuth authentication from a mobile app, which I’m starting to think means they’ve not implemented it yet.

A site with a very similar name (DailyMotion) also uses OAuth 2.0 and has an example of how to do mobile app authentication, following this would lead to the following curl test.


curl -F grant_type=password -F client_id=[client_id] -F client_secret=[client_secret] -F username=[username] -F password=[password] https://api.dailymile.com/oauth/token

This gives the following output


{"error":"unauthorized_client"}

So I’m wondering if this means that dailymile have to enable specific clients for this direct access to a token.

I’ve send a mail to the dailymile guys asking about this and if they can update their examples/docs for this case. In the mean time I’m going to have a look at using the web based authorization approach with a custom schema for the callback URL which I should be able to register with a suitable Intent filter.

 <activity android:name=".AuthCallBackAct" android:label="@string/app_name"
      android:exported="true">
 <intent-filter>
  <action android:name="android.intent.action.VIEW"/>
  <category android:name="android.intent.category.BROWSABLE"/>
  <category android:name="android.intent.category.DEFAULT"/>
  <data android:scheme="dm" android:host="localhost"/>
 </intent-filter>
</activity>

Unbricking Guruplug Server

I’ve been playing with a Guruplug Server over the last few days trying to get it to work with my MIMO UM-740 Touch Screen. It’s been slow progress because nobody seams to have a link to the src for the kernel that it comes with so I can’t build the DisplayLink module.

Guruplug
Used with permission from Flickr taken by andypiper

To try and get round this I tried to update the kernel using some instructions on the openplug.org website. Unfortunately due to a miss match in the uboot boot loader that came installed on the device the new kernel wouldn’t boot.

I tried following the un-bricking instructions on the openplug.org wiki and ran into the problem that not only is the src not available for the original but neither is the binary. I tried to grab a copy from a colleagues machine but it didn’t want to read the area of flash properly once booted.

I finally found a nice little post on the New IT forum that had all the bits needed to update uboot, the kernel and a clean root file system using a USB stick.

The updated version of uboot should also allow the pre built kernels out there to boot now as well.

So while I’m not back to original state of the as the Guruplug shipped, it is now up are running again and I can get back to getting my MIMO working with it.