Regis VidalRead between the cache lines

2026-10-27

An Android app in C under 2 KB

Charles Lohr's rawdrawandroid builds Android apps in C with a Makefile, without Java or Android Studio. Its README gives "an apk size of about 25kB (with API 26)", and 45 kB for API 30 with ARM64 and ARM32. It made me want to see how small an app can get, and how simple the source can stay along the way.

The result is a 1,854 byte APK that fills the screen with a gradient. I removed every dependency I could get rid of: Java, any C library and resources. Then I squeezed every byte I could out of the compiler, the linker, the manifest, the zip and the signature. The source is one C file, a manifest, a Makefile and a Python script. The project uses NDK r28 (clang 19), build-tools 35 and SDK platform 36, targets API 36, and runs on Android 7.0 (API 24) and later.

The app: a gradient from blue to pink at the top, green to yellow at the bottom
The app on the cover screen of an Android 16 phone, without the status and navigation bars. The screenshot is 2,751 bytes, bigger than the app.

I measured what each change saved. All the savings are in the table under Summary, and each section ends with the size of the APK after its changes. Signing comes last, so until then the sizes are those of the unsigned APK.

No Java

An APK normally carries a classes.dex with the app's Java code. Android also ships a NativeActivity, an activity that loads a shared library and passes it the window and the input events. With it, the manifest sets android:hasCode to "false", and the system does not try to load any Java code:

<application
  android:hasCode="false"
  android:label="Tiny">
  <activity
    android:name="android.app.NativeActivity"
    android:exported="true">

NativeActivity loads lib/<abi>/libmain.so unless an android.app.lib_name meta-data element names another library. Calling the library libmain lets NativeActivity find it by default, so the manifest needs no such element.

To measure each step, I start where the NDK's own native-activity sample starts: an android_main on android_native_app_glue, with the C library. The sample itself is C++, built by Gradle and CMake, and also reads sensors, so I did not measure it as is. I wrote the same gradient in its style instead, built with -O2, with the same manifest as the final app, and packaged by aapt2 with its defaults. This first APK is 8,220 bytes, and the app's code, libmain.so, takes 7,181 of them, compressed:

Part of the first APKSize (bytes)
libmain.so, compressed7,181
manifest, compressed632
resources.arsc and the zip headers407
the whole APK8,220

Uncompressed, libmain.so is 21,984 bytes.

No glue

Every NativeActivity sample of the NDK uses android_native_app_glue, a small static library that ships with the NDK to make NativeActivity easier to program. NativeActivity calls the app back on the activity's main thread, and none of those callbacks may block. The glue gives the app its own thread instead, with an android_main that pumps the events in a loop, like a Win32 message loop. My small app doesn't block anything, so I skip the glue and write a simple ANativeActivity_onCreate that only registers callbacks:

a->callbacks->onNativeWindowRedrawNeeded = draw;
a->callbacks->onInputQueueCreated = input_created;

One thread, one file. Small is beautiful.

NativeActivity always takes the window's input queue, and Android expects an answer to every event in it. An app that leaves one unanswered, a touch or a key, is reported as not responding. The glue reads the queue and answers each event. Without the glue, the app has to do it. It attaches the queue to the main thread's looper and answers each event with "not handled":

while (AInputQueue_getEvent(q, &e) >= 0) {
    AInputQueue_finishEvent(q, e, 0);
}

"Not handled" lets Android do the default thing with each event, so the Back key still closes the app.

The glue first passes each event to AInputQueue_preDispatchEvent, so that an input method can see the keys before the app. In Android's source, that function only takes the key events marked for it, and answers them itself with "not handled", as the loop above does. Without it, the APK loses 41 bytes.

The glue also detaches the queue from the looper when Android destroys it, in onInputQueueDestroyed. Android does it anyway: right after that callback, it disposes of the queue, which detaches it from every looper. So the app registers no callback for it, and the APK loses 28 more bytes.

To check that the answers are needed, I built the app without its input callback and tapped the screen twice. The phone logged:

WindowManager: ANR in c.tiny/android.app.NativeActivity.
  Reason:af953d4 c.tiny/android.app.NativeActivity (server)
  is not responding. Waited 10008ms for MotionEvent

Without input handling, the final build is 1,665 bytes, so it costs about 190 bytes.

The glue brings a thread, a pipe, memory allocation and logging. Its library imports 24 functions from the C library and one from liblog. Without the glue, the APK drops to 3,351 bytes, 4,869 fewer.

