After anki trashed my history, uploaded the trashed history to the sync server, and then repeatedly re-synced the trashed history every time I tried to restore from backup, I wrote my own spaced repetition app. It reads input from a markdown file, asks questions in the terminal and stores state in a json file. I wrote those few hundred lines of code in an afternoon and I've been using it ever since.

I wanted to run it on my android phone too, but after several days of struggling and several gigabytes of IDE downloads I lost interest.

This is my typical experience. Extending and customizing my laptop is a routine affair. Extending and customizing my phone is so painful that I give up every time. The software on my laptop feels like a comfortable old boot, worn in over years of tweaking and scripting. The software on my phone feels like a slot machine that moves all the buttons around once a month and holds my eyes open while the ads play. It's shiny and polished, but it isn't on my side.

The pinephone is a $150 mobile phone that aims to be able to run mainline linux. I bought one hoping to create an experience more like the experience of using my laptop.

Obviously writing an entire mobile suite is a big project, so I'm relentlessly cutting corners wherever possible and adopting an aesthetic of simplicity > capability.

I just finished the first milestone - porting my little spaced repetition app.

Installing an operating system

I installed mobile-nixos. The docs at the time were very barebones but samueldr was kind enough to walk me through it over irc and I wrote down the process here.

I haven't gotten around to writing a system configuration yet, but once I do it will be a single-click deploy with nixops deploy -d focus - a big improvement over the LineageOS update process. The default configuration looks like this.

Cross-compiling the dependencies

Since the phone is running a normal linux userland it is possible to compile directly on the phone. But my laptop compiles things ~10x faster so it seemed worth investing in cross-compilation.

The first step is getting hold of arm64 versions of all my dependencies. This is done in shell.nix.

I pin the version of nixpkgs to make sure that both local and remote development use the exact same version of each dependency.

nixpkgs = builtins . fetchTarball { name = "nixos-20.03" ; url = "https://github.com/NixOS/nixpkgs/archive/20.03.tar.gz" ; sha256 = "0182ys095dfx02vl2a20j1hz92dx3mfgz2a6fhn31bqlp1wa8hlq" ; } ;

Nix recently gained support for cross-compilation but many packages don't cross-compile successfully yet. So the trick is to setup for cross-compilation, but grab some packages from the native arm64 repo. These won't build from source on a non-arm64 machine but there are pre-built version available in the nixpkgs binary cache.

armPkgs = import nixpkgs { system = "aarch64-linux" ; } ; crossPkgs = import nixpkgs { crossSystem = hostPkgs . lib . systems . examples . aarch64-multiplatform ; overlays = [( self : super : { inherit ( armPkgs ) gcc mesa libGL SDL2 ; })] ; } ;

Most config is shared between local and cross builds, so shell.nix takes a boolean argument that tells it which we're attempting.

targetPkgs = if cross then crossPkgs else hostPkgs ;

We need pkgconfig and patchelf runnable on the host machine because they are used during compilation. And we need libGL and SDL2 runnable on the target machine to link against.

buildInputs = [ hostPkgs . pkg-config hostPkgs . patchelf targetPkgs . libGL . all targetPkgs . SDL2 . all ] ;

Now we can do nix-shell to drop into a shell setup for local compilation, or nix-shell --arg cross true to drop into a shell setup for cross compilation.

Cross-compiling my code

I wrote everything in zig because I'm trying to keep the whole system small, simple and fast. Zig is a small, simple, fast language that also cares a lot about cross-compilation.

The build.zig is mostly self-explanatory. Cross-compiling with zig takes almost no effort.

The one hitch is that it struggled to find headers using pkgconfig when cross-compiling. I haven't tried to debug this - just passed them directly instead.

In shell.nix :

NIX_LIBGL_DEV = targetPkgs . libGL . dev ; NIX_SDL2_DEV = targetPkgs . SDL2 . dev ;

In build.zig :

