Boxlet
Get the app

Build log

Boxlet was written between 5 August and 6 September 2026 and shipped to the App Store and Google Play on the last of those days. It was built for RevenueCat Shipaton 2026. This is the engineering side of that month — mostly the parts that went wrong, because those are the parts another developer can use.

5 August — a layout that cannot break

The first real decision was about the board, and it was made before any of it was drawn: layout is derived from order and span, never stored as coordinates. There is no (x, y) anywhere in Boxlet. A card knows how many cells it occupies and where it sits in a list, and the grid is recomputed from that on every composition.

Absolute positions were rejected because they fail in three ways that derived layout cannot. Two devices editing the same day need a merge rule for positions, and every rule you pick produces overlaps in some ordering. Deleting a card leaves a hole and nothing in the data says what should move up. Making one card larger requires a correction pass over every card after it.

With derived layout none of those exist. Every arrangement you can reach is legal by construction, so there is no such thing as a corrupt board and no repair code to write.

The packing pays for this in density. It refuses to pull a later small card forward into an earlier gap, so a board is sometimes looser than it could be. That is deliberate: without it, dragging a card to second place would make it appear somewhere in the middle, and dragging would stop meaning anything.

Mid August — one UI, two platforms

Boxlet is 93% one Kotlin codebase, and 100% of what you can see is Compose Multiplatform:

All 341 composables live in commonMain. There is no SwiftUI screen and no Android-only screen anywhere in the project. iOS renders the same Compose tree through ComposeUIViewController, and ContentView.swift is 24 lines that host it and forward the system colour scheme.

The 2,274 lines outside commonMain are expect/actual bridges and nothing else: camera and photo picker, secure storage, the SQLDelight driver, the Ktor engine, location, notification channels and APNs, and four sign-in providers.

Because an expect and its actual have to live in the same module, every platform seam became its own module — twenty in total, split :core:<noun> and :feature:<noun>. A custom Gradle task, checkModuleGraph, is wired into check and fails the build if a feature module reaches into :core:data or depends on another feature. Screens structurally cannot touch Ktor or SQLDelight.

15 August — two traps in Play Console

Pricing a subscription took an afternoon longer than it should have, for two reasons that have nothing to do with code.

The first: Play Console reads the price field using the console's display language. In Vietnamese a dot is a thousands separator, so typing 2.99 into a USD price field produces $299.00. It saves without complaint.

The second is worse, because it looks like it worked. Editing prices in bulk — select all rows, set the price, save — only applies to the rows the table has actually rendered. The footer said 177 countries selected. Bahrain and Poland got the new price. Thailand and Vietnam kept the old one.

Base plan codes cannot be reused once created, so fixing it meant creating a new one. Boxlet's monthly plan is called monthly-v2 for exactly that reason, and the first monthly sits deactivated beside it. Set prices when you create a base plan, and check a country from the end of the alphabet before you trust the count.

The billing chicken-and-egg

Google Play will not let you create a subscription product until a build containing the Play Billing Library is on a track. That build is the one you are trying to make, and it needs the subscription to exist so the paywall has something to show.

RevenueCat's Test Store breaks the deadlock — the paywall runs, purchases complete, the entitlement flips. But it is hard-blocked in release builds: a signed release carrying a Test Store key throws up a blocking dialog on launch, and the SDK warns it will crash in production. So the Test Store gets you a working paywall and nothing you can ship. You build with it, then swap the key and rebuild.

App Store Connect had a quieter version of the same problem. Both subscriptions sat at MISSING_METADATA with every documented field filled. The cause was the subscription group localization — the group name iOS shows in the system subscription screen — which no submission guide we found mentions. Creating en-US and vi localizations flipped both products to READY_TO_SUBMIT immediately.

A ghost that only lived in the simulator

For two days, sheets and dialogs on iOS rendered completely invisible. The layout was right, the state was right, taps landed where the content should have been — nothing drew. It looked like a Compose Multiplatform layering bug, and it was investigated as one.

On a real iPhone everything drew correctly, and had been the whole time.

The worst bugs are not the hard ones. They are the ones where a tool quietly reports something untrue and you spend your effort in the wrong place. Anything with an overlay is now checked on hardware before it is believed.

22 August — the grid came out as a library

Building the board meant discovering that Compose Multiplatform has no drag-to-reorder grid for items that span multiple cells. LazyVerticalGrid does spans without reordering; the reorderable libraries do lists, not grids. So one was written, and at the end of the month it was pulled out of the app and published as slotgrid-compose — Apache 2.0, on Maven Central, targeting Android, iOS, desktop and Wasm from pure commonMain. There is a live demo you can drag around in a browser.

Generalising it surfaced a bug the app could never have hit. With a fixed four-column grid, no card could be wider than the grid. Once the column count became a parameter, an oversized span sent the placement scan advancing row after row forever — a hang, not a wrong drawing. It is clamped and tested now.

Then a subtler one. A test was written to check the library worked in someone else's project. It passed. It was worthless: it called the component with the minimum arguments, so it never touched half the types the public API exposes. A stricter version failed immediately — the Compose dependencies were declared implementation rather than api, so Modifier, Dp, Rect and ScrollState never reached consumers. Nobody could have used the version that shipped as 0.1.0.

The library's own build cannot catch that class of bug — inside the repository the Compose artifacts are always on the classpath. There is now a script in CI that builds a throwaway project depending on the library the way a stranger would, declaring the bare minimum and using every parameter of the public API. It was verified by watching it fail against 0.1.0 before watching it pass against 0.1.1.

6 September — shipped

Boxlet went live on both stores on the same day, from one repository, through fastlane on both platforms.

The expensive part of shipping cross-platform was never the screens. Compose Multiplatform on iOS is production-ready in a way it was not eighteen months ago. The expensive part is the seams — auth providers, billing, push notifications — and, by a wide margin, store metadata. Roughly as much calendar time went into App Store Connect and Play Console as into the entire social feature set.

The library's own reasoning is recorded in docs/design.md — why layout is derived, why the packing gives up density, why drag decisions follow the finger rather than the dragged card's centre.