Pinephone first steps

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,
            "{}\n\n(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.