This article tells the story of how Stream’s Android team refined our progress tracking process during file uploads in the Stream Chat Android SDK.
•about 2 months ago
Our original implementation to track file upload progress worked, but it had some in-code usability and UX issues that we wanted to clean up.
The following account gives an up-close look into the process we had, the problems we encountered, and what we did to improve.
Warning: As this is a story of mistakes we made and then corrected over time, do not use any of the initial or intermediate forms of code here in your own projects, only the final fixed version. 😉
Uploading Files With Retrofit
First things first, let's see how you can upload a file to an API using Retrofit. Most APIs will expect a multipart form to contain the file data. You can declare a method inside a Retrofit interface like the one below to support that operation:
To invoke this method, you can create a
Part by using the
createFormData function, like so:
Then you just
enqueue the Retrofit
Call to run it, and done! That's a basic, working file upload.
Counting in the Request Body
Time to add progress tracking. For our initial implementation, we used this callback interface:
We then created a
ProgressRequestBody class, most likely based on this StackOverflow answer.
This wraps a
file and a
callback, and implements the OkHttp
writeTo is invoked to write this
RequestBody to the network, we loop through the contents of the file manually, write the bytes, and invoke the
callback, calculating the percentage of the upload completed so far.
This requires a modification to our API invocation, as we now have to create a
ProgressRequestBody that we then embed in our
Part, which will — remember this — contain the
ProgressCallback instance that was passed in.
We also pass the callback to the
enqueue call via a wrapper to adapt it to a Retrofit
Callback, which will run the
onSuccess methods of our original callback as needed.
If you want to explore this implementation, you can find the old version of our code here in the GitHub history.
Counting With a Custom Sink Implementation
This is okay, but we can make it a lot nicer. For this change, we essentially took the implementation from Paulina Sadowska's post about the topic.
The improvement is to (instead of handling a
FileInputStream manually) create a custom OkHttp
Sink implementation (based on the handy
ForwardingSink), which will perform the counting and corresponding progress callbacks for us.
We'll also make
ProgressRequestBody wrap a
RequestBody and delegate to it whenever it needs to behave like a
For this improvement, we've updated our
ProgressCallback interface to take the more meaningful
totalBytes values during progress updates instead of a percentage.
(We also added KDoc at this point to make the interface more obvious, which you can check out in the GitHub repo.)
On the call site, we can now create a simple
RequestBody first, and then wrap it with the
The Logging Problem
A major problem we encountered was that we had several interceptors added to the
OkHttpClient that we used for these uploads. The configuration looked something like this:
This is natural, and lots of apps have setups like this. What's notable here is that
CurlInterceptor will both log the request and call
requestBody.writeTo() internally to do that.
In the case of our file upload calls, this method is what contains our progress tracking implementation.
The end result is that whenever we make an upload call, we'll run the progress callback three times in a row: twice for the logging, and once when it's actually written to the network.
This results in an... interesting experience for the user looking at the UI, where progress goes something like this:
This only happened in debug builds, as we had the logging disabled in release builds, but it was still a problem.
Fixing this wasn't simple, as we wanted to keep these logging interceptors around. After some elaboration, we begrudgingly added an ugly, ugly workaround, like this:
As you can see, we simply hardcoded that the first two times when the
ProgressRequestBody is written, it shouldn't invoke callbacks.
What made this worse is that this value wasn't actually
2 like I assumed, because as mentioned above, we only log with these interceptors in debug builds.
This meant that we had to make this a
var and set it dynamically. In release builds it'd be
0, and in debug builds, we'd increment it to
That's bad enough, but then we also had the requirement to allow our users to set their own
OkHttpClient instances for the SDK to use, where they could also add more interceptors of their own, which may or may not invoke
writeTo in the request body (one or more times!).
We could've somehow provided an additional API where they can increment
progressUpdatesToSkip to account for this, but then they could also have interceptors that will sometimes read the body but not at other times, based on some dynamic condition... There's clearly no winning with this approach, and it's an awful rabbit hole to go down.
So, to quote myself from the workaround PR linked above:
A real solution would be intercepting the call with an OkHttp network interceptor, but we can't pass in individual callbacks per different Retrofit upload calls with that approach (at least not easily).
So the problem was that we had no way to get the
callback value from the call site that invokes the Retrofit method down to the interceptor attached to the underlying OkHttp client.
Turns out, thankfully, that I was wrong about that!
As the docs say, you can:
Use this API to attach timing, debugging, or other application data to a request so that you may read it in interceptors, event listeners, or callbacks.
When building the
Request, you can pass in a
Class as a key and then an object of that type as the associated value:
And then later you can read these values from the
One small problem is that in our file upload code we create a
RequestBody manually, but not the actual
Request object, as that's created under the hood by Retrofit.
Thankfully, the tagging API is also exposed through Retrofit, so you can add tags to methods using the
@Tag annotation on parameters. We'll use this in the next section!
The strategy then is the following:
- Add the
ProgressCallbackinstance as a tag when making the Retrofit call.
- Create an interceptor at the end of the interceptor chain that will check each outgoing request and wrap its
ProgressRequestBodyif the callback is present on it.
First, we'll update the Retrofit interface to accept a
progressCallback parameter that will be used as a tag:
Then, we'll implement the interceptor that reads this tag and performs the wrapping of the
RequestBody if needed:
Finally, we'll add this interceptor to our
OkHttpClient, making sure it's the last one added.
We'll add it as a network interceptor, as we want it to be as close to the actual upload as possible, and it doesn't need to be involved in requests that don't go out to the network.
This way nothing that previous interceptors do with the request will interfere with the progress reporting, as they'll still have the original
RequestBody to work with, and the special progress-tracking wrapper is only added at the very last moment before it actually goes out to the network.
If you want to see this last change in detail, check out the corresponding PR on GitHub.
So that's the implementation we ended up with for now! You can find all of this code in the Chat Android SDK's GitHub repository if you want to look at it in a real project.
There is one remaining issue with this progress tracking: Whenever the body gets written, it's only written into local buffers, and what we track is how fast we're writing to that buffer. This then still needs to make it over the network, which can take some time.
So the upload progress tends to get to 100% relatively quickly and then the request will remain pending for a while as the network call completes.
This is as close to the socket (so to say) as we can get with these APIs. For a bit more info and references, see this GitHub discussion.
If you want to learn more about how Okio (and OkHttp, Retrofit, and Moshi) work super efficiently with data, watch A Few Ok Libraries by Jake Wharton. For an introduction to Moshi, check out Say Hi to Moshi.