... try includeNix(exe, "NIX_LIBGL_DEV"); try includeNix(exe, "NIX_SDL2_DEV"); ... fn includeNix(exe: *std.build.LibExeObjStep, env_var: []const u8) !void { var buf = std.ArrayList(u8).init(allocator); defer buf.deinit(); try buf.appendSlice(std.os.getenv(env_var).?); try buf.appendSlice("/include"); exe.addIncludeDir(buf.items); }

Zig also didn't set paths correctly in the resulting binary but this is reasonable - there is no way for it to know that I'm cross-compiling to an identical nix system so it defaults to some reasonable heuristics. I just patch the binary after compilation.

In shell.nix :

NIX_GCC = targetPkgs . gcc ; NIX_LIBGL_LIB = targetPkgs . libGL ; NIX_SDL2_LIB = targetPkgs . SDL2 ;

In sync :

patchelf --set-interpreter $( cat $NIX_GCC/nix-support/dynamic-linker) zig-cache/focus-cross patchelf --set-rpath $NIX_LIBGL_LIB/lib:$NIX_SDL2_LIB/lib zig-cache/focus-cross scp ./zig-cache/focus-cross $FOCUS:/home/jamie/focus

The build process is then either zig build local or zig build cross && ./sync .

Since both laptop and phone are running the same operating system with the same pinned dependencies I don't bother to use a virtual machine for local development. I can just mock out the device sensors in code and everything else will run the same.

Writing a simple GUI library

I'm writing an immediate-mode GUI library because I find them much simpler than most retained systems, in terms of both lines of code and mental overhead. Our Machinery recently gave a GDC talk that lays out the rationale.

Battery usage is often given as a concern, but on my laptop this app hovers around 1% cpu at all times whereas gnome-calculator reaches 20-30% whenever I press buttons. If this becomes a problem with more complex UIs I can add some caching at the draw command layer.

There are definitely some things that may be difficult to do well in immediate-mode, like complex reactive layouts, but luckily they aren't things that I care about doing on my phone.

I copied my renderer and font atlas from microui and I've built just enough UI library to get things working. Here is the entire UI for the spaced repetition app:

if (ui.key orelse 0 == 'q') { try saveLogs(self.logs.items); std.os.exit(0); } const white = UI.Color{ .r = 255, .g = 255, .b = 255, .a = 255 }; var text_rect = rect; var button_rect = text_rect.splitBottom(atlas.text_height); switch (self.state) { .Prepare => { try ui.text(text_rect, white, try format(allocator, "{} pending", .{self.queue.len})); if (try ui.button(button_rect, white, "go")) { self.state = .Prompt; } }, .Prompt => { const next = self.queue[0]; const text = try format( allocator, "{}



(urgency={}, interval={})", .{ next.cloze.renders[next.state.render_ix], next.state.urgency, next.state.interval_ns } ); try ui.text(text_rect, white, text); if (try ui.button(button_rect, white, "show")) { self.state = .Reveal; } }, .Reveal => { const next = self.queue[0]; try ui.text(rect, white, try format(allocator, "{}", .{next.cloze.text})); var event_o: ?Log.Event = null; var miss_rect = button_rect; var hit_rect = miss_rect.splitRight(@divTrunc(miss_rect.w, 2)); if (try ui.button(miss_rect, white, "miss")) { event_o = .Miss; } if (try ui.button(hit_rect, white, "hit")) { event_o = .Hit; } if (event_o) |event| { try self.logs.append(.{ .at_ns = std.time.milliTimestamp() * 1_000_000, .cloze_text = next.cloze.text, .render_ix = next.state.render_ix, .event = event, }); self.queue = self.queue[1..]; if (self.queue.len == 0) { self.queue = try sortByUrgency(&self.frame_arena, self.clozes, self.logs.items); self.state = .Prepare; } else { self.state = .Prompt; } } }, }

I really like that it's just straight code - not split across multiple files or classes or callbacks. You can just read it from top to bottom. If I want to abstract over some component or layout I can just put it in a function.

Obviously it needs a lot of work to improve anti-aliasing, fonts, layout etc, but I think those can be done without complicating the interface above.