Modernizing downloads in Showmax iOS app
We are constantly researching new products and features for our customers — to succeed in the highly-competitive, technically-challenging world of video streaming business, we have to. Back in January 2016, we knew that data was a luxury in the African market, so we added downloads into the Showmax iOS app. Letting people download content ahead of time so they could watch it later when they are offline seemed like it would be a great commercial edge. That was a few years ago, but what happened next? The code remained the same and we needed to re-write it so that it could again be a shiny feature that sets us apart. Let’s have a look at what we did from start to finish.
What are downloads
Downloads are on the most popular Showmax features. They help people watch content while they are offline, and let them save money on cellular data by downloading in advance on wifi.
Users initiate a download from the movie detail screen, downloads are then gathered on a dedicated screen where users can watch and manage downloaded content.
The Tricky Part
Downloads are intricate mainly due to the related licensing rules. Multiple content providers might have various requested rules that affect availability of the download; for example, a movie or series can be downloaded only (n) times a year, or needs to be watched within 48 hours. All of these rules are resolved on the backend, and anytime our iOS app does something with a download, it simultaneously reports what happened to the backend. Later, the backend can decide what to do next with a particular download.
Our backend defines the life of download by the state machine. This definition only cares about key parts of downloads lifetime. Frontend clients have to deal with additional cases, so they need many (many) more states. For example, a normal HTTP video download requires fetching metadata, thumbnails, verifying DRM, recovery options, and other edge cases, and it’s quite challenging to synchronize local states with backend ones.
Our old download code was designed five years ago. It worked, but had several issues: no dependency injection, lots of singletons, no modularization, no unit tests, no testability, and more — put simply, it was a recipe for disaster. Indeed, we encountered bugs that were very hard to reproduce and debug, and every change required a bit of courage and extensive testing to make sure we didn’t break anything.
How to Modernise
We approached modernisation pretty simply:
- Write down what we want
- Design new implementation
- Split the work
1) Write down what we want
Looking into our old code, we identified pain points and came up with some solutions.
Avoid binding local download representation to backend entity.
Our old code suffered from having only one type that represented downloads — it was a 1:1 map of the object that existed on the backend. At first, it seemed like a good idea, but we couldn’t foresee all of the hidden states that were required on the client side. Strong ties to backend representation were limiting us in code, it was hard to include additional client-only logic, and it caused confusion when backend-relevant naming was propagated to UI components.
In the new code, we introduced two types. One is mapped to the backend side, and the second represents downloads in the client and uses local states. This means that a download can exist locally even when it doesn’t exist on the backend.
Have test coverage
Our old code had objects that used singletons inside that were handling many different things in one place. Code like this is nearly impossible to unit test. We needed to be able to trust how our code worked, and have confidence when changing it in the future, and we focused on testability with our new code. Now, we declare dependencies and aim for single responsibility per type. This enables us to test every piece separately, and write integration tests which test the whole use case together.
Have predictable behavior
In the old code, some bugs were hard to track and reproduce, mainly due to scattered conditions that handle failures. It was quite time consuming to imagine all of the influences in the code when solving bugs. The predictability of new downloads behavior is mainly down to the defined state diagram, where we know what will happen with a download in any condition, every time. It means that downloads will fail, but will always fail according to prescribed conditions.
Handle failures gracefully
The old code was also missing a few error states. The new code can handle many more error states than before, each identified by unique code. Certain failed operations can be now retried with care to not burden the backend with requests.
Keep current functionalities
We also noted features that must be kept, like asking users what video size to download, integrating parental control, handling the amount of disk space, and handling network connection changes. We covered many of those features with unit tests.
Migrate old downloads
The old code stored downloads in different structures, so we created a migration action that transforms existing old downloads to the new expected structure. It can handle not only video, but also related data like DRM keys, subtitles, metadata, and images.
2) Design the new implementation
Here are some highlights of what we incorporated in the new downloads implementation.
We’ve defined a state diagram of all possible states that occur on the frontend side. This gives us a big graph that we eventually split into smaller state diagrams. We’ve documented states and transitions using plantuml syntax, and generated handy images for all state diagrams.
(for full size scroll to end of blog post)
We decided to implement a state machine, in simple Swift form, as a dictionary where key is state and value is action. When the state machine is running, it always picks an action for the current state and executes it. This action results in one of the defined states, and then the process repeats with a new state.The benefit is that each action is relatively compact and the mapping to states is quite readable. Actions are so small that unit testing is really easy and results in small, straightforward unit tests as well.
Each state machine is specialized to handle only certain download states, so when an action results in a state that is out of scope for the current state machine, we finish. The control flow is then returned on a higher level and there is a manager that always looks at the download’s current state and selects the appropriate state machine suitable for handling the current state.
From the outside, the download component behaves asynchronously and does not block the main thread. On the inside, each state machine performs actions synchronously on a dedicated dispatch queue. This ensures clear straightforward flow and good testability.
The new download code is wrapped inside a separate Swift framework. This means the code is nicely isolated and doesn’t pollute the app’s namespace. Only a few objects are publicly accessible, so for the outside user it is very clear what should be used. For example, the available downloads are emitted via RxSwift stream. Inside the framework we represent a download by class because it is easier to work with its lifecycle this way. On the outside, we emit only a snapshot of a download, represented by struct, to avoid issues when passing class instances between threads.
3) Split the work between team members
Thanks to the defined state diagrams, we had lots of small isolated actions, which helped us split all of the work on download modernization into a bunch of small tickets, each with prescribed hints for implementation and testing. Then it was fairly easy to scale to more developers and resolve in parallel. We highly recommend this approach!
For the millionth time in recorded history, we were able to prove that the path to success has several obvious, yet often-ignored prerequisites: A proper problem statement, well-defined design and architecture, and deliberate implementation. Although it’s obvious and written zillion times, it is worth reminding ourselves from time to time that good process is the path to good outcomes.
Rewriting downloads was not easy, but it was very well worth the effort.
For developers, it brings more control over dependencies, predictable behavior when a download state changes, and higher confidence thanks to better test coverage.
For users, it brings more reliability. Thanks to actions being isolated in the state machine, we can now easily run some actions again. We’ve improved the reliability rate of successful downloads from 75% to 87%. The rest are cases mostly related to slow or poor internet connection, and as such are not easily addressable, but we are always looking for ways to improve even further.
Are you interested in iOS development, join us we are hiring! Check out the open position in our team.