Vantage Point

Bury a memory in a real place. Come back and dig it up.

Inspiration

When I was a kid, my sister and I buried a biscuit tin in the garden.

Drawings. A cricket card. A note to whoever dug it up. We drew a map on notebook paper, and then we lost the map. We dug up half the garden anyway and never found it. That tin is still down there somewhere with a ten-year-old's handwriting in it.

I've thought about that tin more than I've thought about any photo in my camera roll. Every one of those lives in a scrolling grid, sorted by a timestamp I don't remember, viewable from anywhere, worth almost nothing. A memory that costs nothing to reach costs nothing to ignore.

The tin was different because it was somewhere. You couldn't open it from the sofa.

So I went looking for the app version of the tin, and it doesn't exist. There are email-to-the-future services, which are just a delayed inbox. There are photo vaults, which are folders with a password. There are AR toys that let you leave a virtual object at a GPS pin — fun for about four minutes, and built for brand campaigns rather than for people. Every one of them is trying to remove exactly the thing that made the tin matter: that finding it should cost you something. A walk. A wait. A return trip.

That's also where the name came from. A vantage point is a specific place you have to physically get to, and getting there changes what you can see. That's the whole product in two words.

The line I've held every feature to: bury a memory at a place; it stays sealed until someone stands there. If a feature doesn't serve that sentence, it isn't in v1. I've cut several things with it.

What it does

  • Bury a capsule — text, photos, audio, video — anchored to a GPS point and to an object you train the app on from three angles.
  • Scan to open. The app scores what the camera sees against what was buried, in real time, and opens the capsule only when it's genuinely convinced.
  • Time-lock it. Seal a capsule for years. The unlock is enforced on the server, not by a countdown on the phone that a reinstall would erase.
  • Share it. Give someone else the right to find it — at exact coordinates, an approximate area, or scan-only, where they get no coordinates at all and have to actually find the place. A note to whoever digs it up, basically.
  • Recover it. A recovery phrase restores a vault onto a new phone. Not your password — the two are separate on purpose. This one is personal: we lost the map.

How I built it

