Inspiration

I already had a cellar app people loved — on the wrong half of the planet.

Nobli started on iOS: point your camera at a bottle, and the label turns into a real record — producer, vintage, region, ABV, drinking window, food pairings. Collectors adopted it because it removed the one thing that kills every cellar spreadsheet: typing. But every few weeks the same email arrived — "is there an Android version?" — and the answer was no.

Shipaton was the reason to finally say yes. Not a port in the "wrap it in a WebView" sense: a native Android app that earns its place next to the iOS one, with a monetization model that fits how people actually buy.

What it does

Every bottle has a story. Nobli reads the label and tells it.

  • AI label scan — photograph a bottle; the AI fills in the whole record. No typing.
  • A cellar that is actually a cellar — wine, beer and spirits on their own shelves, with tags, search, 10 sorts, and a bottle rack you can tap to fill.
  • Drinking windows — when each bottle is at its peak, filled in automatically.
  • AI sommelier — ask what to serve, how to pair it, when to open it.
  • Private by design — no account, no ads, analytics off until you turn it on, cellar stored on the device.

How I built it

Kotlin + Jetpack Compose, single-activity, Material 3, minSdk 33 / targetSdk 36. Room for the cellar, DataStore for preferences, Hilt for DI, Retrofit + kotlinx.serialization against the same PHP AI proxy the iOS app uses, CameraX for capture, Coil for images, Firebase Analytics behind an opt-in switch. Around 67 Kotlin source files and 10 unit test files, shipped as a 14 MB R8-minified release.

The port was done screen by screen against the SwiftUI original — not pixel-copying it, but keeping the feel (Playfair headlines, burgundy and gold, the bottle-rack metaphor) while letting Android be Android: Material 3 components, adaptive icon with a monochrome layer for themed icons, per-app language via localeConfig across 14 languages.

Store assets are code. Screenshots and feature graphics are HTML documents rendered headlessly by Chrome and downscaled — one master per slide, a table of translations, and python3 render.py de it es fr pt-BR pt-PT produces 42 correctly-sized PNGs. Changing a headline in seven languages is a diff, not an afternoon in a design tool. The store listings themselves — text, screenshots, feature graphics for seven locales — are pushed through the Play Developer API in a single committed edit.

RevenueCat: the interesting part

iOS runs RevenueCat in observer mode behind StoreKit. On Android I went full mode — RevenueCat owns the purchase — and leaned on the parts of the platform I had never used properly:

  • A hybrid model in one entitlement. Subscriptions (monthly, annual with a 3-day trial), a lifetime unlock, and consumable scan-credit packs. Subscriptions and lifetime grant the pro entitlement; credits deliberately do not — they are a different promise, and keeping that boundary clean in code was the single most important design decision in the billing layer.
  • Two offerings, two moments. The onboarding paywall resolves the onboarding offering; every in-app paywall uses current. Same identifier as the iOS build, so a pricing experiment can be run once and land on both platforms.
  • Offering metadata as remote config. The number of credits each consumable grants is read from metadata["credits"] on the offering, with a hardcoded map as the fallback. I can re-balance credit packs from the RevenueCat dashboard without shipping a release.
  • RevenueCat Paywalls + Customer Center, so the paywall design and the subscription-management flow are not another five screens to maintain.

Challenges

The Play Billing API does not want to be scripted. Creating the products through the Play Developer API turned into an archaeology exercise: one-time products answer to oneTimeProducts on GET but onetimeproducts on PATCH; the documented /monetization/ path segment 404s; regionsVersion is mandatory but undocumented at the call site; a subscription allows exactly one legacyCompatible base plan; an offer must mirror its parent base plan's regions exactly, and the regions the base plan reports are not the ones it will accept back. Worst of all, a one-time product refuses to be created without at least one priced region — and the API seeds only that one region, silently leaving a product that looks fine and sells in one country. I found that the honest way: by shipping it wrong first.

A paywall that cannot dead-end. If RevenueCat is misconfigured or offline, the naive implementation shows a blank sheet and traps the user at the end of onboarding. The paywall now resolves its offering before rendering, and falls through to the app when there is nothing to show. Losing a conversion beats losing a user.

Testing purchases is its own project. On an emulator without a signed-in Play account, RevenueCat returns BILLING_UNAVAILABLE and no offerings at all — which looks exactly like a broken integration until you read Finsky's log line saying No account found. Real product data needs a Play-signed build from a testing track, a license-tester account, and patience.

Telling the truth in the store listing. iOS advertises iCloud sync; the Android v1 does not sync. Every place that claim appears — screenshots, listing copy, onboarding, the profile footer — had to be rewritten to "your cellar is stored on this device". The same audit caught a privacy policy that still described the app as an "iOS application" and, thanks to a long-ago copy-paste, promised a photo-to-anime feature. Google's Data Safety form is compared against that document; shipping the mismatch would have been a rejection, and deserved one.

What I learned

  • Offering metadata is underrated. Treating RevenueCat as a small remote-config service for anything purchase-shaped removed a release cycle from my pricing experiments.
  • Consumables and entitlements are different promises. Every bug I wrote in the billing layer came from briefly letting them blur.
  • Design-as-code pays for itself at N=2 languages, never mind seven. The store assets became the fastest part of shipping instead of the slowest.
  • A port is a re-audit. Half of this project was not writing Kotlin — it was discovering which claims the iOS app makes that Android cannot keep, and fixing them honestly.

What's next

Cloud sync as an opt-in premium feature (the one thing the Android build genuinely misses), Play Store listing experiments on the screenshots that already exist in seven languages, and a paywall A/B test through RevenueCat that runs on both platforms from one dashboard.

Built With

  • claude
  • compose
  • kotlin
  • openai
  • revenuecat
Share this project:

Updates