Building Labi

The day everything half-finished got finished

2026-07-10

This day didn't start out looking like the biggest one in the project. It started as a cosmetic pass — wire in the new Tally Cluster icon across Android, iOS, and web, add a settings screen for user-supplied AI provider keys, fix a batch of bottom sheets that overflowed on smaller screens. Routine work. It didn't stay routine for long.

The password that shouldn't have been readable

Touching android/app/build.gradle.kts to wire in the new icon meant looking at the release signing block, and the release signing block had a problem: storePassword, keyPassword, keyAlias weren't references to anything. They were the actual values, sitting in plaintext, in a file that was already committed to git. The keystore hadn't signed a published release yet, and the repo is private, so the immediate risk was moderate rather than urgent — it still shouldn't have been there. Fixed the same hour: credentials moved into android/key.properties, gitignored, with the Gradle config reading from that file instead of containing the values directly. The exposure doesn't get erased from git history just because the current file is clean — that's a separate decision (rotate the password, or accept the risk since access is controlled) — but going forward, key.properties is the pattern, full stop.

Finding a real, live credential leak in the middle of what was supposed to be an icon swap changes the tone of a day. It stops feeling like polish and starts feeling like an audit.

The policy that stopped matching the app

So: audit. The privacy policy had drifted quietly out of sync with the app it was supposed to describe — it still said "Labmate" in places, and it said nothing about Firebase Analytics or Crashlytics, nothing about the optional AI provider keys just added that same day, nothing about the calendar permission the app actually requests. None of that was deliberate. It was just what happens to a policy written once, early, while the app underneath it kept changing. Rewrote it to match current behavior, then wrote the real Play Store submission checklist — marking as done only what had actually been verified that night, and flagging everything that genuinely needed a human decision instead of pretending it was handled.

A class that was never real

Weeks of real, good work had been sitting on an old, unmerged branch — the actual ProtocolRepository, the CDN lazy-loading, the attribution UI, all of it built back on June 16th and never merged. Pulling the useful commits back onto the main branch meant going through them one at a time, and one of them called a method on ProtocolCdnService.fetchSample().

ProtocolCdnService didn't exist. Not currently, not in an earlier version, not on any branch, at any point in the project's history — checked exhaustively, across every commit ever made, and it simply was never written. Something got cherry-picked that referenced a service someone had planned to build and then never did, and the reference survived the cut. The honest fix wasn't to invent an implementation and guess at what it was supposed to do. It was to delete the dead method and let the real ProtocolRepository — the one that actually existed — be the only path to CDN protocols. Sometimes the right fix is subtraction.

What if the database just breaks

By this point in the day, the question in the air was something like: what else is quietly wrong that nobody's checked? That question turned inward, toward the database itself. Isar has no built-in migration system — a renamed field or a retyped one isn't something it recovers from gracefully, and there had been no real answer to "what happens to a real user's runs and notes if a future update breaks the schema" beyond a vague sense that it probably wouldn't.

A vague sense isn't a plan. IsarService.init() already caught the error if Isar failed to open, but it only logged it with debugPrint — silent in release builds by design, meaning if this ever happened on a real phone, nobody building the app would ever find out. Wired it to Firebase Crashlytics instead, with a reason string specific enough to actually be useful later.

And underneath that, something with real teeth: LocalBackupService, a rolling JSON snapshot of runs, notes, and scheduled experiments, written to the app's own document storage on every launch, throttled to once a day, keeping the last five. It doesn't touch Isar. It doesn't need Isar to be working correctly in order to succeed — that's the entire point. A safety net that depends on the thing it's supposed to protect against isn't a safety net.

iOS, opened for the first time in a while

The audit kept going, and it went to iOS next — and iOS had clearly not been opened in a while, because it showed. The bundle identifier in Runner.xcodeproj still read com.labmate.labmate, left over from before the rename, mismatched against Android's com.labi.labi and Firebase's registered package name. ios/Podfile wasn't in the repository at all, which meant CocoaPods would have had nothing to work from on the very first install attempt.

The one that actually mattered most: main.dart called Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) on every platform except web and Linux desktop — which includes iOS, and there is no iOS configuration in firebase_options.dart at all. That getter throws UnsupportedError for iOS, uncaught, before runApp() ever runs. On a real iPhone, that's not a bug that shows up later. That's the app crashing the instant someone opens it, every single time, until someone happened to check. Fixed the bundle ID, rebuilt the missing Podfile, scoped the Firebase call to Android only until real iOS configuration exists, and added the camera and photo permission strings iOS requires — image_picker is used in three screens, and without those strings iOS doesn't fail gracefully, it crashes the moment it's asked.

The library, finally connected to a screen

And then, at the end of an already very long day, the last piece: the protocol library that had been sitting fully built since June 16th, wired into the app's startup, and never once connected to a screen a person could actually tap through. Building that screen — search, browse, a read-only detail view — meant testing the whole path end to end for the first time, and that's when the placeholder search index from June finally got caught: six protocols in it, and five of those six no longer existed at the CDN source at all.

The CDN itself had a real index sitting at its root the entire time — 11,656 protocols, every one checked against the source and confirmed to actually resolve. Swapped the placeholder for the real thing, and the protocol library went from a beautifully built system connected to almost nothing, to an actual feature.

Six separate things, one day, each one uncovered by looking closely at the thing right next to whatever was actually being worked on. That's not a coincidence and it's not a coordinated plan either — it's what happens when you finally stop assuming something works because it hasn't complained yet, and start checking.


← Back to Blog