No OpenGL

rawdrawandroid draws with OpenGL ES. ANativeWindow_lock gives a pointer to the window's pixels, so the gradient needs neither EGL nor OpenGL ES. Red grows from 0 to 255 from left to right, so a pixel's red is x * 255 / w, and green does the same from top to bottom. The loop gets there without a divide. Some ARMv7-A cores have no divide instruction, so on armeabi-v7a the compiler would call a helper from its runtime library for every pixel:

unsigned *row = b.bits, g = 0;
unsigned w = b.width, h = b.height, gy = 0;
for (unsigned y = 0; y < h; y++, row += b.stride) {
    unsigned c = 0xff800000u | g << 8; /* X=ff B=80 G R */
    unsigned rx = 0;
    for (unsigned x = 0; x < w; x++) {
        row[x] = c;
        for (rx += 255; rx >= w; rx -= w) {
            c++;
        }
    }
    for (gy += 255; gy >= h; gy -= h) {
        g++;
    }
}

The row pointer moves by b.stride, not b.width: the buffer can be wider than the window.

This step has no measurement. The app never drew with OpenGL, so there was nothing to remove.

No C library

Without the glue, the library still imported three functions from the C library: __cxa_atexit, __cxa_finalize and __register_atfork. My code calls none of them. They come from crtbegin_so.o, the startup object the compiler links into every shared library. It gives the library its own atexit and fork handlers, registered under a handle that identifies the library, and runs them when the library is unloaded, along with C++ static destructors. This app has none of those.

-nostdlib is two switches, and I measured them one at a time. -nostartfiles drops crtbegin_so.o, with crtend_so.o, whose only content marks the end of the unwind table, and saves 747 bytes. -nodefaultlibs drops the C library itself and saves 14 more. The only imports left are seven functions of libandroid.so.

Could the app drop libandroid.so too, and look its functions up by name? Android allows it: libandroid.so is a public NDK library, already loaded in the process, so dlopen and dlsym can reach it. I tried it on an earlier build of 1,958 bytes, which imported nine. The library got 8 bytes smaller, but the APK became 128 bytes bigger.

Without the dependency, the library still has to store the nine function names, now as strings for dlsym instead of imported symbols. It removes seven imports, with their relocations and their slots in the table of addresses. Those records are regular and deflate to almost nothing. In exchange it adds the lookup loop, about 150 bytes of code that barely compresses, and version records for dlopen and dlsym. The app stays linked to libandroid.so.

The APK drops to 2,590 bytes.

Compiler flags

The compiler flags in the Makefile:

CFLAGS  = -Oz -fPIC -ffunction-sections -fdata-sections \
          -fno-asynchronous-unwind-tables -fno-unwind-tables \
          -fomit-frame-pointer -fno-plt $(THUMB_$(ABI)) \
          -Wall -Wextra

-Os optimizes for size instead of speed, and saves 22 bytes here. -Oz goes further. With -Os, clang still tests each loop twice, once before it and once at its end, which runs faster. With -Oz, each loop tests once, at the top, and jumps back. Each pixel costs one more jump, and the APK loses 59 bytes.

-fno-asynchronous-unwind-tables and -fno-unwind-tables remove .eh_frame, the tables that let a debugger or a C++ exception walk the stack. C has no exceptions. A crash report from this app has no backtrace, and the APK loses 140 bytes.

-fomit-frame-pointer stops each function that calls another from setting up a frame pointer, the register that chains the stack frames so that a profiler can walk them. The stack could not be walked from the unwind tables anyway: they are already gone. It saves another 30 bytes.

-fno-plt changes how the code calls libandroid.so. By default each call goes to a small stub in .plt, which jumps to the address the dynamic linker wrote in the GOT, the table of imported addresses. With -fno-plt, each call loads the address from the GOT itself. That is two more instructions per call, but no stubs. It saves 44 bytes on arm64, and changes nothing on armeabi-v7a.

$(THUMB_$(ABI)) is -mthumb for armeabi-v7a, and nothing for the other ABIs. Thumb encodes most instructions in 2 bytes instead of 4. ndk-build and CMake turn it on, but clang alone does not, and for Android it even turns a thumbv7a target back into ARM code. It saves 70 bytes on armeabi-v7a.

