The app is deliberately not the point of this guide, so here is all you get: a single-activity Jetpack Compose app in Kotlin, fully offline, no account, no network, no permissions, two languages, one outbound link, a local database, and encrypted export. One person built it and shipped it. It went live in the spring of 2026, seven months after the first commit.
What this guide covers is everything around the code that turned out to be harder than the code: the tools, the testing, the reviews, the disclosures, and the store. Nearly all of it applies to any small app. Where something is specific to this one, I say so and skip the details.
Important: This app was built with Claude Code from the first commit, and every review, test harness, and compliance pass in this guide went through an AI model too. Everything here can be done by hand. I did not do it by hand, and for a solo developer shipping a first app in 2026 I would not recommend it. This guide is honest about where the models were wrong, which was often enough to matter, and about what only a human holding the phone could catch. The closing section looks at how that changed across the project as the models did.
This is a write-up of a real project from its git history, its working notes, and memory, not a fresh replay. Dates and counts come from the repository. Where something is from memory alone (the early tooling pain, mostly) it is written generally and says so. No invented error messages.
Decide the Shape Before the Code
Three decisions were made before the first screen existed, and two of them turned out to matter more than anything in the code.
The one that paid off five times: no permissions, no network. The product requirements doc, written before any code, said “fully offline capable.” It also said MVVM architecture, a crash-reporting SDK, and a permission for adjusting screen brightness. What shipped had none of those three: no ViewModels (more on that in Step 4), no crash reporting, and not a single <uses-permission> in the manifest. The doc was wrong about the architecture and right about the posture, and the posture is the thing that kept paying:
- The Play Console’s Data Safety form became “no data collected, no data shared,” which is the shortest possible version of that form.
- The question of whether the one outbound link needed
INTERNETnever arose, because Custom Tabs open in the browser process, which holds its own permission. - Every later platform change that touched permissions, local network access, or sign-in was a no-op by construction. The companion guide on staying compliant counts these.
If you have a choice about whether your app talks to a server, and for a small utility you often do, make it early and make it “no.” Every form gets shorter.
The one I worried about before the idea was finished: screens. Before the app was even fully formulated I was already uneasy about device scaling. How does one layout survive every phone size, every font-size setting, every density, and then tablets? That worry was correct, and it became the longest-running thread in the project: a calibration reference that did not fit a phone (Step 5), text that scrolled off the bottom at a large font setting (Step 3), a portrait lock added to fix one screen that later became a Play Console warning (Step 7) and then a platform compatibility opt-out with an expiry date (the companion guide). If you take one thing from this page, take that: decide what screens you support, test on the ones you claim, and expect the platform to keep moving the line.
The one that has to happen before the account exists: the entity. The app was published under an LLC rather than a personal name, for two reasons that are worth separating.
The first is practical: it is materially easier to get an app onto the store from an organization account. Personal developer accounts created after November 13, 2023 must run a closed test with at least 12 testers opted in continuously for 14 days before they are allowed to publish to production. Organization accounts do not have that requirement. For a solo developer with no tester pool, that rule alone can be the difference between shipping and not.
The second is liability. This app’s subject matter sits next to a regulated area, close enough that the console asked about it (Step 7). If your app touches anything a regulator or a lawyer could plausibly care about, form the entity first. It happens to make the console easier too.
Costs vary by state and country and I will not quote one. But the decision is made at account creation, and the account type is not something you casually change later.
Also before code: a research document. For this app that meant writing up the public-domain protocols it would implement, with sources. It cost a day. It was later pasted nearly verbatim into the reviewer notes on the store submission, which is not what it was written for.
Studio, adb, and the Emulator
If you have never done Android development, the first hurdle is not Kotlin. It is understanding what the tools are.
Android Studio is the IDE, but it is also the SDK manager, the virtual device manager, and the owner of the JDK that builds the project. adb is a separate command-line tool that talks to a device or emulator: installs builds, taps the screen, pulls files, reads logs. Studio uses adb underneath, but you will end up using adb directly, and the mental model that “Studio is the editor, adb is the hand on the phone” is the one that makes the rest of this guide make sense.
Getting the app to run in a usable way on the emulator was awkward at first. Emulation is emulation; it is slow to boot, it has no real touch, and its screen is a window on a monitor rather than a phone in a hand. That is fine for navigation and layout and useless for anything physical, which matters for Step 5.
Three tooling problems stand out in memory, written generally because the session logs from those months no longer exist:
Locked virtual device files. At one point the emulator refused to start because the virtual device’s files were locked, and it was, to quote my notes, a huge pain. The fix was not a repair: unlock and manually delete the device’s files and start fresh in Studio’s Device Manager. The lesson is to manage virtual devices deliberately. Keep one per API level you actually need, name them for what they are, and when one breaks, delete and recreate rather than trying to save it.
API version mismatches. The project’s compile target, the Android Gradle Plugin, and Gradle itself are coupled; bump one and the other two have to move with it. The first hop this project made (34 to 35) broke the build until the plugin and Gradle were brought to matching versions. Worse, at one point a code refactor and a target-version change landed in the same pass, with two sets of objectives in one diff, and the incompatibility took a while to untangle. Switch target versions as an isolated change, verify, then do anything else.
Things that were simply missing. The project had no Gradle wrapper until the third month, so command-line builds did not work until one was generated. java was not on the PATH; the fix is to point JAVA_HOME at the JDK bundled inside Android Studio (its jbr directory), which is what Studio itself builds with, so there are no version surprises. On Windows under Git Bash, adb silently rewrites paths like /sdcard/... into a C:\Program Files\Git\... path, which breaks adb pull with no error; export MSYS_NO_PATHCONV=1 fixes it, and it took a while to find because nothing failed loudly.
Beyond those, updating Studio and the SDK as the project went was not much trouble, apart from the occasional forced Gradle resync.
And the general thing, which nobody says plainly enough: you will copy errors out of the build log and back into the chat, repeatedly. That is not a sign something is wrong with the tools or the model. It is the normal loop. Budget for it.
How AI can help
This step is where the model is at its most useful for a beginner and where it needs the least supervision: paste the error, get the explanation and the fix, repeat. It is good at the coupling arithmetic (which plugin version goes with which Gradle goes with which compile target) if you make it cite release notes rather than guess. The one place to be careful is exactly the mismatch above: when you ask for a version bump, ask for only the version bump, and reject a diff that also refactors.
The Bugs That Hid
Two months of build produced the usual volume of ordinary bugs, and they are not interesting. Five are worth writing down, because each one hid for a reason that will hide the same bug in your app.
The dark-mode logo took five commits in one day. The first attempt used Android’s drawable-night resource qualifier to swap in a dark variant. It never loaded, because resource qualifiers follow the system’s night mode, not your in-app toggle, and the app’s toggle was not syncing the system setting. Selection moved into Compose, keyed on the app’s own state. Then the dark PNG turned out to have a checkerboard pattern baked into its transparent regions by the tool that generated it, invisible on a light background and glaring on a dark one. Then the edges needed defringing. Lesson: resource qualifiers and in-app state are two different systems, and generated image assets carry artifacts you only see on the background you did not test.
Seven global singletons instead of ViewModels. Every measurement flow kept its in-progress state in a Kotlin object. It worked perfectly in the happy path. What it did not survive was process death: come back to a result screen after the system reclaimed the process, and the screen read stale defaults out of the singleton and happily saved them as a real result. This was found by a review pass (Step 4), not by testing, because you have to know to kill the process. The immediate fix was a validation guard on every result screen; the real fix, ViewModels with saved state, was deferred and is still the app’s largest piece of technical debt.
The brightness dialog never fired on any device, for a month. Two screens were supposed to offer to raise screen brightness before a measurement. The check needed the hosting Activity, and the code did LocalContext.current as? Activity. That cast is always null in this app, because the app wraps its content in a locale provider that calls createConfigurationContext, which returns a bare context with no chain back to the Activity. No crash, no log, just a dialog that never appeared. Found on a real phone in April by someone noticing it never appeared. The fix is LocalView.current.context walked up to the Activity, which bypasses the wrapper. If you use any context-swapping wrapper in Compose, every Activity lookup underneath it is suspect.
The home screen was unusable at a large font setting. At the system font scale of 1.3 the expanded category descriptions pushed the buttons below the bottom of the screen; at 1.5 they were unreachable. The root column had no scroll container. Adding one revealed a second rule: weighted spacers are illegal inside a scroll container, so they had to become fixed heights. This is the scaling worry from Step 1 showing up as a bug, and the test that finds it is a one-line adb shell settings put system font_scale 1.5.
Edge-to-edge crowding. Two hand-built screens drew content under the status bar because they did not use Scaffold and did not apply statusBarsPadding() themselves. Every other screen was fine. Same class as the font-scale bug: a layout assumption that only fails on a device.
One line on the general pattern. This project traveled through time: new model versions, refactors, store-required changes before submission, and testing that got more thorough as it went. Some bumps are the price of that. The defence is boring and non-negotiable: documentation and git. Every decision that mattered later was recoverable because it was in a commit message or a working note, and the ones that were not (Step 2’s tooling pain) are the ones this guide has to write from memory.
Review Passes, and How Often They Were Wrong
Before launch the code went through three kinds of review, and the useful thing to report is not what they found but how they disagreed.
A same-model audit ran five parallel reviews (architecture, security, UI, tests and dead code, store readiness), synthesized and deduplicated. It produced two “verify before submission” items, nine high, fourteen medium, and about sixteen low findings.
A different model, read-only. Codex, run in a sandbox that could read the tree but not write to it, reviewed the same code and returned nine findings, five of which were genuinely store-blocking: the stale-singleton save from Step 3, an import routine that read an entire user-selected file into memory with no size check, a dropdown item wired to an empty lambda, bare startActivity calls with no ActivityNotFoundException handling for devices with no browser, and an unsafe Activity cast that crashed in preview contexts.
Validation before action. Every finding from both was checked against the source before anything was changed. This is the step that mattered:
- The audit’s dead-code reviewer said a locale helper was entirely unused and should be deleted. Codex, checking the same file, confirmed it was used. Deleting it would have broken the language switch.
- The audit’s contrast-ratio arithmetic for a secondary text color was wrong. Codex recomputed it and supplied the value that actually passes.
- Codex was itself wrong on two of its own items (a state reset it said was missing already existed; a gitignore entry it wanted was already covered globally) and deferred three more of the audit’s suggestions as “the correct fix is different and needs tests.”
Out of the combined list, sixteen items were applied as low-risk, mostly single-line changes, and nineteen were explicitly deferred with a reason each: needs a visual regression pass, needs a database migration test, needs a product decision. The deferred list was treated as a first-class output, not a to-do that quietly vanished, and several of its items became the roadmap for the year after launch.
The pattern that came out of this and stayed: one model writes, a different model reviews read-only, and a human (or a third pass) checks every finding against the actual line before it becomes a change. Directionally the reviews were excellent. Line by line, each was wrong often enough that acting on them blind would have broken things.
How AI can help
Ask for the review in a form that can be verified: file, line, the claim, and what the reviewer expects to see at that line. Then hand the list to a fresh session or a different model and ask it to confirm or refute each item against the source, nothing else. The disagreements are where the value is. Also ask for the deferred list explicitly, with a reason per item, because a review that ends in "fixed everything" has usually just skipped the hard ones.
Testing Without a Test Framework, Then on a Real Phone
The app has unit tests for its calculations (63 of them; an earlier note said 94, which is a reminder that counts drift and should be checked before you publish them). It has no instrumentation tests and no UI test framework. What it has instead is adb.
The emulator walk. Two shell scripts, one that taps a coordinate and one that presses back, each followed by a screenshot and a uiautomator dump of the view hierarchy, with the current focused window printed after. Every screen, every back path, every cross-navigation, driven from a terminal and logged. The walk found zero crashes and zero dead ends, and two real bugs: an intro screen whose text clipped in landscape (fixed by locking the app to portrait, the decision that echoes through the rest of this page), and a trend indicator that showed “declined” after two data points, which is noise dressed as a signal (threshold raised to three).
It also produced a false positive that is worth more than the bugs. uiautomator reports Compose’s Surface(...).clickable nodes as clickable="false" even when they work perfectly. A finding was filed that the dashboard cards were non-interactive. It was wrong; tapping them worked. It was retracted with a note so the next walk would not re-file it. Never conclude an element is non-interactive from the hierarchy dump alone. Tap it.
Two more adb quirks from the emulator: taps on a freshly-composed button sometimes needed a second injection (a 50 ms input touchscreen swipe to the same point is more reliable than input tap), and everything image-drawn on a Compose canvas is invisible to the XML dump, so those steps need the PNG.
Then the phone, over WiFi. The emulator cannot tell you whether a letter is legible at arm’s length, whether the brightness feels right in a room, or whether a physical reference object fits on the screen. Those need a device in a hand. Wireless debugging in Android’s developer options lets adb reach the phone without a cable, with one trap that has bitten in every single session since: there are two ports. The pairing dialog shows one next to the six-digit code; the main wireless-debugging screen shows a different one for connecting. They are not interchangeable, the connect port changes every time the toggle is cycled, and the pairing itself does not reliably survive between sessions. adb mdns services finds the connect endpoint without typing an address, which helps.
The physical pass found four bugs in two sessions, none of which the emulator could have:
- The calibration reference did not fit the phone. The app asks the user to hold a card of known size against the screen to calibrate physical dimensions. The design used the card’s long edge, 85.6 mm. The test phone is 78 mm wide. Redesigned around the short edge with an adjustment slider seeded from the reported display density. Which led to the second finding: the phone reports 450 dpi and measures 386, a 16.5 percent overstatement. Anything in your app that depends on physical size cannot trust the reported density. The scaling worry from Step 1, made concrete.
- A Save button went dead, but only in one order of taps. Tapping the outbound link first auto-saved the result and set a flag; the Save button’s handler had its navigation inside the
if (!saved)guard, so it became a silent no-op. On all eight result screens. Only visible with real touch in the order a real user would use. - The brightness dialog that never fired (Step 3), noticed because a human expected it and it did not come.
- The large-font overflow (Step 3), found by setting the system font size to large on the phone and trying to use the app.
Plus a smaller one: long sessions let the screen auto-lock mid-measurement, so the activity now holds the screen on.
Two items were consciously skipped as not automatable and low-value: battery drain over a full session, and a comparison with and without corrective lenses. Everything else on the physical checklist was walked.
The rule this produced, stated as a rule because it is one: thorough manual testing on real devices is not optional, and it is not something a scripted walk replaces. The script finds navigation bugs. The hand finds the product bugs.
How AI can help
It wrote the adb scripts and drove the emulator walk itself, reading each hierarchy dump to decide the next tap, which is a genuinely good use: exhaustive, patient, and logged. Give it the device resolution and it will compute taps from the dump's reported bounds instead of guessing coordinates. What it cannot do is hold the phone, and what it will do is file a false positive from the hierarchy dump; the dashboard-cards retraction above was its own finding, corrected by its own tap. Ask it to cross-check any "non-interactive" claim against the source for a clickable modifier before it files it.
One Outbound Link Means Five Disclosures
The app has one way of making money: on one result screen, a card offers to open a retailer’s site through an affiliate link. The code for that is about forty lines. The disclosure around it touched five places, and missing any one of them is a policy problem.
- Next to the link itself, an FTC-style disclosure at full opacity, not greyed out: the app may earn a commission, at no extra cost to the user.
- In Settings, a “how we earn” entry with a dialog explaining the relationship.
- In the privacy policy on the website, a section on affiliate links stating that no personal data is shared with the retailer, because the link is one-way.
- In the store description, a line saying the app may suggest retailers and that those are affiliate links.
- In the console’s Data Safety and reviewer notes, declaring that the app contains links to external retail sites.
Two product decisions sat alongside. The card is suppressed for the results where a purchase would be the wrong advice, so the app never looks like it is selling something to a user it has just told to seek help. And the card is visually separated from the app’s disclaimers with its own container and spacing, so that a reader cannot mistake a commercial suggestion for guidance.
One tooling note. The affiliate network’s dashboard tool for building deep links was broken at the time (“unavailable” despite an active partnership). The workaround was to append the destination as a URL-encoded parameter on the base tracking link, which the network’s own documentation supports. Dashboards for these networks are often the least reliable part of the relationship.
Signing, the Console Gauntlet, and the Rejection
Everything in this step is generic to the Play Console and none of it is hard, but there is a lot of it, and one piece of it got the app rejected.
Registration. A one-time US$25 fee and identity verification, which differs by account type. Nothing about it is app-specific, and the organization-versus-personal decision from Step 1 is the only part with consequences.
Signing. Generate an upload key (RSA 2048, long validity) and enroll in Play App Signing, so Google holds the key that actually signs what users install and your upload key can be reset if lost. The build reads the keystore path and passwords from a local properties file that is gitignored. A review pass rightly pointed out those passwords sit in plaintext on the development machine; acceptable for a solo developer, but back up the keystore somewhere that survives the machine dying, because losing it means a support request to Google before you can ship an update.
The forms. Data Safety (short, thanks to Step 1). Content rating through the IARC questionnaire. Ads declaration (none). Government, financial, and app-access declarations (none, none, and “no login required”). Store listing: title, short and full description, a mandatory feature graphic at 1024x500 and icon at 512x512, phone screenshots. Tablet screenshots were skipped, with the app declared as phone-only. The reason recorded at the time (“calibration may not work on tablets”) later turned out to be wrong when checked against the code; the real large-screen blocker was state loss on rotation, which the portrait lock was hiding. The skip was still correct. The reasoning was not, and it got carried forward for months before anyone checked.
Category and declarations. Choose the category that is honest and does not invite a regulatory question you cannot answer. This app went in a general wellness category rather than a medical one, because it is a guidance tool that makes no diagnostic claims, and the disclaimers on first launch and every result screen say so. The console then has policy declaration forms for sensitive areas, and this is where it went wrong.
The rejection. The first submission was rejected within a week for an “inaccurate” declaration. The reviewer’s message named exactly one category on one form that had not been checked. The fix was to check that box, keep the existing free-text description unchanged, and not over-declare anything else in reaction. The console offers an appeal path on the existing release for exactly this case, which avoids bumping the version and re-uploading a bundle that was never the problem. Re-applied, approved within days. Submitted in late April, live in late April.
Would this apply to your app? The specific form, no, unless you are in a similar area. The pattern, yes: declaration forms are reviewed literally, the reviewer will tell you the one box, and the right response is the smallest accurate change, not a defensive sweep of every checkbox.
Warnings you accept. At submission the console flagged three “recommended” issues. Two came from one deprecated status-bar-color call in the theme, redundant since the app already enables edge-to-edge; the third was the orientation restriction from Step 5. All three were acknowledged and deferred. Recommended warnings do not block, and some are wrong for your app. Decide each on purpose and write the decision down; the companion guide inherits all three.
The listing. One country at launch. Widening it is a listing change, not a build change, and it is still open.
A small tooling footnote: a one-shot scheduled agent was set to check the public store URL three days after submission and report whether the listing had gone live. It reported “still in review.” It was cheap and slightly reassuring.
What Shipped, and What Came After
Against the requirements doc written at the start: the offline, no-account, no-permission posture shipped exactly as specified and was the best decision in the project. The architecture did not survive (singletons where ViewModels were planned) and that is the debt the app still carries. Crash reporting was dropped, which is what made “no data collected” true. Everything about screens, sizes, and orientation was harder than planned and is still not finished.
After launch was quiet. Very few changes between late April and late August. Once the app is on the store you watch for user feedback, if any arrives, and adjust. Iteration is what produces a polished app; the launch version is never the polished one, and it does not need to be.
Then Google started sending email, three times in one summer, and that is the companion guide.
What iOS would have taken
The app has not made the jump and may never, so this is from research rather than experience. The differences that would have mattered:
- Cost. Google Play is a one-time US$25 registration. The Apple Developer Program is US$99 per membership year, every year the app is listed.
- Hardware. Building and submitting for iOS requires Xcode, which requires a Mac. There is no supported path from a Windows machine alone.
- Codebase. A Kotlin and Compose app does not port. The non-rewrite option is Compose Multiplatform, which shares Kotlin logic and Compose UI across Android and iOS and was declared stable for iOS in 2025. Anything that touches platform APIs (this app’s brightness control, its display metrics, its file picker) still needs a per-platform implementation.
- Review. App Store review is generally reported as stricter and more human than Play’s, with more attention to how the app presents itself. A guidance-tool app with strong disclaimers would need the same care on the declaration side, in different forms.
None of that is verified here. It is the shape of the work, so that the decision not to do it is an informed one.
The models changed under the project
This project ran on AI coding tools from the first commit, and the commit trailers record which model wrote each one. That makes the trajectory something the history can show rather than something I have to claim:
| Period | Model | What it did |
|---|---|---|
| Early Feb 2026 | Claude Opus 4.5 | First screens, first tests, logo |
| Feb to mid-Apr 2026 | Claude Opus 4.6 | The bulk of the build, both review remediations, the emulator walk, the physical test sessions |
| Mar to Apr 2026 | Codex GPT-5.4 | Read-only second-opinion reviews |
| Mid-Apr 2026 | Claude Opus 4.7 | The five-review pre-launch audit |
| Aug 2026 | Claude Opus 5, then Claude Fable 5 | The compliance work in the companion guide |
What the history supports saying is narrower than “newer models are better,” and more useful. The early build shipped patterns that later reviews had to unwind: the singletons, dark mode hardcoded off with the system-theme check imported and unused, a contentDescription parameter accepted and never applied, the brightness dialog that could never fire. The August work, by contrast, caught its own mistakes: its first device smoke run reported three false passes, it diagnosed why (tapping disabled buttons and calling it success), and it rewrote the assertions and wrote down the anti-pattern. It also checked the standing “tablets won’t work” belief against the code and found it wrong.
That is confounded, and the guide would be dishonest not to say so. The project matured. A second-opinion tool arrived in March. The test harness got better. I learned the platform. The claim the evidence supports is that the later sessions found and corrected their own errors and the earlier ones did not, and that whatever share of that is the models, it was large enough to feel.
What You Spent
- Google Play registration: US$25, one-time
- The entity: varies by jurisdiction; not quoted here. Decided for the reasons in Step 1, not for the console
- Hardware: one mid-range phone that was already owned; no tablet
- Toolchain: $0. Android Studio, the SDK, the emulator, and adb are free
- Affiliate network: $0 to join; the network takes its cut from the retailer’s side
- Website and privacy policy hosting: a page on a domain that already existed
- AI tooling: the subscriptions were already in place for other work and are not itemized here
- Time: seven months from first commit to live listing, part-time, with a long quiet stretch in the middle
Toolkit Reference
The components that appear across this guide, and the concrete spots where an AI assistant earns its keep.
Tools and Services
- Android Studio
- IDE, SDK manager, virtual device manager, and the JDK under
jbr/that should be yourJAVA_HOME. Delete and recreate a broken virtual device; do not try to repair it. - adb and uiautomator
- The hand on the phone. Tap, press back, screenshot, dump the hierarchy. Under-reports Compose clickables; never file "non-interactive" from the dump alone.
- Android Gradle Plugin
- Coupled to the compile target and to Gradle. Bump all three together, in a change that does nothing else.
- Jetpack Compose
- The UI toolkit. Two traps from this project: resource qualifiers follow the system, not your state; and a context-swapping wrapper makes every
LocalContext-to-Activity cast underneath it return null. - Play's closed-testing requirement
- Personal accounts created after 2023-11-13: 12 testers opted in for 14 continuous days before production. Organization accounts are not subject to it. The numbers have changed before; read the page.
- Play App Signing
- Google holds the signing key; you hold an upload key. Back up the upload keystore off the machine.
- Policy violations and appeals
- The appeal path on an existing release. For a declaration-form rejection, fix the form and appeal; do not bump and re-upload.
- Compose Multiplatform
- The non-rewrite route to iOS for a Kotlin and Compose codebase. Platform APIs still need per-platform code. Not done here.
Where AI Earns Its Keep
- The error loop
- Paste the build log, get the fix, repeat. This is the normal mode of early Android work and the model needs the least supervision here.
- Version coupling
- Which plugin version goes with which Gradle and which compile target, cited from release notes. Reject any diff that bundles a refactor with the bump.
- Cross-model review
- One model writes, a different one reviews read-only, and every finding is verified against the line before it becomes a change. The disagreements are where the value is.
- The deferred list
- Ask for it explicitly with a reason per item. A review that ends in "fixed everything" has skipped the hard ones.
- Driving the emulator
- It can read a hierarchy dump, compute the next tap from reported bounds, and walk every screen patiently. It cannot hold the phone, and it will file a false positive from the dump; make it check the source first.
- Disclosure inventory
- Given one monetization feature, list every place it must be disclosed: in the UI, in settings, in the privacy policy, in the store description, in the console forms. It finds the fifth one you forgot.
- Reading the rejection
- Paste the reviewer's message and ask for the smallest accurate change. The instinct to over-declare in response is the wrong one, and it will say so.