Android, Kotlin, Jetpack Compose, single fixed dark theme. Roughly thirty Gradle modules: core/* for domain, data, sensors, security, billing; feature/* for each journey, none of which depend on each other. Koin for DI. Room as the source of truth, Firestore as a sync layer, offline-first in that order — the app is fully usable with no network, because the moment you're standing in front of a buried object is exactly the moment you have one bar.

The server half is Firebase Cloud Functions in TypeScript, and it owns everything a client must not be trusted with: sealing, unlock sweeps, wallet balances, sharing membership, account deletion, data export.

The recogniser

This is the part the whole product rests on, and it's the part I rewrote most.

The naive version — ask ML Kit what's in frame, compare labels — fails immediately. ML Kit's default labeler returns Tree. That is true of every tree on Earth. A label match is a coarse gate, nothing more.

So recognition is a composite of three legs: label agreement, visual similarity against the trained reference images, and proximity to the buried coordinate.

The first version combined them with a weighted sum. That was the single most instructive bug in the project, because a weighted sum is not an AND. With my weights, a generic label match at the right GPS coordinate scored $$0.32 + 0.22 = 0.54$$ before the camera contributed anything at all — meaning any tree standing where the oak stood would open the capsule.

The fix was a weighted geometric mean:

$$ C \;=\; \exp!\left(\frac{\sum_{i} w_i \ln s_i}{\sum_{i} w_i}\right), \qquad w_{\text{label}} = 0.32,\; w_{\text{visual}} = 0.46,\; w_{\text{prox}} = 0.22 $$

A geometric mean can't be rescued by strong legs: as any $$s_i \to 0$$, so does C. That is the AND the design promises, expressed as arithmetic rather than as a chain of guards some future caller forgets to write.

It also gives radius containment for free. Proximity is zero outside the trigger radius by construction:

$$ P(d, R) = \begin{cases} 1 & d \le \tfrac{R}{2} \[4pt] 1 - \dfrac{d - R/2}{R/2}\,(1 - 0.35) & \tfrac{R}{2} < d \le R \[6pt] 0 & d > R \end{cases} $$

With $$P = 0$$ the whole product is 0, so no combination of the other two legs can open a capsule from the wrong place. Not "shouldn't" — can't.

Visual similarity is a 64-bit dHash for structure plus a 32-bin colour histogram, weighted $$0.75/0.25$$ toward structure, because colour is corroboration and never identity — two different trees in the same garden have nearly identical histograms. Both signals are rescaled above their chance level (0.5 each): two unrelated 64-bit hashes agree on half their bits, and treating that as partial evidence is how a recogniser opens on the wrong object while reporting high confidence.

The pass bar is 0.65, set well above where a same-class impostor at the right coordinate lands (~0.38 — there's a unit test that asserts exactly this), because the two errors aren't symmetric. Refusing a real match asks the user to step closer. Accepting an impostor opens a sealed memory at the wrong object, and cannot be taken back.

Every number above lives in one dependency-free file, RecognitionThresholds, unit-tested on the JVM. :core:sensors only supplies pixels.

Challenges I ran into

GPS noise nearly shipped as a bug. Proximity started as a linear ramp to zero at the boundary. Under tree cover a 10–15m fix put the reported distance anywhere from 4m to 22m while the user stood perfectly still — the leg swung between 0.84 and 0.12, and through a geometric mean that's the difference between an open capsule and "not quite, hold steadier", flickering several times a second. Hence the plateau: inside half the radius, standing at the place scores like standing at the place.

An anchor that could never be opened. Training took three deliberately different angles and kept the intersection of their labels. In practice the overhead shot shares nothing with the front shot, the intersection came back empty, a zero leg went into the geometric mean, and the score was exactly 0 forever — for a capsule you were standing in front of with a perfect image match. The lesson: a missing leg is absent, not zero. "The labeler disagrees" and "this anchor has no labels" are different facts, and conflating them is fatal. Now the leg is dropped and its weight redistributed.

The failure ladder ran faster than a human. The design said three consecutive failures before offering an alternative way in. Recognition completes in tens of milliseconds, so "this place may have changed" arrived before the user had finished raising the phone. Scoring is now rate-limited to 4fps and the ladder advances at most once per 1.8s.

Countdowns don't belong on phones. A capsule sealed for five years cannot rely on a local alarm — a reinstall, a new handset, or battery optimisation erases it, and five years is a long time to bet on one device. Unlock reminders became an hourly server-side sweep keyed by time remaining, so a capsule buried three days before its date still gets one honest reminder.

Sharing meant real cryptography. A capsule is encrypted under the owner's device key, which a recipient can't touch. So sharing seals a per-recipient envelope to their public key, and fidelity is enforced there: approximate and scan-only omit the coordinates rather than hiding them client-side. The share keypair belongs to the account, not the phone — which took an escrow model to make several devices agree on one identity.

Decisions I reversed in public. Dropped Hilt for Koin. Killed background geofencing entirely — while-in-use location only, proximity computed in-process — and deleted the Play Services dependency with it. Removed a password gate from account deletion after discovering every failure in the chain surfaced as "check your password", so correct passwords read as wrong and the account couldn't be deleted at all.

What I learned

Formulas are policy. The most important safety property in this app isn't a check — it's the shape of a mean. Writing containment as arithmetic instead of a guard made it impossible to forget.

Absence is not zero, and I will pay for confusing them again somewhere else eventually.

And pick which wrong answer is cheaper, then say so in a comment. A store or server answer that hasn't arrived is null, never false. Nearly every hard call in this codebase came down to that one question.

What's next for Vantage Point

Launch. Play Console gates, a real signing pipeline, and the first capsules buried by people who aren't me.

Somewhere in a garden there's still a biscuit tin nobody can find. This is the version where the map doesn't get lost.

Built With

Share this project:

Updates

Submission history