For armeabi-v7a, $(THUMB_$(ABI)) also holds -Xclang -exception-model=dwarf. On that ABI, clang writes an entry in the ARM unwind table, .ARM.exidx, for every function, even without unwind tables, and lld adds a program header that points to it. Each entry only says that its function cannot be unwound. With DWARF as the exception model, clang writes none, and the armeabi-v7a APK loses 4 bytes. It is an internal clang option, passed through -Xclang, so a later NDK may break it.

-ffunction-sections and -fdata-sections only matter to the linker, so they are measured in the next section. -Wall -Wextra only turn on warnings.

Two flags I expected to help saved nothing, and I left them out. -fvisibility=hidden hides every function that is not marked otherwise. The only function that is not static is the entry point, which has to stay visible, and with it marked the APK stayed the same size. -fno-stack-protector removed nothing, because the NDK's clang does not turn the stack protector on by itself. ndk-build and CMake add it.

After the compiler flags, the APK drops to 2,317 bytes.

Linker flags

The linker flags:

LDFLAGS = -shared -nostdlib -s -Wl,--gc-sections \
          -Wl,--no-rosegment,-z,norelro \
          -Wl,-z,lazy,-z,nognustack \
          -Wl,--pack-dyn-relocs=android \
          -Wl,-z,max-page-size=16384

-s removes the symbol table, which only a debugger reads, and saves 162 bytes.

-s keeps one section that only tools read, .comment, where clang and lld store their versions:

Android (12328485, +pgo, +bolt, +lto, +mlgo, based on r530567)
clang version 19.0.0 (https://android.googlesource.com/toolchain/
llvm-project 97a699bf4812a18fb657c2779f5296a4ab2694d2)
Linker: LLD 19.0.0

lld has no option to leave its line out, and -fno-ident only removes clang's. llvm-objcopy -R .comment removes the section, and saves 191 bytes. On armeabi-v7a, -s also keeps .ARM.attributes, 60 bytes that tell other linkers which ARM features the code uses. llvm-objcopy -R .ARM.attributes removes it, and the armeabi-v7a APK loses 45 bytes.

The Makefile does not need llvm-objcopy, though. Both sections come after the code and data, and apk.py cuts them off later, as explained below. The rule for the library:

$(LIB): main.c apk.py
	mkdir -p $(@D)
	$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LIBS)
	python3 apk.py elf $@
	python3 apk.py phdr $@

The apk.py elf line goes further. A library has two tables of contents. The program headers tell the loader what to map, and the section headers name every part for tools such as nm and objdump. The loader works from the program headers, so strip --strip-sections, or sstrip, removes the section headers on Linux. Android's loader rejects such a library. Its linker_phdr.cpp requires section headers, and looks for the header of .dynamic, which must link to the header of a string table.

After -s, and without .comment, llvm-readelf -S lists nine section headers, 64 bytes each on arm64:

SectionDescriptionHeader kept
(null)empty, required by ELF
.dynsymexported and imported symbols
.gnu.hashhash table of the exported symbol
.dynstrsymbol names and libandroid.soyes
.rela.dynrelocations of the imports
.textcode
.dynamicwhat the dynamic linker readsyes
.gotaddresses of the imports
.shstrtabsection names

lld writes the section headers at the end of the file, right after .shstrtab, the names of the sections. apk.py elf cuts the file at the start of .shstrtab, which removes the names and all nine headers. Then it appends the two headers that Android checks, .dynstr and .dynamic. ELF wants the empty header first, but Android never reads it, so apk.py elf drops it too. It then updates the ELF header with the new position and count of the section headers. The header of .dynamic now links to header 0, where .dynstr is. The ELF header also gives the index of the table of section names, which lld set to .shstrtab. Android only checks that it is not 0, so apk.py elf sets it to 1, the header of .dynamic, which holds no names. llvm-readelf -S warns about it, and still lists both headers. Nothing else changes: the code and the other sections stay at the same place in the file, and the loader still finds them through the program headers.

The library loses 600 bytes, and the APK 165. Without those headers, nm, objdump and readelf --dyn-syms no longer find the symbols or the code. llvm-readelf -d, however, still prints the dynamic section.

--gc-sections drops every section nothing refers to, and -ffunction-sections -fdata-sections give each function and variable a section of its own, so that it can be dropped alone. This code has nothing to drop, yet the APK shrank by 1 byte. With one section per function, lld rewrote three adrp and add pairs into a nop and an adr each, which compress better. I have not looked into why it does so only then.

