Under the Hood: Breaking Down Android’s OTA Update Engine
Notes from an Engineer Exploring Android’s Update Engine
Intro
During my 11-hour flight from San Francisco to Tokyo, I decided to investigate Android’s Update Engine. As someone working in the automotive OTA space, I’ve always wanted to understand it at a deeper level. Android’s OTA stack is mature and robust, but surprisingly under-documented. This post shares my notes and observations as I studied the framework, with explanations and diagrams to make the system easier to understand.
This post originates from notes I took while reading the code; I expect the reader to understand C++ well and have knowledge of basic design patterns. Download the code and follow my thoughts, but interpret it and create your own. Also, please comment if something looks wrong; I am happy to make updates. This is in no way a replacement for downloading and reading the code yourself: Android’s Update Engine.
OTA Package Format
Update Payload File Structure
To begin understanding Android’s Update Engine, we need to conceptualize the update payload structure, at least at a high level.
The update payload is essentially a detailed installation instruction plan. These instructions are encoded in the manifest and the payload, and include the information the update engine needs to perform and interpret the update.
Payload Metadata & Manifest
This manifest is serialized as a protobuf and includes version info, partition list, list of InstallOperations
per partition, and metadata about compression, size, and hash validation.
Update Engine API
applyPayload() and InstallPlan
The entry point of the external update request occurs via the AIDL implemented by the update engine service.
When a client invokes the applyPayload
API, the application prepares an installation plan (InstallPlan
). This data structure includes the configuration of the update in question and any other data that needs to be shared between the various actions.
Action Pipeline Overview
Actions are the objects that encapsulate some of the engine’s business logic, but mainly the order in which things occur. Actions are linked together as a chain:
A -> B -> C
Where each is a subclass of Action<T>
and executed by the ActionProcessor
. . The action processor then executes these actions in order. See the UpdateAttempterAndroid::BuildUpdateActions
method.
An action’s entry point is the PerformAction
method declared by the Action<T>
Interface. Each of the implementations mentioned below starts there. Other functions can be overridden based on the action’s implementation, but only PerformAction
and Type
are required.
The update engine defines an abstract class, InstallPlanAction
, with shared functionality for some actions.
This pattern is based on a previous updater written for macOS, and the authors of the Android Update Engine inherited that pattern almost verbatim. See https://code.google.com/archive/p/update-engine.
Actions
As of 04/25/2025, the update engine has installed and processed 5 actions responsible for updating all Android devices. The first few actions are preparation for the update, but it will be apparent as you read which one is more important.
Update Boot Flags
This action marks the current booted slot as “good”. The current implementation ignores a failure and clearly states that it performs a best-effort operation to mark the boot as successful using the BootControlClient
defined in the HAL layer. The BootControlClient.h
is a dependency of the update engine, provided by AOSP’s libboot_control_client
.
Cleanup Previous Update
This action cleans up the virtual A/B system from previous update attempts. We will cover Virtual A/B Compression (VABC) in detail later.
Install Plan
This action sets the install plan as the output of the action if the action pipe has an output. This is very coupled with the action and action pipe framework. There is more on this in The Update Engine Action System.
Download
This is where the start of the installation occurs, the download action operates in two modes: File or HTTP stream.
Looking closer, the AIDL's applyPayload is overloaded by the first parameter, the first one takes a URL, and the other one a file descriptor. Following the UpdateAttempterAndroid
, we can see that the overload function adapts the file input into a file://...
schema, and then calls the API, accepting the string:
The preparation part of this function initializes the instances of the InstallPlan
which is then shared across the actions that need it. Most of the fields in the install plan data structure come from the String[] headerKeyValuePairs
in applyPayload
API. To review the latest configurations, consult the contants.h
.
Payload Fetcher
One crucial piece of the puzzle is the fetcher instance. This is instantiated based on the strategy of the update previously set up. Below is a simplified version of the conditional initialization. The application will be using a pointer to an HttpFetcher
that can be a FileFetcher
or a LibcurlHttpFetcher
:
This abstraction is key to the update process because from here on, the application (the DownloadAction
) doesn’t know where the data is coming from.
I must mention that the interface abstraction's name choice is a bit misleading, and as FileFetcher
implements HttpFetcher
, it doesn’t fetch from an HTTP server.
This same function calls the BuildUpdateActions
method that creates all the actions and bonds them together before it calls ScheduleProcessingStart
. This function schedules the execution of the task, posting it as a task using brillo
, see UpdateAttempterAndroid::ScheduleProcessingStart()
. This task calls the ActionProcessor::StartProcessing
function in a loop, which eventually starts the machinery.
Filesystem Verifier
This action verifies the data written to the target partition by hashing the partition, and if supported, builds the verity hash tree and writes it into the expected location to be verified by the kernel. This action uses the VerityWriterInterface
to build a hash tree using libverity_tree
.
Merkle Tree Generation
Last, it computes and writes the output data to the partition location. I don’t want to get too deep into dm-verity here, but in short, the tree builder basically builds a Merkle tree that can be used to compute the root hash by the kernel when it boots. Assuming that the kernel is signed and can be trusted via secure boot, by checking that the hash of data written to the partition matches the hash of the signed manifest, and the root hash is valid, we can trust that the system hasn’t been tampered with.

