Software
August 23, 2026
#Luma#mobile#Android#React Native#Expo

The build was green. The app was lying.

The build was green. The app was lying.

Luma has been a web app for a while. Film recipes, a photo vault that reads your EXIF, a light meter, gear tracking, photo spots — a companion for people who care about how a frame is made. This month it became a native app too.

The plan was deliberately unglamorous: don't fork it. One portable core of TypeScript, a Vite build serving the web app on one side, an Expo build serving iOS and Android on the other, and a thin layer of platform adapters in between — `storage.web.ts` and `storage.native.ts`, same import in shared code, correct variant per platform. Expo SDK 57, React Native 0.86, expo-router, five tabs.

I expected the hard part to be the interface. It wasn't. The hard part was a category of bug I hadn't had to deal with much on the web, and it took most of the week: failures that produce no error at all. The bundle builds. The app opens. Every screen renders. Nothing in the console. And the thing is broken anyway.

Here are the ones worth writing down.

Every install had the same encryption key

Luma obfuscates the stored session with a per-device key, generated once with `crypto.getRandomValues` and cached. Standard, boring, fine on the web.

Hermes ships no `crypto` global. Neither React Native 0.86 nor Expo SDK 57 adds one — Expo's WinterCG shim installs `TextDecoder`, `URL`, `DOMException` and `structuredClone`, then stops. Verified on device rather than assumed: `typeof globalThis.crypto` is `undefined`, and the call throws `Cannot read property 'getRandomValues' of undefined`.

That throw landed inside the function's own `try`, and the `catch` did what a defensive `catch` does — returned the hardcoded fallback constant. Which is how every single install ended up XOR-ing its stored session against the same key. Nothing was ever written to the device-key slot. No error surfaced, because the code round-trips perfectly well against a weak key: you save a session, you read it back, it works. The security property is the only thing that quietly disappeared.

The fix is a few lines of feature-detected polyfill over `expo-crypto`. The interesting part is where it goes. The device key is generated on the first storage read, which the auth manager issues during boot — so the polyfill has to be the first thing in the bundle, before any route module. `package.json`'s `main` now points at the project's own `index.ts`, which imports the polyfill and then hands off to `expo-router/entry`. Putting it in the root layout would look right and be too late.

A `catch` that returns a plausible default is a decision to fail silently. It's usually the right call for a UI label. It is never the right call for a key.

A fresh install is not a browser you've used for a year

Sign in on a new phone, and Profile is empty. Gear is empty. Achievements is empty. Settings is worse than empty — it shows you a full set of preferences that are actually just the compiled-in defaults, presented as if they were yours.

The core's storage layer is deliberately server-authoritative: it seeds nothing locally and expects data to arrive from a canonical pull. On mobile, nothing ever performed that pull. The function existed; its only caller was a manual sync no screen invoked. Checked on device on August 21, the whole store held six keys — auth, a subscription cache, the daily recipe.

Profile on a fresh install: the shared core seeds nothing locally, so every screen backed by stored data came up empty until boot performed a real server pull.

The web app hides this completely, because a browser accumulates those keys over months of ordinary use. There is no first run on the web, not really. A fresh install is the only place the gap is visible, and a fresh install is what every new user gets.

So boot now blocks on a pull, with two constraints I'd defend in any review. It's a pull, not a push — a "sync" that wrote back would PUT a from-scratch object over real server data, which is the documented way this codebase destroys user history. And it's bounded at eight seconds: the pull retries roughly seventeen keys up to three times each, so an unbounded await can hold the splash screen hostage on a bad connection. On timeout the app renders exactly as it did before the pull existed. The slow path is yesterday's behaviour; the fast path is correct data.

The alias that only broke at runtime

Luma supports several camera brands, and each one's configuration is loaded lazily — `import('./fujifilm/config')`, resolved at runtime from a registry.

Metro needed to resolve the core's `@/` path alias, and the obvious way to do that is `extraNodeModules: { '@': coreRoot }`. It works. Every static import resolves. The bundle builds clean.

It also maps `@` as though it were a package, so core files enter the module graph as package members rather than as files on disk — and Metro then resolves their dynamic imports relative to the project root instead of the importing file. `./fujifilm/config` became a path that doesn't exist, and threw at runtime, inside a `try`, in a loader whose fallback is a bootstrap stub.

