Android Studio, without the studio part
Recently my trusty old Nook Glowlight 3 stopped being able to sync with Dropbox, even after a couple of restarts so I had no way of transferring my books onto it1.
I don't even remember how many years ago (probably 10-ish) I installed Dropbox on it, along with a custom launcher and a file explorer, but it was working without a hitch up until a few months ago.
I then got the bright idea of trying to log out and log back in and proceeded to be unable to even log in which means all of my previously transferred books just dematerialized.
It probably has to do with the fact that it's running Android 4.4.22 which is from the time when the Harlem Shake was considered peak culture.
According to this article, Amazon is dropping support for perfectly good Kindle devices as well, go figure.
Welp, nothing else to do except write a poor man's version of Dropbox in zig, right fellas?
And since I'm not a fan of Android Studio it's going to have to be done manually.
If you wish to make an APK from scratch, you must first invent the universe.
The plan
Nothing too fancy, I just want to be able to upload files from my laptop, I care not about much else, curl as a client will suffice.
So a simple3 HTTP server that would store files somewhere onto the device seems good enough and zig has several of those available, even one in its standard library so we're set and ready to go.
Note: There's the zig-android-sdk project which was a treasure trove of information, as well as its inspiration rawdrawandroid, but I wanted to see for myself how hard making an android application from scratch could really be so I didn't use any dependencies.
The Android SDK package (either installed manually or via Android Studio) comes with most of the things one might need for this:
platform-tools, for e.g. talking to the device viaadbbuild-tools, for e.g. packaging, signing, aligning thingsNDK, for building and linking the actual codeplatforms, for some supplementary artifacts
Android Studio actually does a lot of the heavy lifting for you, and I mean a lot, what with neatly packaging resources, enabling debugging, gradle magic and whatnot.
You press the little Play icon at the top and voilà4, some time and several gigabytes of RAM later an application launches on your connected device!
But... what is an Android application? 🤔
Anatomy of an APK
The APK itself is actually an ordinary zip file, you can try extracting it using 7z or any other similar tool and peek inside any APK you come across.
The core of a minimal APK is just a handful of things since we don't need any resources nor layouts, and we'll try to give the JVM as wide a berth as we can.
What we have to have is an AndroidManifest.xml, and since we'll be using a NativeActivity we need a shared/dynamic library as the entrypoint.
The device is named Nook, so a library named cranny seemed apt.
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<manifest
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android" package="com.cranny.zig">
<uses-sdk android:minSdkVersion="19" android:targetSdkVersion="19"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application android:label="Cranny" android:hasCode="false" android:debuggable="true">
<activity
android:label="Cranny" android:name="android.app.NativeActivity" android:exported="true">
<meta-data android:name="android.app.lib_name" android:value="cranny"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
Nothing too wild here; labels, schemas, permissions, but also the entrypoint library metadata (android.app.lib_name) is set to cranny (libcranny.so in actuality).
That's the dynamic library the Android ecosystem will try to load when we try launching our application.
Do note that the target SDK version is 19 (the latest version as of the beginning of 2026 is 36), we'll come back to what a pain that is later on.
By default the entrypoint of it is a ANativeActivity_onCreate function which we can easily export from our zig code.
Its signature can be found in Android's NDK, in android/native_activity.h:
/**
* This is the function that must be in the native code to instantiate the
* application's native activity. It is called with the activity instance (see
* above); if the code is being instantiated from a previously saved instance,
* the savedState will be non-NULL and point to the saved data. You must make
* any copy of this data you need -- it will be released after you return from
* this function.
*/
typedef void ANativeActivity_createFunc(ANativeActivity* activity,
void* savedState, size_t savedStateSize);
/**
* The name of the function that NativeInstance looks for when launching its
* native code. This is the default function that is used, you can specify
* "android.app.func_name" string meta-data in your manifest to use a different
* function.
*/
extern ANativeActivity_createFunc ANativeActivity_onCreate;
Armed with that knowledge, we can make our very own basic zig Android application that does absolutely nothing:
export fn ANativeActivity_onCreate(_: *anyopaque, _: *anyopaque, _: usize) callconv(.c) void {}
Make sure to compile it for the target device which in our case is arm-linux-androideabi:
$> zig build-lib cranny.zig -dynamic -target arm-linux-androideabi -femit-bin=libcranny.so
$> file libcranny.so
libcranny.so: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, stripped
Now that we have a dynamic library and a manifest, we can start packing things up!
One of the tools in the build-tools package is aapt5 which packages the Android resources like images, layouts, manifests, jar files etc.
We'll use it to bundle our AndroidManifest.xml and an android.jar that comes with platforms since the manifest refers to it for resolving resource identifiers.
# "-v" - verbose
# "-f" - force overwriting files
# "-I android.jar" - add an existing package to base include set
# "-M AndroidManifest.xml" - path to the AndroidManifest.xml
# "-F cranny.apk" - output file
$> aapt package -v -f -I /path/to/android.jar -M /path/to/our/AndroidManifest.xml -F cranny.apk
We'll also throw our dynamic library in there:
$> aapt add -v cranny.apk lib/armeabi-v7a/libcranny.so
Note: the paths to the library have to be in the form of lib/<abi>/lib<name>.so as described here so be careful when adding it.
The next step is to use zipalign to optimize the APK so it can load things using mmap, but we don't really care about that so we'll skip it.
What we can't skip is signing the APK in order to be able to install it, and for that we'll use your run of the mill keystore provided by the system java runtime package (probably).
We'll generate a keystore and then we'll sign the APK through apksigner using said keystore:
$> keytool -genkey -v \
-keystore my-keystore.keystore \
-alias mykey \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass hunter2 \
-keypass hunter2 \
-dname "CN=example.com, OU=ID, O=Example, L=Doe, S=John, C=GB"
$> apksigner sign \
--ks my-keystore.keystore \
--key-pass "pass:hunter2" \
--ks-pass "pass:hunter2" \
cranny.apk
Note: if you try reinstalling the app signed with newly generated keystore it will fail, so don't throw the keystore with the bathwater. If that does happen, just uninstall the application and all is well.
Now we're ready to install and launch it:
$> adb install -r cranny.apk
Performing Push Install
cranny.apk: 1 file pushed, 0 skipped. 26.0 MB/s (29104 bytes in 0.001s)
pkg: /data/local/tmp/cranny.apk
Success
$> adb shell am start com.cranny.zig/android.app.NativeActivity
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.cranny.zig/android.app.NativeActivity }
If everything's working as expected you should have an app with a blank activity pop up on the screen. That's the gist of creating an APK the old fashioned way, just like our grandparents used to do.
The next chapter will roughly describe the timeline of the entire adventure that this was, if you're not interested in seeing all the jagged edges and detours, feel free to jump to the conclusion.
The zig part
Hoo boy this was a doozy.
I've recently delighted about zig being readable, but being able to read something doesn't equate to actually grokking it, which will become apparent down the line.
I'll gloss over the finer details of how to build all of this, but it should all be in the repo in build.zig (in one of them, probably the from-scratch/build.zig one).
Let's start working on the initial idea, an HTTP server, using zig version 0.15.2, which was the style at the time.
It would be nice to be able to log things so we'll use @cImport/@cInclude6 to get access to logging utilities defined in android/log.h.
We'll also include android/native_activity.h so we can use the ANativeActivity passed into our entrypoint function since we need a few of its members:
callbacks- a table of function pointers for hooking up into the application's lifecycle (onStart,onStopetc.)externalDataPath- path to the application's removable/mountable data directory, which sounds like a good destination for our uploaded files
We'll instruct zig to link with bionic libc that comes with the NDK, as well as add the library and include paths.
Since something like this doesn't really need a GUI, I'll consider the onStart to be a good enough signal for the server start listening for a connection, and once it goes into the background the device is set up to kill the application so it'll trigger onStop callback.
Oh right, and it has to be in a separate thread so it's not blocking anything7:
const std = @import("std");
const android = @cImport({
@cInclude("android/log.h");
@cInclude("android/native_activity.h");
});
fn LOGI(msg: [*c]const u8) void {
_ = android.__android_log_write(android.ANDROID_LOG_INFO, "COM_CRANNY_ZIG", msg);
}
export fn ANativeActivity_onCreate(activity: *android.ANativeActivity, _: *anyopaque, _: usize) void {
activity.*.callbacks.*.onStart = &onStart;
activity.*.callbacks.*.onStop = &onStop;
}
var worker: ?std.Thread = null;
var server_running: bool = false;
fn onStart(_: [*c]android.ANativeActivity) callconv(.c) void {
LOGI("+onStart()");
defer LOGI("-onStart()");
if (worker == null) {
server_running = true;
worker = std.Thread.spawn(.{}, runServer, .{}) catch |err| return LOGI(@errorName(err);
}
}
fn onStop(_: [*c]android.ANativeActivity) callconv(.c) void {
LOGI("+onStop()");
defer LOGI("-onStop()");
if (worker) |t| {
server_running = false;
LOGI("Joining worker thread...");
t.join();
worker = null;
}
}
fn runServer() void {
LOGI("+runServer()");
defer LOGI("-runServer()");
}
We can check with adb logcat that it works as expected:
04-12 02:21:52.779 1464 2521 I ActivityManager: Start proc 22207:com.cranny.zig/u0a75 for activity com.cranny.zig/android.app.NativeActivity
04-12 02:21:52.941 22207 22207 I COM_CRANNY_ZIG: +onStart()
04-12 02:21:52.941 22207 22207 I COM_CRANNY_ZIG: -onStart()
04-12 02:21:52.941 22207 22222 I COM_CRANNY_ZIG: +runServer()
04-12 02:21:52.941 22207 22222 I COM_CRANNY_ZIG: -runServer()
04-12 02:21:53.005 1464 1512 I ActivityManager: Displayed com.cranny.zig/android.app.NativeActivity: +243ms
The HTTP server itself is pretty straight forward, let's see if we get a connection first:
fn runServer() void {
LOGI("+runServer()");
defer LOGI("-runServer()");
const address = std.net.Address.parseIp4("0.0.0.0", 7979) catch |err| return LOGI(@errorName(err));
var server = address.listen(.{ .reuse_address = true }) catch |err| return LOGI(@errorName(err));
defer server.deinit();
LOGI("Waiting for a connection...");
var connection = server.accept() catch |err| return LOGI(@errorName(err));
defer connection.stream.close();
LOGI("Connection established");
}
An ordinary curl <IP>:7979 returns success, so let's keep going, let's try sending a file and writing the entire request body somewhere.
That somewhere is the ANativeActivity.externalDataPath mentioned before which we'll just hardcode now, for the sake of brevity and easier testing:
// `conn` is the server.accept() return value
// `storage` is hardcoded to "/storage/emulated/0/Android/data/com.cranny.zig/files"
fn handleConnection(conn: std.net.Server.Connection, storage: []const u8) void {
LOGI("+handleConnection()");
defer {
conn.stream.close();
LOGI("-handleConnection()");
}
var recv_buffer: [512]u8 = undefined;
var send_buffer: [512]u8 = undefined;
var connection_br = conn.stream.reader(&recv_buffer);
var connection_bw = conn.stream.writer(&send_buffer);
var server = std.http.Server.init(connection_br.interface(), &connection_bw.interface);
var request = server.receiveHead() catch |err| return LOGI(@errorName(err));
defer request.respond("Thanks for all the fish!\n", .{}) catch |err| LOGI(@errorName(err));
switch (request.head.method) {
.POST => {
LOGI("Processing a POST request");
var dir = std.fs.cwd().openDir(storage, .{}) catch |err| return LOGI(@errorName(err));
defer dir.close();
var file = dir.createFile("output.bin", .{}) catch |err| return LOGI(@errorName(err));
defer file.close();
var rbuf: [512]u8 = undefined;
var reader = request.readerExpectContinue(&buf) catch |err| return LOGI(@errorName(err));
var wbuf: [512]u8 = undefined;
var file_writer = file.writer(&wbuf);
_ = reader.streamRemaining(&file_writer.interface) catch |err| return LOGI(@errorName(err));
file_writer.interface.flush() catch |err| return LOGI(@errorName(err));
},
else => LOGI("Request type not supported"),
}
}
Sending a random file via curl seems to be in order:
$> echo "hello world" > file.txt
$> curl --verbose --form 'file=@file.txt;type=application/octet-stream' 192.168.1.24:7979
* Trying 192.168.1.24:7979...
* Established connection to 192.168.1.24 (192.168.1.24 port 7979) from 192.168.1.100 port 35328
* using HTTP/1.x
> POST / HTTP/1.1
> Host: 192.168.1.24:7979
> User-Agent: curl/8.19.0
> Accept: */*/
> Content-Length: 224
> Content-Type: multipart/form-data; boundary=------------------------bJvzYYRSTVqPbpsHz2N4Oj
>
* upload completely sent off: 224 bytes
< HTTP/1.1 200 OK
< content-length: 25
<
Thanks for all the fish!
* Connection #0 to host 192.168.1.24:7979 left intact
$> adb shell cat /storage/emulated/0/Android/data/com.cranny.zig/files
--------------------------bJvzYYRSTVqPbpsHz2N4Oj
Content-Disposition: form-data; name="file"; filename="file.txt"
Content-Type: application/octet-stream
hello world
--------------------------bJvzYYRSTVqPbpsHz2N4Oj--
Great! Let's send an epub to see if it's the real deal:
$> curl --verbose --form 'file=@linkers_and_loaders.epub;type=application/octet-stream' 192.168.1.24:7979
* Trying 192.168.1.24:7979...
* Established connection to 192.168.1.24 (192.168.1.24 port 7979) from 192.168.1.100 port 53194
* using HTTP/1.x
> POST / HTTP/1.1
> Host: 192.168.1.24:7979
> User-Agent: curl/8.19.0
> Accept: */*
> Content-Length: 3315140
> Content-Type: multipart/form-data; boundary=------------------------KWGgpBHBIcNNbfcrGtEbin
> Expect: 100-continue
>
< HTTP/1.1 100 Continue
<
Uhhh... it's stuck?
adb logcat confirms it:
04-13 02:06:53.781 20785 20785 I COM_CRANNY_ZIG: +onStart()
04-13 02:06:53.781 20785 20785 I COM_CRANNY_ZIG: -onStart()
04-13 02:06:53.781 20785 20801 I COM_CRANNY_ZIG: +runServer()
04-13 02:06:53.781 20785 20801 I COM_CRANNY_ZIG: Listening...
04-13 02:06:53.782 20785 20801 I COM_CRANNY_ZIG: Waiting for a connection...
04-13 02:06:53.837 1464 1512 I ActivityManager: Displayed com.cranny.zig/android.app.NativeActivity: +197ms
04-13 02:06:56.104 20785 20801 I COM_CRANNY_ZIG: +handleConnection()
04-13 02:06:56.104 20785 20801 I COM_CRANNY_ZIG: Processing a POST request
One might think it's due to the Expect: 100-continue header, but it's not.
After trying out several things and stepping through the code via lldb, it seems that whenever the reader has to stream more than what fits in buf an integer-overflow crops up somewhere.
And it doesn't get any better if I omit the buffer and write directly to file_writer.writer.
I tried this same implementation on desktop and it works so I have no idea how to deal with that, reading in chunks doesn't help either. Maybe updating to zig-trunk will help, we'll see.
A little disclaimer - up until now I've been actually testing this on an armv8 Android phone because I didn't yet have adb on the Nook reader at that point and because it was easier to handle.
But I had managed to get adb access in the meantime so even though I've hit a snag with the actual implementation, I've decided to start/continue the testing on the actual device I was aiming for.
So, we build it for and run it on the actual Nook device, and...
E/AndroidRuntime( 1940): FATAL EXCEPTION: main
E/AndroidRuntime( 1940): Process: com.cranny.zig, PID: 1940
E/AndroidRuntime( 1940): java.lang.RuntimeException: Unable to start activity ComponentInfo{com.cranny.zig/android.app.NativeActivity}: java.lang.IllegalArgumentException: Unable to load native library: /data/app-lib/com.cranny.zig-1/libcranny.so
E/AndroidRuntime( 1940): at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2195)
E/AndroidRuntime( 1940): at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2245)
E/AndroidRuntime( 1940): at android.app.ActivityThread.access$800(ActivityThread.java:135)
E/AndroidRuntime( 1940): at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1196)
E/AndroidRuntime( 1940): at android.os.Handler.dispatchMessage(Handler.java:102)
E/AndroidRuntime( 1940): at android.os.Looper.loop(Looper.java:136)
E/AndroidRuntime( 1940): at android.app.ActivityThread.main(ActivityThread.java:5017)
E/AndroidRuntime( 1940): at java.lang.reflect.Method.invoke(Native Method)
E/AndroidRuntime( 1940): at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:779)
E/AndroidRuntime( 1940): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:595)
E/AndroidRuntime( 1940): Caused by: java.lang.IllegalArgumentException: Unable to load native library: /data/app-lib/com.cranny.zig-1/libcranny.so
E/AndroidRuntime( 1940): at android.app.NativeActivity.onCreate(NativeActivity.java:183)
E/AndroidRuntime( 1940): at android.app.Activity.performCreate(Activity.java:5270)
E/AndroidRuntime( 1940): at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1087)
E/AndroidRuntime( 1940): at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2159)
E/AndroidRuntime( 1940): ... 9 more
Filtering out the noise:
Unable to load native library: /data/app-lib/com.cranny.zig-1/libcranny.so
Remember when I said we were targeting SDK version 19? Yeah, well, on newer versions of Android you usually get a more detailed message of what's wrong when launching an application fails, but that's not the case here.
I guess we'll have to load it manually and see if we can get more info.
The idea is to write a new entrypoint library that will just dlopen the original library and then invoke dlerror and print that out.
The library is located in the application's internal storage:
$> adb shell run-as com.cranny.zig ls -l
drwxrwx--x u0_a29 u0_a29 2026-04-12 20:04 cache
drwxrwx--x u0_a29 u0_a29 2026-04-12 20:04 files
lrwxrwxrwx install install 2026-04-12 21:31 lib -> /data/app-lib/com.cranny.zig-1
Let's add a libentrypoint.so into the mix:
const android = @cImport({
@cInclude("dlfcn.h");
@cInclude("android/log.h");
});
export fn ANativeActivity_onCreate(_: *anyopaque, _: *anyopaque, _: usize) void {
_ = android.dlopen("/data/app-lib/com.cranny.zig-1/libcranny.so", android.RTLD_NOW);
// this is `null` if there is no error so you get a segfault if you try printing it
if (android.dlerror()) |msg| {
_ = android.__android_log_write(android.ANDROID_LOG_INFO, "COM_CRANNY_ZIG", msg);
}
}
We also have to remember to update the AndroidManifest.xml and set the android.app.lib_name to entrypoint.
And this gets us...
I/COM_CRANNY_ZIG( 2987): dlopen failed: cannot locate symbol "accept4" referenced by "libcranny.so"...
SDK version 19 has bionic libc from the year 2013 which doesn't have accept4, apparently, but that never stopped us before8, let's roll our own.
Checking the sys/socket.h confirms it, not available:
__socketcall int accept(int __fd, struct sockaddr* __addr, socklen_t* __addr_length);
#if __ANDROID_API__ >= 21
__socketcall int accept4(int __fd, struct sockaddr* __addr, socklen_t* __addr_length, int __flags) __INTRODUCED_IN(21);
#endif /* __ANDROID_API__ >= 21 */
The zig standard library has a stub for external accept4 in std.c so I guess if we implement it as a wrapper for accept, it should work:
export fn accept4(
sockfd: std.os.linux.fd_t,
noalias addr: ?*std.os.linux.sockaddr,
noalias addrlen: ?*std.os.linux.socklen_t,
_: c_uint,
) c_int {
LOGI("+accept4()");
defer LOGI("-accept4()");
return std.c.accept(sockfd, addr, addrlen);
}
Guess again:
I/COM_CRANNY_ZIG( 6661): dlopen failed: cannot locate symbol "pwritev64" referenced by "libcranny.so"...
And again and again, for openat64, preadv64, sendfile64, and even after just stubbing all of those we get an InvalidProtocolOption error because apparently SO_REUSEADDR isn't a thing either.
I didn't feel like dealing with it at that point because maybe even after implementing all of those the transfer will get stuck on the reader/writer part.
I did test the implementation on a RaspberryPi to see if the hang is ARM related, but it works fine there so our next stop is zig-trunk.
In 0.15.1 zig provided the new Io.Reader and Io.Writer interfaces, and the current trunk keeps adding more Io stuff.
Let's try to make a desktop version first this time.
After watching several videos, reading through PRs, and trying to build zig several times, this is what I ended up with:
const std = @import("std");
pub fn main() !void {
const address = try std.Io.net.IpAddress.parseIp4("0.0.0.0", 7979);
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
var server = try address.listen(threaded.io(), .{ .reuse_address = true });
defer server.deinit(threaded.io());
std.debug.print("Listening for a connection...\n", .{});
try handleConnection(threaded.io(), try server.accept(threaded.io()));
}
fn handleConnection(io: std.Io, stream: std.Io.net.Stream) !void {
defer stream.close(io);
var recv_buffer: [512]u8 = undefined;
var send_buffer: [512]u8 = undefined;
var connection_br = stream.reader(io, &recv_buffer);
var connection_bw = stream.writer(io, &send_buffer);
var server = std.http.Server.init(&connection_br.interface, &connection_bw.interface);
var request = try server.receiveHead();
try handleRequest(io, &request);
}
fn handleRequest(io: std.Io, request: *std.http.Server.Request) !void {
std.debug.print("Handling a request!\n", .{});
var file = try std.Io.Dir.cwd().createFile(io, "output.bin", .{});
defer file.close(io);
var fbuf: [512]u8 = undefined;
var file_writer = file.writer(io, &fbuf);
var rbuf: [512]u8 = undefined;
var reader = try request.readerExpectContinue(&rbuf);
std.debug.print("Streaming remaining data!\n", .{});
_ = try reader.streamRemaining(&file_writer.interface);
std.debug.print("Streaming completed!\n", .{});
try file_writer.interface.flush();
try request.respond("Thanks for all the fish!\n", .{});
}
Some APIs changed, but it hasn't moved around a lot, and it works as expected, we can transfer files greater than the size of the buffers!
Just to be doubly sure it works, I tested it on a newer Android device which has all the bells and whistles (like libc from this decade) and managed to hit a compiler bug.
Fixing that locally is good enough for now, testing on a newer Android device proved to be successful, so back to our Nook device - we have a good news/bad news situation.
The good news is that all the previous error are still absent!
The bad news is that now we got a new one that I have no idea what it's supposed to mean:
I/COM_CRANNY_ZIG( 9526): dlopen failed: cannot locate symbol "__tls_get_addr" referenced by "libcranny.so"...
As far as I could figure out, bionic libc doesn't have the thread-local storage support zig requires, even after just stubbing that symbol, a few more missing symbols crop up: __tls_get_storage, dl_iterate_phdr, mmap64...
At this point, it's been 3 weeks since I've read a book and continuing this started to seem an exercise in futility so I resorted back to doing it all in C (but still in zig).9
The problems there pale in comparison with the current approach.
The biggest ones were:
- missing
htons, but since I only need it once I can just use port11039instead of7979 the inability to make thesolved withaccept()non-blockingpollin the meantimeactivity.externalDataPathapparently points to a non-existing folder?- having to write a
clientcounterpart for it
But in the end I did what I set out to do, a way to transfer books to my device wirelessly.
I also came out with a better understanding of both Android and zig ecosystems, both of which is probably completely useless in the current job market.
Anyways, I'm publishing it all for the world to see that it wasn't nearly as straightforward as I make it seem out to be, so if someone thinks to themselves "I have no idea how to do something like this" - neither did I.
Conclusion
In short, zig might be suitable for greenfield projects, but when you need to deal with legacy things, whichever way you slice it, you just can't beat the venerable king everyone's actively trying to replace - C.
Huge shout-out to LaurieWired, Rafa Moreno, silbinarywolf, cnlohr, Andrew Kelley and many other giants whose shoulders I stood on.
Using a USB cable is obviously not an option.↩
Back then they had tasty codenames like
Kitkat, in the meantime they dropped the usage of sweet-tooth codenames publicly.↩In hindsight, it wasn't simple.↩
Pardon my Fr*nch.↩
It stands for
Android Asset Packaging Tool. There's also a neweraapt2which has different looking knobs and buttons.↩That will be removed after
0.16.0is tagged. (issue on github).↩The
catch |err| return LOGI(@errorName(err))is pretty nifty, even if returning thevoidfrom a function call seems icky for some reason.↩Because it has never happened before.↩
Yes, it's redundant, I know.↩