Pet Med Reminder on Android
Half the owners a vet says "keep a log" to are on Android, so the referral channel is capped before anyone counts conversion. But the port is not a rewrite of the screens. It is a decision about the sync layer, and the honest answer was to build none.
Big batch. Six weeks.
Problem
A vet says one sentence at discharge: “keep a log and bring it to the recheck.”
When I interviewed vets about recommending an app, the biggest anxiety was lending credibility to software they did not build. Right behind it was platform mismatch. Roughly half the owners they say that sentence to are on Android, and no vet is going to maintain a mental table of which app for which phone. So the discharge card says “iPhone” and the sentence stays generic, which caps the channel at half the room before anyone measures whether the other half would have converted.
This is a bigger version of a ceiling I already hit once. The app shipped with a minimum operating system nobody had chosen deliberately, and lowering it was a one-line change that widened the funnel immediately. Android is the same shape and is not a one-line change.
The product itself is not what is holding this back. Four people who are not me have run between 0.83 and 0.91 dose consistency over multiple weeks, and the last two domain features both landed without a schema change. The app is stable enough to copy. That was not true in June.
Appetite
Six weeks. About two of them are building.
The other four belong to Google. The developer account is a personal one, which means a new app has to run a closed test with twelve testers opted in for fourteen consecutive days before it can even apply for production access, and that clock starts at the twelfth opt-in rather than at the first upload. I learned this on a previous Android release.
So tester recruitment is milestone zero. It starts on day one, in parallel with the engine, because no amount of building shortens it.
If the build side crosses three weeks, cut features. Never cut parity.
The honest cost line: my other Android app has been sitting in closed testing since late August with six of the twelve testers it needs. A second Android client competes for the same hours and the same pool of friends who happen to own Android phones. This pitch does not solve that. It names it, and it sources testers from somewhere other than the same friends.
Solution
Five moves, in order of confidence.
1. A native twin. Same four tabs, same screens, same copy, same rules. Kotlin and Compose, with the recurrence maths, the anchoring rule, the course ceilings and the analytics contracts in a pure engine module that knows nothing about the platform. The iOS domain tests port across as the conformance suite, which is the only way to know the twin is actually a twin.
2. No server.
The iOS app stores data in Apple’s own local store and syncs through Apple’s cloud, and that cloud has no Android story at all. So a port is not a rewrite of the screens, it is a decision about what happens to the data, and there are only two answers: build a backend both clients speak to, or build neither.
I built neither. Android gets local storage plus the platform’s own backup, with no accounts and nothing syncing between devices.
The reason is that a backend would unlock exactly one thing, sharing a pet with a partner, and the iOS app does not have that either. Building server infrastructure for a feature neither client ships, before the interviews that would tell me whether anyone wants it, is how a two-week port becomes a quarter. The local schema mirrors the iOS one field for field, including the row that identifies which caregiver logged a dose, so if sharing ever earns its place it is a sync layer added to both apps rather than a migration forced on either.
3. Reminders that survive the phone. This is where the platforms differ most, and where a naive port breaks. Alarms are scheduled explicitly, the notification actions work with the app killed, a receiver rebuilds everything after a reboot, and a periodic heartbeat repairs anything the system dropped. My other Android app deliberately uses inexact alarms, because a vocabulary reminder can drift by an hour and nobody is harmed. A dose cannot drift, so this one asks for the exact-alarm permission and degrades honestly when it is refused.
4. The first written palette this product has ever had. The iOS app has no palette file. Its accent colour is literally an empty slot, so it renders in the system blue it inherits. Android has nothing to inherit, and the framework’s default seed is purple, so the port forces a decision the iOS app never had to make: the sage from the app icon, dynamic colour off, so the app looks the same on every phone.
5. Ship the store packet inside the batch. Screenshots from the running app rather than a mockup, the listing copy, the privacy policy, the declarations and the reviewer walkthrough, all verified against the binary that was actually built.
Rabbit holes
- Do not touch the iOS app. It is the reference and it is read-only for the whole batch. Every temptation to “just fix this one thing while I am in here” is a second project.
- Do not port the tests by counting them. I wrote that the iOS app had 101 domain tests. It has 108 methods, of which nine cover a platform-specific analytics path Android does not implement. The right artifact is a generated mapping from each iOS case to its Android counterpart, not a number in a document. A count that matches proves nothing about which cases are missing.
- Storage is not the engine. It is tempting to claim the whole domain is verified in the pure module because that module is easy to test. Dose logging and schedule regeneration happen in a transaction against the real database, so they get tested against the real database.
- Emulator evidence is not device evidence. An alarm that fires on an emulator after a simulated reboot says nothing about what a particular manufacturer’s battery optimiser does to it at 3am. Record the two separately and never let the first stand in for the second.
No-gos
- No cross-platform framework. Two native clients over one shared specification, not one codebase over two compromised runtimes.
- No backend. If this batch grows a server, the shaping was wrong.
- No new features. Anything that does not exist on iOS does not exist here. The point is parity, and parity is measurable.
- No claiming the store gate is closer than it is. An internal test release is a smoke test. Production access needs the closed test, the twelve testers and the fourteen days, and nothing about a finished build moves that.
What this cost me to learn: I budgeted two weeks to build this and it took a day, so the only number in the pitch that held was the four weeks I do not control. The build corrected the document in six places and was right in all six, including the test count above and a privacy policy I said was unnecessary. Then I filled in the three store declarations by hand, because attestations felt like mine to do, and got two of the three wrong. I had checked the app with tests I could run, and checked the forms by looking at whether they were filled in.
Get updates
Occasional notes on what's happening at Enginery. No spam, no marketing.