Home: the masthead byline is drawn from the venue config. When the dynamic import failed, the whole multi-brand system fell back to bootstrap stubs while the app looked entirely normal.

The visible symptom was one console warning about a config that failed to load. The actual symptom was the entire multi-brand system running on placeholder data while the app looked completely normal. Static imports kept working the whole time, which is exactly why it took so long to see.

The alias is now a plain path rewrite inside `resolveRequest`, so core files are files. Two other rules live in that same resolver, both learned the hard way: mobile's `node_modules` is searched before the repo root's so an Expo-pinned version always wins, and `react` is pinned to exactly one copy — two React instances produce "Invalid hook call", which reads like a bug in your components and isn't.

The error that named the wrong thing

Not every failure here was silent. One was loud and pointed somewhere else entirely.

The Android native build died in `configureCMakeDebug` with a single line about a restricted method in `java.lang.System` — no mention of a JDK, no mention of a version. I spent a full cycle blaming the 32-bit ABI. That was a red herring; arm64 failed identically.

The cause was `JAVA_HOME` pointing at Android Studio's bundled runtime, which is JDK 25. Gradle inherits it, and this project doesn't compile on it.

Two decisions came out of the fix, and they matter more than the fix itself. It is not an edit to `android/gradle.properties`, because that file is generated and `expo prebuild --clean` rewrites it — a direct edit disappears on the next prebuild, which is precisely the moment nobody expects a build to break. And it does not set `JAVA_HOME` machine-wide, which would change the JDK for every other tool on the box including Android Studio itself. It's a config plugin that finds a JDK 17, pins `org.gradle.java.home` at every prebuild, respects an explicit environment override, and — if no JDK 17 exists — writes nothing and leaves the machine exactly as it was.

That last clause is the part I'd argue for. A fix that can't find what it needs should degrade to today's behaviour, not to a new failure.

A dependency nobody asked for

`npx expo install expo-crypto` pulled in four packages this project doesn't use: worklets, reanimated, gesture-handler, and react-dom.

npm 7+ auto-installs optional peer dependencies. `expo-modules-core` declares one of them as a peerOptional, and npm resolved it to a version outside the declared range. Autolinking then compiled the C++ against mismatched headers, and the ninja build failed — a native compile error, several layers below the install command that caused it.

One line in `.npmrc` closes it, and it's mostly there as a note to my future self about what a plain `npm install` is allowed to do to a native build.

What the week actually changed

Two of these were found by reading source instead of trusting a mental model, and two were found by logging the real value on a real device instead of reasoning about what it must be. The cold-start deep link bug — `luma://subscription` parses `subscription` as the URL's host, not its path, so reading `path` alone silently dropped every single-segment link — was found only after a link that "worked" turned out to have been handled by something else entirely.

That's the whole pattern. This stack fails quietly by design. React Native's `fontFamily` is another one: it's just a string, nothing checks it against what you actually registered, and a typo falls back to the system font with no error and no warning. Luma's mobile theme file now exports the font names, and the loader registers each face under those same values — so the registered name and the name every screen uses are physically the same object and cannot drift.

You can't test for a bug you don't know exists. You can make the class of bug structurally impossible, one narrow case at a time, and write down why in the file where someone will next be tempted to undo it. Most of the comments in this codebase are longer than the code they sit above, and every one of them exists because something looked fine and wasn't.

Where it is

The native app is in active development: Expo SDK 57, React Native 0.86, expo-router, sharing its core with the web build. Five tabs, an AI Hub with image analysis, recipe generation, composition and gear tools, and a Lab that starts with a light meter — pure math, the same table as the desktop screen, because two copies of an exposure chart is a mistake this repo has already paid for once.

The Lab's light meter: aperture, shutter and ISO tables with a scene preset, computed from the same shared module the desktop screen uses.

You can see the full feature set on the [Luma project page](/lab/luma).

---

Luma is a CraftedPxl project. Building something similar, or want to tell me one of these fixes is wrong? [Get in touch](mailto:info@craftedpxl.com).


About the Author

CraftedPxl is a digital playground exploring the intersection of design, code, and photography.