A Play policy audit on one of our apps turned up two findings. One was a single line in the wrong place. The other was a page that made a promise the database contradicted.

Both are the kind of thing that passes review, ships, and sits there.

The line in the wrong place

The app initialised its analytics at module scope — at the top of App.js, outside any component, outside any effect. Which means it ran the moment the module was imported. Before the first render. Before the consent screen had been drawn, let alone agreed to.

So app_open was captured, and the install attribution was recorded, for a user who had not yet been asked anything.

Two separate obligations land on that. Google Play requires prominent disclosure and consent before collection begins. India’s DPDP Act requires notice before processing. This app is age-gated to 18+ and ships to India, so both applied simultaneously.

Module scope is the trap here, and it is an easy one to fall into. Code inside a component runs when the component renders, which you can reason about. Code at module scope runs at import time, which is before anything you can gate on exists. Nothing about the syntax warns you. It looks like setup, and setup feels like something that should happen early.

The fix moved all three calls into a single effect gated on two conditions: the stored preferences had hydrated, and the user had confirmed their age.

The order that caught us: the module loads, then the first render, then the user agrees

Failing closed, on purpose

The part worth stealing is what happens when that gate cannot resolve.

If the user abandons onboarding, or hydration never signals, or something throws in between — nothing is collected. The gate fails closed.

That is a deliberate inversion of how the same app handles its force-update check, which fails open: if we cannot determine whether an update is required, the user gets in. Two gates, opposite defaults, and the reason is that the harm is asymmetric in opposite directions.

For a force-update check, the harm is locking out a legitimate user over a network blip. For consent, the harm is collecting data from someone who never agreed. Over-collecting is a breach; under-collecting is a gap in your dashboard. Those are not the same magnitude of problem, and the default should say so.

Worth writing down explicitly somewhere in your codebase: for each gate, which way does it fail, and why. The answer is different per gate and it is almost never stated, so it gets decided accidentally by whoever wrote the try/catch.

Opposite defaults on purpose: the force-update gate lets you in, the consent gate collects nothing

One thing stayed ungated, deliberately

Crash reporting still initialises unconditionally, and that was a decision rather than an oversight.

It carries no analytics identity. It runs with personally identifiable information switched off. It exists to keep the app working rather than to learn anything about the person using it.

That distinction matters when you are drawing these lines. “Consent before collection” is not a rule that everything must wait. It is a rule about collection that builds a picture of a person. A stack trace that tells you a screen crashes on a particular device is a different category from an event stream tied to a distinct identifier, and treating them identically means either over-gating your diagnostics or under-gating your analytics.

Identity against diagnostics: gate the event stream, do not gate the crash report

The deletion page was not true

The second finding was worse, because it was a statement we had published.

Our account deletion page told guest users, in plain words, that we held no account for them to delete and their progress lived only on their device.

The first half was true. There is no account. The second half was not.

There was server-side data. An install attribution row, keyed by a device-local install identifier, recording the campaign that brought the user in. And analytics events under an anonymous distinct identifier.

Nobody wrote that page dishonestly. It was written from the mental model that “no account” means “no data”, and for a guest with no login that feels obviously right. The database disagreed, and the database is what the page is describing.

“Anonymous” is a claim about what is in your database, not a feeling about your product. If a row exists that is keyed to a device and records how that device arrived, you hold data about that user, whether or not they ever typed an email address.

The page now says what is actually held, and gives a working erasure route through the existing grievance address.

Anonymous is a database claim: no account exists, and a row keyed to the device still does

Two things our privacy policy had not disclosed

Writing the corrected page forced a second look at the policy, which turned up two undisclosed collections.

The install referrer — the campaign that brought the user in — was being recorded and was not mentioned.

The IP address and approximate location were being collected, because the analytics SDK enriches events with them and its React Native version offers no geo-IP opt-out. That enrichment is on by default and there was no way to switch it off at the SDK level. It is disclosed now, with a note that it can be narrowed if the project-level IP anonymisation setting is enabled later.

That second one is the general warning. Your third-party SDKs are collecting on your behalf, and you are the one who has to declare it. An SDK that enriches every event with location data has made a disclosure obligation for you, silently, in a default you never chose.

Declared by you, not by them: the install referrer, and the IP and rough location the SDK adds by default

What the tests actually prove, and what they do not

We wrote six tests around the new gate and mutation-probed three of them: return the analytics call to module scope and a test goes red; drop the age condition from the guard and a test goes red; remove a required import and a test goes red.

The honest limitation, which we wrote into the commit: these are assertions about the shape of the source, not the behaviour of the running app. Without a render-testing library in this project, the component cannot be rendered in a test, so the gate’s runtime behaviour is not proven. What the tests do catch is the specific regression that actually occurred.

That is worth saying out loud rather than letting a green tick imply more than it earns.

The bug we caught while fixing the bug

While writing the new gate, we used a React hook without importing it.

Babel parses that file without complaint. The build succeeds. It would have crashed on launch, for every user, on every platform.

“It compiles” is not a judge. That is why one of the six tests checks the hook import specifically — not because import errors are interesting, but because this one would have shipped.

A draft of the corrected deletion page also told users they could find their install identifier under a Settings screen that does not exist. Removed before it went anywhere. Writing accurate privacy copy means checking the app, not describing the app you remember.

Builds fine, crashes anyway: a hook used without being imported

If you are shipping an app with any analytics

Check where your analytics initialise. If the call is at module scope, it runs before your consent UI exists.

Read your own deletion and privacy pages against your actual database, not against your mental model of the product. The phrase to be suspicious of is any version of “we do not hold anything about you”.

List what each third-party SDK collects by default, including the enrichment you did not ask for.

And decide, per gate, which way it fails — then write the reason next to the code.

This is the kind of review we run as part of app maintenance and support, and it is built into how we approach store submission and compliance on new work.


Awesome Apps builds and maintains iOS and Android applications for Australian businesses.

Ready to build your app?

Talk to a Sydney app developer — free.

30 minutes. We'll tell you what your app needs, how long it takes, and what it costs. Real answers, no sales pitch.

Book Free App Strategy Call →

Free · 30 minutes · No obligation