max-page-size=16384 aligns the segments on 16 KB, which devices with 16 KB pages require. It is the default since NDK r28 and adds nothing to the file, but older NDKs align on 4 KB.

lld normally puts the read-only data, such as the dynamic symbol table and its strings, in a segment of its own, apart from the code. --no-rosegment puts them in the code's segment, which saves a program header and the padding between the two, 11 bytes. The read-only data becomes executable.

RELRO marks the table of imported function addresses read-only once the dynamic linker has filled it in. In the library it is one more segment, GNU_RELRO. -z norelro removes it, and the table stays writable for the life of the process. It saves 11 bytes. Measured before the section headers went, it saved 34.

The dynamic linker finds the exported ANativeActivity_onCreate through a hash table. The NDK's clang asks lld for the GNU style only, with --hash-style=gnu, since Android reads it from API 23. --hash-style=sysv asks for the older System V table instead. It makes the library 48 bytes larger, and the APK no smaller, so I left it out.

The NDK's clang passes -z now to lld: resolve every import when the library loads, not at its first call. lld records it as two flags in .dynamic. Bionic never resolves an import late, so it ignores them. -z lazy leaves out the two entries, 16 bytes each, and saves 8 bytes.

PT_GNU_STACK is a program header that says whether the stack may be executable. The kernel reads it in a program, but bionic never looks at it in a library. -z nognustack leaves it out, 56 bytes on arm64, and saves 9 bytes more.

The dynamic linker writes the address of each of the seven imports in the GOT. lld describes each write with a relocation of 24 bytes: the offset of the slot, the type and symbol, and an addend. --pack-dyn-relocs=android writes them in Android's own packed format instead. It stores once what the seven share, the 8 bytes between two slots and the addend 0, and keeps only the type and symbol of each. The 168 bytes of relocations become 63. The library loses 104 bytes, but the APK gains 2, because the plain records deflated well. In the final build, though, the APK is 4 bytes bigger without it, so I kept it. Bionic reads this format since API 23. On armeabi-v7a it changes nothing: clang ignores -fno-plt there, the imports keep their PLT stubs, and lld does not pack the PLT's relocations.

One more program header can go. lld always starts the table with PT_PHDR, which only says where the program headers are, and has no option to leave it out. Bionic uses it when it is there. Otherwise it takes the first segment, if that segment starts at offset 0 of the file: it then begins with the ELF header, which gives the position of the program headers. Ours does, so I added a phdr command to apk.py, the last line of the rule above. It moves the three other program headers up, and the ELF header now counts three. The 56 bytes left free are zeros, which deflate to almost nothing.

The same command also cleans up .dynamic. lld writes three entries there that tell bionic what it already knows: DT_SYMENT, the size of a symbol, DT_RELAENT, the size of a relocation, and on armeabi-v7a DT_PLTREL, the type of the PLT's relocations. Bionic only checks them when they are there, so apk.py phdr removes them and fills the end of .dynamic with zeros.

The two section headers Android checks can share their bytes with the program headers. Bionic reads four fields of the .dynamic header, its type, offset, size and link, and three of the string table's header: type, offset and size. On arm64, the PT_DYNAMIC program header already reads as the .dynamic section header. Its flags, 6 for read and write, are the value of SHT_DYNAMIC, and its size in the file is the size of the section. Its physical address and its size in memory, which bionic never uses, take the offset and the link. The header of the string table goes in the 56 zeros left by PT_PHDR. On armeabi-v7a, the headers are smaller and fall over other fields bionic never reads, in the program headers and the symbol table.

lld writes the GOT right after .dynamic. In the file it holds only zeros: the dynamic linker writes the addresses of the imports in it when it loads the library. So apk.py phdr ends the file after .dynamic, and the GOT, like the zeros at the end of .dynamic, only exists in memory. Everything lld wrote after the GOT goes with it: .comment, .ARM.attributes on armeabi-v7a, and the section headers that apk.py elf appended. Bionic checks that the offset and size of the .dynamic header match PT_DYNAMIC, and they do. llvm-readelf still warns that the header is not inside the segment. I only tested this on Android 16. With the program header and the three entries, we save another 41 bytes in the APK.

--build-id=none removed nothing: the NDK's clang writes no build ID unless asked, so I left it out.

--no-rosegment and -z norelro weaken the library's protection against memory bugs. In our case, the app never reads the contents of an input event. Android 12 and 16 load the library without them. Together they save 22 bytes, and 22 bytes is 22 bytes.

