●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify
Three conditions that make Play Policy Insights report nothing on an Expo project
Google Play now ships an open-source policy auditing skill. Running it against an Expo-shaped project, one directory argument moved the result from five detected data categories to zero. Here is when the scan actually reaches your code, and where its output should not be trusted.
In its July 15, 2026 policy announcement, Google Play pointed developers at an open-source auditing tool: something that grounds an LLM in Play's policy documents so your assistant can evaluate your code from inside your IDE or CLI. It is called Play Policy Insights. Anything that reduces the number of rejection round-trips is worth an afternoon when you are an indie developer keeping six apps in the store at once.
So I built a minimal Expo-shaped project and ran it. First pass, pointed at android/app: zero findings. Second pass, pointed at the repository root: five data collection categories.
Same code. Same permissions. Same dependencies. The only difference was the path I typed.
What the skill actually inspects
The skill lives in the android/skills repository. It audits three policy domains: Permissions and APIs Hygiene, User Account and Identity, and Data Safety and Privacy.
It runs in two phases. Phase 1 executes orchestrator.py init <app_dir>, which performs static analysis, maps the codebase, and decides which audit goals to activate. Phase 2 hands each activated goal to an agent as a generated prompt, collects the JSON results, and generate_report.py turns them into a Markdown compliance report.
Run per-goal prompts, then orchestrator.py aggregate
aggregated_findings.json
Finalize
generate_report.py <temp_dir>
compliance_report.md
Everything rests on Phase 1. Whatever the static scan misses is gone — no downstream model recovers it. The skill says as much: the Phase 1 audit is the source of truth, and if orchestrator.py fails you must stop rather than fall back to manual review. Which means the dangerous case is not a loud failure. It is Phase 1 quietly scanning nothing.
Condition 1: the entry point has to be the repository root
scanner.py checks the directory you gave it and its parent for a package.json or pubspec.yaml. If it finds one, it flags the project as hybrid and widens the scan root to that directory.
In an Expo project, package.json sits at the repository root. Point the tool at android/app and the parent is android, which has no package.json. Hybrid detection never fires, and not a single line of your JavaScript or TypeScript is read.
Directory passed to init
activated_goals
Data categories detected
Repository root
data_safety_part_1, permissions_and_apis
5
android/app
(empty)
0
When a tool says "Android app audit," pointing at the android directory feels correct. That is what I did first. But in Expo and React Native the code requesting permissions lives on the JavaScript side. Scan only the native tree and the audit stops being an audit.
The tell is one line on stderr: Hybrid app detected. Expanding scan root to: .... If that line is absent, the report is not worth reading.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to tell the difference between a pre-submission check that found nothing and one that never reached your code in the first place
✦You will be able to point the official skill at the right entry point in an Expo or React Native project and close Data safety gaps before you submit
✦You will be able to see where static scanning over-reports and where it is structurally blind, so you never copy a machine-generated finding straight into your Play Console declaration
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Condition 2: Expo's default Gradle shape hides your target SDK
Google Play requires target API level 36 or higher starting August 31, 2026 — listed under Reminders in that same July announcement. Target SDK is the first thing you want a pre-submission check to confirm.
It is also the thing that came back null. I fed four Gradle styles directly into parse_application_modules to find the boundary.
The cause is the variable reference. The skill resolves gradle.properties entries and version catalog entries, but it does not follow values declared in the ext { } block of android/build.gradle — which is exactly where Expo prebuild puts them. So target_sdk stays null in manifest_details.json and travels that way into every downstream prompt.
applicationId fared no better: three of the four styles failed to resolve it. When resolution fails, package_name falls back to the name field in package.json. In my reproduction the recorded package name was wallpaper-demo, which is not a package name at all.
With a deadline in sight, two nulls in the report means you checked without checking. Resolve the effective values yourself before you run anything.
#!/usr/bin/env bash# preflight.sh — verify the entry point before running Play Policy Insights# usage: ./preflight.sh /path/to/expo-projectset -euo pipefailROOT="${1:?pass the project root}"# 1) Scan root sanity: is package.json actually here?if [ ! -f "$ROOT/package.json" ]; then echo "FAIL: no package.json in $ROOT — hybrid detection will not fire and JS will be skipped." echo " Point the tool at the repository root instead." exit 1fiecho "OK: scan root = $ROOT"# 2) Effective targetSdk: a variable reference reads as null to the skillGRADLE="$ROOT/android/app/build.gradle"if [ -f "$GRADLE" ]; then LINE=$(grep -E '^\s*targetSdk(Version)?\s' "$GRADLE" || true) if echo "$LINE" | grep -qE 'targetSdk(Version)?\s+[0-9]+'; then echo "OK: targetSdk is a literal -> ${LINE//[[:space:]]/ }" else EXT=$(grep -E 'targetSdkVersion\s*=\s*[0-9]+' "$ROOT/android/build.gradle" 2>/dev/null | head -1 || true) echo "WARN: targetSdk is a variable reference; the skill will report null." echo " Value declared in ext: ${EXT:-not found}" fielse echo "WARN: no android/ directory (managed workflow). Confirm targetSdk from app.json."fi
Managed-workflow projects have no Gradle files at all, so the skill says nothing about target API level. Teams that ship builder output as-is are the most likely to read that silence as approval. For pulling the value the build actually uses rather than the value you declared, I wrote up the procedure separately in the three places I had to fix to reach targetSdkVersion 36.
Condition 3: node_modules is excluded from the scan
node_modules sits in ignored_directories in scanner_config.json, alongside .git and build. Reasonable for scan speed. Consequential for Expo and Rork projects.
What usually creates a Data safety obligation is not the code you wrote — it is what your dependencies collect on their own. When I reviewed declarations across the two Android and four iOS apps I run, the additions that mattered were the AdMob advertising ID and the crash logs Crashlytics transmits. Neither appears in my source as a getAdvertisingId() call. Both start the moment the SDK initializes.
So the bulk of the declaration surface sits outside the scanner's reach. That part has to be inventoried by hand, from the dependency list.
# Inventory collection-capable SDKs — surface declaration candidates from depsnode -e 'const pkg = require("./package.json");const deps = Object.keys({...pkg.dependencies, ...pkg.devDependencies});const rules = [ [/google-mobile-ads|admob|applovin|unity-ads|inmobi|liftoff/i, "Advertising ID, approximate location"], [/crashlytics|sentry|bugsnag/i, "Crash logs, diagnostics"], [/analytics|amplitude|mixpanel|firebase\/app$/i, "App interactions, diagnostics"], [/expo-location|geolocation/i, "Location"], [/image-picker|expo-media-library|camera/i, "Photos and videos"], [/purchases|revenuecat|iap|billing/i, "Purchase history"],];const hits = [];for (const d of deps) for (const [re, label] of rules) if (re.test(d)) hits.push([d, label]);if (!hits.length) { console.log("No collection-capable dependencies matched"); process.exit(0); }console.log("Dependencies that may require a Data safety declaration:");for (const [d, label] of hits) console.log(` ${d.padEnd(42)} -> ${label}`);'
These are candidates, not conclusions — confirm against each SDK's own documentation. Even so, mechanically surfacing "present in dependencies, absent from the declaration" removes most of the oversights.
And it over-reports in the other direction
Even when the scan does reach your code, do not transcribe its output into Play Console. Here is what the minimal project produced.
Category
Evidence cited
Correct?
PRECISE_LOCATION
app/index.tsx (Pattern: latitude)
Yes
FILES_AND_DOCS
app/index.tsx (Pattern: AsyncStorage)
Needs judgment
PHOTOS / VIDEOS
package.json (Pattern: expo-image-picker)
No — never called
MUSIC
app/index.tsx (Pattern: track)
No
MUSIC fired on the analytics endpoint https://api.example.com/v1/track. The configuration registers track as a MUSIC keyword. Anyone who names their analytics helper trackEvent will hit this on the first run.
PHOTOS and VIDEOS fired purely because expo-image-picker appears in package.json. No call site exists. Generated projects routinely carry dependencies they never use, so this class of over-report is the normal case, not the edge case.
Simultaneous over-reporting and structural blindness is simply what static scanning is. I treat the output as a list of places to look, not as a draft declaration. Open the cited file, decide one at a time. It is slow, but a wrong Data safety entry is a rejection reason, and fixing it after the fact costs more. If the declaration itself is what is blocking you, fixing the Google Play Data safety section covers that ground.
The order I run it in
With six apps in flight, an unfixed order is how you lose track of which app got which check. This is where mine settled.
Run preflight.sh to pin the scan root and the effective targetSdk
Run orchestrator.py init from the repository root and confirm Hybrid app detected appears on stderr
Confirm activated_goals is non-empty — an empty array means the entry point is wrong
Run the dependency inventory and add SDK-originated collection by hand
Walk each finding back to its cited file and drop the false positives
Diff against the current Play Console Data safety entries and change only what differs
Step 3 is explicit because that is the one that fooled me. An empty activated_goals does not mean clean. It means nothing ran. There is no error, no warning — just an empty array in a JSON file.
Post-launch monitoring is a separate discipline, but I keep it separate on purpose: thresholds for crash-free users and ANR get decided first, and a staged rollout does not advance to the next slice until they hold. Passing review and surviving after review are two different design problems, and keeping them apart has consistently been faster for me.
What to do next
Run orchestrator.py init once from your repository root and watch stderr for the Hybrid app detected line. If it is there, your entry point is right. If it is not, nothing you have checked so far was actually checked.
Twelve days remain before the target API level 36 requirement. Verifying that the scan reaches your code takes a few minutes.
One caveat on the numbers above: they come from a minimal reproduction of an Expo project shape, not from a production repository. Your dependencies and call sites differ, so your counts will differ. What transfers is not the values — it is the conditions under which the scan reaches anything at all.
I am still working this into my own release routine and have not yet judged the accuracy of Phase 2. When I have, I will write it up.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.