Shipping through a store policy deadline
A policy flag is not a bug report. There's a deadline, the consequence is removal, and negotiating isn't on the table.
The flag
A specific build was flagged under a photo-and-video permissions policy. The rule: apps targeting recent platform versions may only request broad media-read permissions when a system-provided picker is technically insufficient for the app's core functionality. Enforcement was already mandatory and non-compliant apps were subject to removal.
Our app requested broad media access to let users attach images. That is precisely the case the policy exists to eliminate, because the system picker handles it — the user chooses specific items and the app receives only those, with no standing access to everything else.
Why it isn't a one-line change
Deleting the permission is trivial. Keeping the app working afterwards is not, because the permission was load-bearing for more than the obvious path:
- Gallery selection — genuinely replaceable by the system picker.
- Camera capture that saves to the device — a different flow that had come to depend on the same permission, and which breaks silently if you only think about the gallery.
- Legacy storage permissions that older platform versions still honour and newer ones ignore.
What shipped
- Removed the broad media-read permission entirely.
- Moved gallery selection to the system picker.
- Capped the legacy storage permissions at the last platform versions that actually honour them. Left uncapped, the app keeps requesting permissions that newer versions ignore — which reads to a reviewer as still asking for broad access.
- Preserved the camera-capture path by requesting the narrower permission it genuinely needs, only on the versions where it applies.
The manifest before and after is the whole change:
<!-- Before: broad media access, and legacy permissions uncapped. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- After: broad access gone; legacy permissions capped at the last
API levels that actually honour them. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
Version-capping permissions is the detail most easily missed. A permission that a modern OS ignores is not harmless — it's still in your manifest, and the manifest is what gets reviewed.
On the app side, gallery selection moves to the system picker, which hands back only the items the user chose and needs no permission at all:
// No runtime permission request. The picker is the permission.
const result = await launchImageLibrary({
mediaType: 'mixed',
selectionLimit: MAX_MEDIA_COUNT,
});
Worth knowing: selectionLimit is advisory on some paths. If your product genuinely
caps attachments, enforce the count again after the picker returns — one of the five defects found in
review was exactly this, a limit that existed in the picker call and nowhere else.
The part that went wrong
The compliance change itself was fine. The delivery of it wasn't.
The pull request was merged capturing only its first commit. A follow-up commit — which reverted an over-ambitious refactor and fixed five defects a reviewer had found — landed twelve minutes later and never reached the main branch. A subsequent PR was then merged into that stranded branch rather than main, stranding it too.
Main was left running the known-bad first attempt with none of the fixes: a duration cap not applied on one path, a selection limit unenforced, and a control rendering as a blank white square.
The recovery was a PR that restored main to known-good behaviour while preserving the compliance change, with an explicit written inventory of what was live and broken so the team could see the blast radius rather than take my word for it.
What I took from it
- Verify the merged diff, not the PR page. The PR looked complete because it was; what landed on the target branch was not.
- Don't stack a branch onto something mid-merge. That's what turned one stranded commit into two.
- Write down what's broken while it's broken. An honest inventory is faster than the alternative and costs less trust than being discovered.
The code review worked here — it found five real defects. The process around it didn't. Those are separate systems and they fail separately.
← All engineering notes