After the linker flags, the APK drops to 1,720 bytes, under 2 KB!

Manifest

The manifest is 550 bytes:

<manifest
  xmlns:android="http://schemas.android.com/apk/res/android"
  package="c.tiny">
  <uses-sdk
    android:minSdkVersion="24"
    android:targetSdkVersion="36"/>
  <application
    android:hasCode="false"
    android:label="Tiny">
    <activity
      android:name="android.app.NativeActivity"
      android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>
    </activity>
  </application>
</manifest>

aapt2 compiles it to Android's binary XML, and it becomes 1,728 bytes:

Part of the binary manifestSize (bytes)
string pool, 26 strings in UTF-16952
start and end of the 7 elements680
namespace start and end48
resource ids of the Android attributes40
header8

Every string takes two bytes per character, plus a length and a terminator. Every element has a fixed header, and every attribute takes 20 bytes. aapt2 also adds the following four attributes automatically, to record the SDK it compiled against: compileSdkVersion, compileSdkVersionCodename, platformBuildVersionCode and platformBuildVersionName.

--no-compile-sdk-metadata leaves them out and saves 92 bytes. --no-xml-namespaces removes the two namespace nodes and the prefix string android, and saves 24 bytes. The URI itself stays, because every android: attribute still points to it. I tried removing it too. The package manager then refused the APK at the first <action>: "No value supplied for <android:name>". It reads the names of actions and categories by namespace and name, so the URI stays. Android finds the attributes by their resource ids, and still reads the SDK versions.

That leaves 20 strings, all plain ASCII, still stored in UTF-16. The string pool format has a UTF-8 flag, and aapt2 uses it for every other XML file it compiles. For the manifest it is hardcoded off, and a comment in aapt2's source gives the reason: "Only needed for AndroidManifest.xml to avoid a bug in certain non-AOSP platforms." No option turns it on, so apk.py manifest rewrites the pool after aapt2, and 704 bytes of strings become 428.

The same command does three more things. Android finds most attributes by their resource id, not by their name, so the names of label, hasCode, exported and the two SDK versions can be empty strings. Here is the end of the line aapt2 dump xmltree prints for hasCode, before and after:

android:hasCode(0x0101000c)=false
android:(0x0101000c)=false

Android still finds the attribute by its resource id, 0x0101000c. The five empty strings then share one entry in the pool. name has to stay, since actions and categories are looked up by it. And every node keeps the line it came from in the text file, and the index of a comment, which only error messages use. Set to 0 everywhere, the nodes deflate better.

aapt2 sorts the strings of the pool alphabetically, but Android reads them in any order. Only the strings listed in the resource map have a fixed place, at the start of the pool, so apk.py manifest puts the others shortest first, which deflates a few bytes better. A script that tried other orders found one 5 bytes smaller. It follows no rule, so apk.py would have to hardcode it for this manifest. I kept shortest first.

The file also ends with five closing tags in a row, for the category, the intent-filter, the activity, the application and the manifest. The parser stops at the end of the data anyway, so they can go. A closing tag in the middle has to stay, or the next element would end up inside the one left open. The manifest is no longer well-formed XML. aapt2 dump xmltree still reads it, and my Android 16 phone installs it and still lists the app in the launcher. With all of it, the APK loses 138 bytes.

I tried three more cuts. My Android 16 phone accepts all three, but I did not keep them. Without android:label, the APK is 1,841 bytes, 13 fewer, but the launcher shows the activity's class name under the icon, android.app.NativeActivity, instead of Tiny. Without android:targetSdkVersion, the target falls back to the minimum, API 24, and android:exported is only required from API 31, so both attributes can go, for 19 bytes, and the APK is 1,835 bytes. But the app then declares it was written for Android 7.0, and runs in that version's compatibility mode. Without the intent-filter, the APK loses 100 bytes, and the launcher no longer lists the app: only adb shell am start can open it. Even then the namespace cannot go. The package manager reports "code is missing", because it no longer sees android:hasCode. Its URI cannot be shortened either: without http:// the package manager no longer finds android:name.

The text of the manifest does not change. In binary, it drops to 888 bytes, 381 compressed, and the APK to 1,466 bytes.

No resources

With no resources, aapt2 still writes a resources.arsc of 40 bytes, which the app does not need. The Makefile deletes it, rewrites the manifest's strings, adds the library, and compresses the zip again:

unsigned.apk: AndroidManifest.xml $(LIB) apk.py
	$(BT)/aapt2 link -o $@ -I $(JAR) \
	  --manifest AndroidManifest.xml \
	  --no-compile-sdk-metadata --no-xml-namespaces
	zip -q -d $@ resources.arsc
	python3 apk.py manifest $@
	touch -t 198001010000 $(LIB)
	chmod 755 $(LIB)
	zip -q -9 -X -D $@ $(LIB)
	advzip -q -z -4 -i 1000 $@

extractNativeLibs decides how Android gets the library. At its default, true, the installer copies the library out of the APK, so the entry can be compressed. With false, Android maps the library straight from the APK, which works only if the entry is stored uncompressed and aligned on a page. Stored, this library would cost about 680 bytes more, plus padding to the next 16 KB, so the manifest keeps the default.

A zip entry also stores the date of its file and its Unix permissions. aapt2 dates its entries 1 January 1980, the earliest date a zip can hold. touch gives the library the same date, and chmod fixed permissions, so the zip records neither when the library was built nor the shell's umask. Two builds of the same source give the same bytes.

Removing the 40 byte file saves 144 bytes, since each zip entry also has a local header and a record in the central directory.

The unsigned APK drops to 1,322 bytes.

APK compression

An APK is a zip file. A zip can compress its entries with bzip2 or LZMA, but Android's zip reader, libziparchive, only accepts entries that are stored or deflated, and the others are rejected at install. What can change is the deflate encoder. The format leaves the encoder free to choose how it describes the data, and any inflater reads the result. zip -9 uses Info-ZIP's own deflate, which limits its search to stay fast. 7-Zip searches longer, and zopfli, from Google, longer still:

EncoderManifest, deflated (bytes)Library, deflated (bytes)
none (stored)8881,344
zip -9381681
7-Zip, -mx=9 -mfb=258 -mpass=15374678
zopfli, 1,000 iterations373666

advzip, from AdvanceCOMP, deflates every entry of a zip again with zopfli, and keeps the dates and headers as they are. It is the last line of the rule above, and adds 3 seconds to the build. The unsigned APK drops to 1,299 bytes.

What zopfli finds also depends on the order of the functions in the code. Moving them around in main.c changes nothing: clang writes the static functions in the order the code uses them, whatever their place in the file. lld follows a list instead, given with --symbol-ordering-file. With four functions, there are 24 orders. Built with each of them, the library compresses to between 665 and 673 bytes on arm64, and between 584 and 592 on armeabi-v7a. The best order saves 1 byte on arm64 and 4 on armeabi-v7a, compared to lld's own, but costs 2 on x86_64. It follows no rule, so the Makefile would have to hardcode it for this code. I left the order to lld.

No padding

Android installs only signed APKs, and an app that targets API 30 or later needs APK Signature Scheme v2. A v2 signature lives in a signing block, inserted in the zip file between the last entry and the central directory:

[entries]
[signing block]
[central directory]
[end record]

The older JAR signature is only needed below API 24, so the app sets its minimum to 24 and has none. The key is an EC P-256 key, with a self-signed certificate. openssl x509 makes one of 320 bytes by default. The Makefile uses openssl req -x509v1 instead, for an X.509 v1 certificate, which has no version field and no extensions, so no key identifier either. It asks for serial number 1 instead of 20 random bytes, and ends the validity on 31 December 2049: after 22 October 2033, which Google Play requires, and before 2050, when X.509 dates get two bytes longer. Google sets no rule for the name, so it is CN=a. The certificate signs itself, and its ECDSA signature usually takes 70 to 72 bytes, depending on the random nonce. The Makefile runs openssl again until the signature takes 69, about once in 500 tries, several seconds the first time. The certificate is 256 bytes. Android compares the whole certificate when it updates an app, so a new certificate, even for the same key, means uninstalling the old app first.

The SDK's apksigner turned the 1,299 byte APK into 8,349 bytes. That is 129 bytes more than the APK I started from, glue and C library included. All the work above, undone by one command!

The entries end at byte 1,142. apksigner fills the gap up to byte 4,096 with zeros, starts the signing block there, and pads the block to 4,096 bytes:

Part of app.apkSize (bytes)
zeros after the entries2,954
v2 signature513
padding inside the block3,527