Post-Install Runner
As the name suggests, this step performs a set of post-installation operations. While this action performs other checks, I want to highlight these three:
Mount Check
The engine mounts the partition in /postinstall (in Android) to verify it is mountable. This verification asserts that the device is mountable because if that operation fails, it will most likely fail to be mounted at boot time. Note that the engine immediately unmounts the devices before continuing.
Post-install Script
The engine runs the post-installation script asynchronously to avoid blocking the main thread. The device administrator or vendors deliver this custom script within the update payload.
Switching Boot Slots

Last, the action performs a boot control operation to switch the active partition to the target partition to install boot_control_->SetActiveBootSlot(install_plan_.target_slot)
. This indicates to the system that it will be booting from target_slot.
This action has indirect recursive calls, which makes it a little tricky to follow, but an attentive reader can notice that the recursion is completed when current_partition_ == install_plan_.partitions.size()
Action Inputs & Outputs
The high-level idea is that an action pipe can have an input and an output, and the user "bonds" two actions together to pass the input/output in the framework. In the update engine, the InstallPlan
is the input and output of all the actions are based on InstallPlanAction
. That is defined by the action traits in the install_plan.h
:
An important part to highlight is where the connection occurs. In the BuildUpdateActions
method, the application bonds these actions together:
However, it does not for UpdateBootFlagsAction
and CleanupPreviousUpdateAction
because these actions are self-contained and do not require input or produce output. The action trait from CleanupPreviousUpdateAction is defined as:
In summary, this action starts the update pipeline, which includes the installation plan. The previous actions should be considered pre-update actions.
Delta Performer
If you are reading this post, this is the part you want to pay close attention to because this is the software component that matters the most. An optimal update execution ideally reduces the storage required to apply the update, i.e., no intermediate file in the system’s storage for decompression, and minimal to zero disk IO except the target installation partition. Last, you want to minimize the data you transfer over the wire to these devices, consider the cost to the host of the software updates and the customers, and the transfer time.
The update payload stream is structured as nested layers: a Frame, which contains a Deflated block, which in turn wraps the Data, and finally, the actual Instruction set. The Update Engine must parse and decompress these layers in sequence.
Frame
└── Deflated
└── Data
└── Instruction
The goal is to get to the data that encodes the instruction the engine needs. To do so, the application needs to read enough data from the source, allowing a decoder to deflate the data and then apply this application. In a brute force approach, one can easily download the file completely, decompress it, apply the update, and clean up. However, as mentioned above, that’s very file I/O inefficient and requires that the system reserve at least sizeof(data_deflated) + sizeof(data_inflated)
for a brief moment.
Delta Archive Manifest
To avoid that, the engine sets up a stream pipeline that works as follows: when the bytes “arrive” (DownloadAction::ReceivedBytes
), the Write
The function in the Delta performer is called.
This function first attempts to parse the manifest in the payload, but returns early and waits for more bytes to arrive if there are not enough to generate a PayloadMetadata
.
The payload metadata is encoded in the first 24 bytes of the stream and, as shown above, contains the version of the manifest, the size, and a signature. This signature is used to verify that the manifest following this is verified because the application even touches it. If the verification is deemed valid, the application continues to decode the protobuf message:
A lot is going on in that message; however, the critical part of this message is field number 13: repeated PartitionUpdate
partitions
.
Partition Updates
Usually, devices are split into various partition layouts depending on the needs of the product in question. In this example, let’s assume that we have four partitions: bootloader
, kernel
, rootfs, and special_p, where special_p is a special partition with data that services in your system know how to consume and adapt. If the software package requires updating these partitions, the delta archive manifest will include each partition in the order in which it needs to be updated. Since the update engine is a single-threaded application, partitions are truly written in the order specified by this manifest.
There is even more here to digest, including signatures, verity, and others, but let’s zoom in closer into field eight: repeated InstallOperation operations
.
Performing the Update
As I briefly mentioned in the introduction of this post, the Android update payload is a set of instructions that specify how to apply an update. These operations are described by the InstallOperation
message. These operations are divided into four groups in the application: replace, zero or discard, source copy, and diff operations. See DeltaPerformer::ProcessOperation:
Some indirection occurs here because the delta performer is composed with a PartitionWriterInterface
parameter that changes depending on whether the device supports dynamic partitions, or Virtual AB compression—we will cover this topic later. Still, it is a nice optimization in Android systems that reduces the required storage to perform seamless OTAs.
Extents and Writers
Extent
An extent is a tuple-like data structure representing how data blocks are organized on disk. The message definition has two values: start block and number of blocks. This is used to represent data that overlaps multiple blocks, e.g., let’s say that a file is stored over blocks 10, 11, 13, the extent tuple would be (10, 3), where 10 is the start block number and 3 is the number of blocks after. Another way to think about this is a range: a list of ranges indicates that a file is stored over multiple blocks at different offsets: { (10, 3), (20, 1), (55, 10) }
. Now that we have a high-level idea of what extents are, we can return to the concrete writers.
Partition Writer
The partition writer interface declares the API to perform each of the InstallOperation
. Today, there are two implementations, the PartitionWriter and the VABCPartitionWriter. Before we consider the different writers, let’s explore what the writers use to dictate where the writing occurs.
PartitionWriter
The partition writer object can be considered the legacy partition writer prior to adding VABC support, and it writes directly into the target partition according to the operations it handles. The partition writer uses a DirectExtentWriter
for writing non-compressed data; however, if the operation is a Bzip or Xz, the extent writer is replaced by the specialized writers, such as BzipExtentWriter
and XzExtentWriter
, respectively. Something to highlight is that DirectExtentWriter
is the default write behavior unless otherwise specified.
VABCPartitionWriter
This writer specializes in the latest Android optimization strategy: Virtual A/B Compression (VABC). In essence, VABC allows OTA packages to be smaller because it only requires duplicating the bootloader partition. Both writers use a DirectExtentWriter to perform the install operations for data that is not compressed. When the data is compressed, the installation executor replaces the
SnapshotExtentWriter
This writer is a wrapper around the platform COW writer, android::snapshot::ICowWriter. The COW format is used in Android to represent a compressed disk layout version. These operations are Copy, Replace, Zero, and XOR. All of the I/O is routed to the snapuserd using the android::snapshot::ISnapshotManager
.
Final Notes
Android’s Update Engine is optimized for reliability at scale:
No intermediate file usage
Streaming updates
Partition-level operation via protobuf-defined instructions
Support for VABC, dm-verity, and snapshotting
There’s still more to explore (rollback, recovery, etc.), but hopefully, this post will help engineers navigate update_engine with more clarity.
Don’t stop here, here are some links that I recommend reading as they help me gain more understanding as well:
Android’s Virtual A/B Compression
Streaming OS Updates Will Work Even if Your Phone is Full
Device-mapper snapshot support — The Linux Kernel documentation
Android 13’s Virtual A/B Mandate Could Bring Seamless Updates to More Devices