Android and the Form Post Download problem…

Only 338 points *and* no POST download???
Only 338 points *and* no POST download???

Another day, another workaround… This time, it was for this rather annoying issue that’s been open for over four years (maybe because it’s in as an RFE and not an actual bug, who knows?). It basically describes an unexpected behaviour in the stock Android browser, whereby it cannot handle form POST requests that result in a file download.

This was picked up by one of the testers on Retroify.me (thanks Natalie!) where we’re using form post to facilitate saving image data from an HTML 5 canvas to the user’s computer. This is something that some browsers can handle without server involvement, but support varies and the various browsers implement filename selection in different ways, so we went with the solution of posting the Base64-encoded image data back to the server, which then initiates a download with disposition: attachment, allowing us to give the file whatever name we like.

This works fine on every browser we’re targeting, except the stock Android browser in everything from Gingerbread to Jelly Bean. Clicking the “Download” button on that browser resulted in “Download unsuccessful” 100% of the time.

This took some tracking down, and after sanity checking the appropriate code to make sure we were returning the right content-type and so forth, I turned to the logs to see if I could see anything out of the ordinary. Sure enough, enlightenment ensued:

Started POST "/download" for 192.168.0.4 at 2013-04-28 20:17:28 +0100
<<Base64 parameters elided>>
Processing by AppController#download as HTML
Rendered text template (0.0ms)
Sent data retroified.me.png (1.5ms)
Completed 200 OK in 3ms (Views: 1.3ms | ActiveRecord: 0.0ms) 

Started GET "/download" for 192.168.0.4 at 2013-04-28 20:17:29 +0100
Processing by AppController#download as HTML
Completed 500 Internal Server Error in 2ms (Views: 1.1ms | ActiveRecord: 0.0ms)

Immediately after sending the POST, Android follows up with a GET to the same endpoint. After some thinking (read: Googling) I decided that this must be the download manager trying to grab the file – the browser simply ignores the first (successful) response, and instead launches the download manager to grab the file via a GET. The problem is, somewhere along the way, the form post data is discarded.

In our particular case even if the post data wasn’t discarded, GET would never work because the Base64-encoded image data is simply too big for a GET request – Our Heroku dyno would simply refuse to touch it. Even if the browser insists on using a download manager in this way, my expected behaviour would be for it to pass along the entire request to it. However, on Android that might mean stuffing a large amount of Base64 into an intent, which could cause other problems.

It’s at this point that I found the aforementioned “RFE” 1780 and realised that this is what I was up against. Given the previous paragraph I can allow that this isn’t as simple a fix as it might first appear, but still – four years and no love?

In the end, I decided the only way forward was simply to work-around the problem by doing the image download a different way on the stock Android browser. Fortunately, I had already implemented server-side image generation for use with the Facebook integration, so all I needed to do was make the download button behave differently in Android stock. Here’s the Javascript that does that:

/* Download image from canvas */
function downloadImage(canvas) {
  var data = canvas.toDataURL("image/png");

  // Stock android browser can't download from post.
  // See: https://code.google.com/p/android/issues/detail?id=1780
  var ua = navigator.userAgent.toLowerCase();
  var isStockAndroid = ua.indexOf("android") > -1 && ua.indexOf("chrome") == -1 && ua.indexOf("dolphin") == -1;

  if(isStockAndroid || data.substr(0,6) == "data:,") {
    // redirect to server-side download
    window.location = getGeneratedPictureURL() + "?d=1&f=retroified.me.png";
  } else {
    post_to_url("/download", { data: data });
  }
}

(Note: This also works around an issue with pre-Gingerbread, where canvas.toDataURL is not actually implemented).

What this actually does is check if the user-agent reports Android, but also that it doesn’t specify any of the browsers that are known to work. I had to go this way since the stock browser’s user agent doesn’t easily allow us to do single-step sniffing. If the stock browser is detected, the form post is skipped, and server-side generation is used instead (getGeneratedPictureURL() returns the server-side URL for the currently-displayed image, and we just add some parameters to force disposition: attachment and set the filename).

This works, but in my opinion it’s not ideal. The user’s browser has already done the work of generating this image, but that load is then pushed back onto the server when the Download button is clicked. This is not a trivial thing – the image generation (using ImageMagick behind the scenes) is a relatively slow operation. Of course we cache the generated images, but with 20M+ possible combinations we don’t keep them cached forever.

All we can hope at this point is that 1780 gets some attention at some point soon. If you’re so inclined, you might even want to head over there and star it to let the AOSP guys know it’s still alive…

Advertisements

It’s been a while!

Wow, looks like I’ve not blogged since February! Doesn’t seem that long, but there you go…

I’ve actually been really busy lately, with work and other things. Mostly I’ve been spending some time getting our new app into beta (check it out at http://retroify.me). It’s been a bit of a learning curve for me (it’s the first time I’ve really used Javascript in anger, and the first FB platform integration I’ve done for anything commercial) but it’s been (and continues to be) lots of fun.

This has also meant that things have fallen behind a bit on Deelang and ORMDroid, but I’ve got plans to get back on track with both projects very soon.

Anyway, I’ll just leave this here for now, with a promise that now the main development of the app is over, normal blogging services will be resumed in the near future 🙂