With a 2,048 bit RSA key and apksigner's defaults, the APK is 8,534 bytes. The defaults also add v3 and the old JAR signature, three files under META-INF. The files fit in the zeros before byte 4,096, and the bigger RSA signatures fit in the padding. Only the three files' records in the central directory make the APK bigger, by 185 bytes.

Neither --alignment-preserved nor --verity-enabled false changed the output. apksig, the Java library that does apksigner's signing, always adds the padding, and none of apksigner's options turns it off. The constants are named ANDROID_COMMON_PAGE_ALIGNMENT_BYTES, 4096, and VERITY_PADDING_BLOCK_ID, the id of the padding pair. Android needs the alignment only to build a hash tree of the APK, one hash per 4 KB page, so that it can check each page as it reads it. It installs the app without it. So it adds 6,495 bytes, mostly zeros, to our 1,854 byte app.

Those bytes had to go. I added a signer to apk.py that writes the same signing block without the zeros or the padding, and a make tiny target to the Makefile that calls it.

A v2 signature signs a SHA-256 digest of the three other parts of the zip file, each cut in chunks of 1 MB. Here each part fits in one chunk. Each chunk is hashed, then the list of hashes is hashed. apk.py sign puts that digest and the certificate in a small structure, and has openssl dgst -sign sign it. An ECDSA signature needs a random number, the nonce. apk.py sign asks OpenSSL for a deterministic one, as in RFC 6979, so the same key and the same APK give the same signature, and two builds give the same file.

Then it writes the block, and moves the central directory offset in the end record past the block. The end record is hashed before that change. The verifier puts the start of the signing block back in that field before it hashes, and before the change the field already holds that value.

The block is now 555 bytes, compared to apksigner's 4,096! The APK is only 1,854, and apksigner verify accepts the trick.

Summary

Each step for arm64-v8a, from the starting point. Down to the advzip row, the sizes are those of the unsigned APK, which ends at 1,299 bytes. The last three rows are that same APK signed in three different ways, so their Saved column is empty:

StepAPK (bytes)Saved (bytes)
start: glue, C library, -O28,220
No glue3,3514,869
No C library: -nostartfiles2,604747
No C library: -nodefaultlibs2,59014
Compiler: -Oz2,53159
Compiler: no unwind tables2,391140
Compiler: -fomit-frame-pointer2,36130
Compiler: -fno-plt2,31744
Linker: -s2,155162
Linker: .comment removed1,964191
Linker: apk.py elf, 2 section headers1,799165
Linker: function sections, --gc-sections1,7981
Linker: max-page-size=163841,7980
Linker: --no-rosegment1,78711
Linker: -z norelro1,77611
Linker: -z lazy1,7688
Linker: -z nognustack1,7599
Linker: --pack-dyn-relocs=android1,761-2
Linker: apk.py phdr, no PT_PHDR, overlapping headers1,72041
Manifest: --no-compile-sdk-metadata1,62892
Manifest: --no-xml-namespaces1,60424
Manifest: apk.py manifest1,466138
No resources: resources.arsc removed1,322144
APK compression: advzip, zopfli1,29923
Signing: apksigner, RSA 20488,534
Signing: apksigner, EC P-2568,349
Signing: apk.py sign1,854

The armeabi-v7a build, signed the same way, is 1,780 bytes, smaller than the arm64 build, thanks to Thumb code and the two cuts that only apply to it. Android 16 still runs it, after a warning that it is not compatible with the latest version of Android. The x86_64 build is 1,807 bytes. It is not tested.

Conclusion

The app started at 8,220 bytes and ends at 1,854, under a quarter of its size, and it still draws the same gradient. The glue alone was 4,869 bytes. Most of the other cuts are worth 10 or 20 bytes, and there are many of them.

The compiler and the linker also add a lot that this app never uses. That is the startup object for atexit and fork handlers, the unwind tables, the symbol table, the version strings in .comment, seven of the nine section headers, and the flags and headers bionic ignores. At the -Oz step, the library is 4,168 bytes, and 2,384 of them are such data. In the APK, all of it, startup object included, costs 1,463 bytes, more than the whole unsigned APK at the end.

The code of the whole app, gradient and input handling included, is now only 404 bytes of arm64 instructions. In the APK, the library takes 666 bytes compressed, the signing block 555 and the manifest 373. The other 260 are the zip's headers and central directory.

Next

The next step is to replace apk.py with small C command line tools, one per job, the signer first. Stay tuned!