feat(playscan): scan Android projects against Google Play policies - #17
Conversation
Adds a Google Play tier alongside the existing App Store checks. Android projects are detected automatically, including the android/ directory of an Expo or React Native app, so a cross-platform repo is checked against both stores in one pass. Also available standalone as `greenlight playscan`. Deadline checks, both landing 2026-08-31: - Target API level: CRITICAL below API 35, HIGH below 36, with a live day countdown - Play Billing Library 7 and below, which has no direct v7 to v9 path Policy checks: - Restricted permissions: SMS and Call Log (including the 2026-07-15 change dropping phone-call account verification), All files access, QUERY_ALL_PACKAGES, REQUEST_INSTALL_PACKAGES, background location, broad photo/video access, accessibility service, overlays, device admin, usage stats, contacts - Foreground service types missing their FOREGROUND_SERVICE_* permission, which throws at startForeground() on Android 14+ - android:exported missing on components with an intent filter, debuggable, cleartext traffic - Ads SDK without the AD_ID permission, which silently zeroes the ad ID - Account creation without both in-app and web deletion paths Every finding carries a Doc link to the policy page it comes from. Play policies are named rather than numbered and change often, so the citation is worth more than a section reference. The Apple scanners now skip only when a project is unambiguously Android-only. Previously every pure-Android repo led with a CRITICAL for a missing PrivacyInfo.xcprivacy, an iOS-only requirement. iOS-only and cross-platform projects are unaffected. targetSdk is resolved from the app module, convention plugins, version catalogs, gradle.properties, and named constants, since most real projects reach it through a reference rather than a literal. lint.targetSdk and testOptions.targetSdk are explicitly excluded. When it cannot be resolved the scan says so rather than reporting a pass. Scope is documented in the README and command help: this reads the pre-merge manifest, so permissions contributed by library manifests are not visible until the build merges them. Validated against nowinandroid, AnkiDroid, Thunderbird, Pocket Casts, DuckDuckGo, and Mattermost, with every finding checked against source.
Adds `greenlight playscan --apk` / `--aab`, closing the gap the source scan documents: a repo's AndroidManifest.xml is the pre-merge manifest, so permissions contributed by library manifests are invisible until build time. A built artifact carries the merged manifest, so its permission list is complete. Every existing policy rule runs unchanged against the decoded manifest. The archive scan adds the native code checks, which only exist at this layer: - 16 KB page size: ELF LOAD segment alignment on arm64-v8a, 16 KB zip alignment of uncompressed libraries, and GNU_RELRO presence. Google Play requires apps targeting Android 15+ to support 16 KB memory pages; an unaligned library fails to load and crashes at startup on those devices. Both container formats are decoded in pure Go with no new dependencies, so no Android SDK, aapt2, or bundletool is required: - APK: Android binary XML, including the string pool in both UTF-8 and UTF-16 form and the resource ID map. aapt2 emits an empty string-pool entry for framework attributes and identifies them only by resource ID, so without the map every android:* attribute reads as unnamed. - AAB: the protobuf-encoded manifest, walked directly off the wire format. foregroundServiceType is a bitmask once compiled; it is rendered back to the pipe-separated names the source manifest uses so the foreground service rule needs no second code path. Only arm64-v8a is assessed for 16 KB alignment. 16 KB page size devices are 64-bit ARM, so flagging a 32-bit library at 4 KB would be noise. Verified against real APKs. Termux 0.118.3 decodes to exactly the 15 permissions in its published source manifest, and its 4 KB aligned libraries are flagged; F-Droid's 16 KB aligned library is not. Alignment findings were cross-checked by parsing the ELF program headers independently.
Two denial-of-service bugs in the binary parsers, both reachable through the stated threat model of scanning an archive someone handed you, and both reproduced before and after the fix: - protoxml: the decoder recursed once per manifest nesting level with no bound. A deeply nested AAB drove a Go stack overflow, which is a process abort recover() cannot catch. An 8.9 MB file was enough. Now capped at 100 levels, matching Android's own parser; the repro drops to 0.35s / 22 MB and reports an undecodable manifest. - axml: attrCount is a uint16 read straight from the file and need not match the bytes present, but it was used directly to size an allocation. A 97 KB APK of 36-byte chunks each claiming 65535 attributes cost 65s CPU and 1.46 GB RSS. Capped by the chunk's actual capacity; the repro drops to 0.04s / 110 MB. Correctness: - protoxml decoded compiled values against a guessed schema. Primitive's oneof is non-contiguous in Resources.proto (float=3, int_decimal=6, int_hex=7, boolean=8), and the code treated field 3 as the boolean, so every compiled boolean in a bundle was misread and an AAB's debuggable=true was missed entirely. Now decodes Item.str and Item.prim against the real field numbers. - The BIND_* permissions are never requested via <uses-permission>; the framework requires them as the bound component's android:permission. Those rules could therefore never fire. Component permissions are now inspected, and BIND_VPN_SERVICE is covered alongside accessibility and device admin. - Every foreground service needs the base FOREGROUND_SERVICE permission from API 28 regardless of type. Only the type-specific permissions were checked, so an app missing the base one was reported clean. - Gradle file ranking assumed the app module is named "app", the same assumption already fixed for manifest selection. Ranking is now tied to the module the selected manifest belongs to, so a library module's targetSdk cannot be reported as the app's. - The Billing Library version had to be a literal, but most builds reach it through a variable or a version catalog version.ref, so the support-window check silently never fired on them. Both forms now resolve. This surfaces a real finding on Thunderbird, which ships Billing 7.1.1 via version.ref. - detectIOS missed Objective-C sources and app.config.json, either of which could classify a cross-platform repo as Android-only and skip the Apple scanners. Also drops GradleInfo.CompileSDK and .Dependencies, which were written or declared but never read, and serializes an empty findings list as [] rather than null.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c078a31. Configure here.
| return strings.Contains(line, "lint.targetSdk") || | ||
| strings.Contains(line, "testOptions.targetSdk") || | ||
| strings.Contains(line, "lint {") || | ||
| strings.Contains(line, "targetSdkPreview") |
There was a problem hiding this comment.
Lint block targetSdk misread
Medium Severity
targetSdkIsNotTheApps only filters dotted forms like lint.targetSdk / testOptions.targetSdk, not the common brace-block form where targetSdk = … sits on its own line inside lint { } or testOptions { }. Because the first literal wins, a lower lint/test target can be reported as the app’s targetSdk.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit c078a31. Configure here.
| strings.Join(truncateList(unaligned, 6), ", ")), | ||
| Fix: "Rebuild with AGP 8.5.1+ and NDK r28+, or add -Wl,-z,max-page-size=16384 to the linker flags. Prebuilt dependencies must be updated to a 16 KB compatible release.", | ||
| Doc: docPageSizes, | ||
| }) |
There was a problem hiding this comment.
16KB findings ignore targetSdk
Medium Severity
checkPageAlignment always emits CRITICAL/HIGH 16 KB findings and never consults the archive’s resolved targetSdk. Play only requires 16 KB support for apps targeting Android 15+, so lower-target uploads that Console still accepts can fail --exit-code CI.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit c078a31. Configure here.


Adds a Google Play tier alongside the existing App Store checks.
greenlight preflight .detects Android projects automatically, including theandroid/directory of an Expo or React Native app, so a cross-platform repo is checked against both stores in one pass. Also available standalone asgreenlight playscan.Why now
August 31, 2026 is a double deadline, ~5 weeks out:
Both are trivially detectable from the build files, and the target API finding carries a live day countdown.
What it checks
Restricted permissions, each of which creates a declaration or approved-use-case obligation:
READ_CALL_LOGuseQUERY_ALL_PACKAGES,REQUEST_INSTALL_PACKAGES, background locationManifest and build:
FOREGROUND_SERVICE_*permission. Throws atstartForeground()on Android 14+, so the feature crashes on every current deviceandroid:exportedmissing on components with an intent filter (API 31+), which fails to installdebuggable, cleartext trafficAD_ID, which silently returns a zeroed advertising ID rather than failing loudlyEvery finding links the policy page it comes from. Play policies are named rather than numbered and change often, so the citation is worth more than a section reference.
Behaviour change worth reviewing
The Apple scanners now skip only when a project is unambiguously Android-only. Before this, every pure-Android repo led with a CRITICAL for a missing
PrivacyInfo.xcprivacy, an iOS-only requirement. iOS-only and cross-platform projects run exactly as before. Covered by tests ininternal/preflight.Resolving targetSdk
Most real projects reach the SDK level through a reference, not a literal. Resolution covers the app module, convention plugins (
build-logic/,buildSrc/,build-plugin/), version catalogs,gradle.properties, and named constants.lint.targetSdkandtestOptions.targetSdkare explicitly excluded, since reading a low lint target would report a compliant app as non-compliant. When the value cannot be resolved the scan warns rather than reporting a pass.Scanning built artifacts (
--apk/--aab)A repo's
AndroidManifest.xmlis the pre-merge manifest, so permissions contributed by library manifests are invisible until build time. Passing a built artifact reads the merged manifest, so its permission list is complete. Every policy rule above runs unchanged against it, plus the checks that only exist at this layer:LOADsegment alignment on arm64-v8a, 16 KB zip alignment of uncompressed libraries, andGNU_RELROpresence. Play requires apps targeting Android 15+ to support 16 KB pages; an unaligned library crashes at startup on those devices. docsBoth containers are decoded in pure Go with no new dependencies, so no Android SDK,
aapt2, orbundletoolis needed:android:*attribute reads as unnamed.foregroundServiceTypeis a bitmask once compiled, and is rendered back to the pipe-separated source names so the foreground service rule needs no second code path. Only arm64-v8a is assessed for alignment, since 16 KB page size devices are 64-bit ARM and flagging a 32-bit library at 4 KB would be noise.Source scanning still documents its own limit in the README and command help, and now points at
--apk/--aabto close it.Testing
62 tests across
internal/playscanandinternal/preflight. Binary-format fixtures are synthesized in-test (hand-built AXML chunks, protobuf messages, and minimal ELF objects) rather than committing real APKs.Validated against six real production apps, with every finding checked against source:
Real-world testing caught four bugs that fixtures alone would have shipped:
AnkiDroid, notapp, so a benchmark module won on path score and hid every real permission. Now selects on the launcher activity.targetSdkwas unresolvable on 3 of 4 real repos, which used convention plugins,set("targetSdkVersion", 36), and properties variables.play-services-auth-blockstore(credential backup) matched the auth SDK pattern and raised the account deletion requirement on an app with no accounts.Each has a regression test.
Binary decoding verified against ground truth. Termux 0.118.3 decodes to exactly the 15 permissions in its published source manifest, with the same package name. Its 4 KB aligned
arm64-v8alibraries are flagged and F-Droid's 16 KB aligned library is not — both cross-checked by parsing the ELF program headers independently. Decoding also caught two bugs in the AXML reader:attributeStartis an offset from theattrExtstruct rather than the chunk start (a 16-byte error), and framework attribute names must come from the resource ID map.Note
Medium Risk
Large new scanning surface (manifest/Gradle/archive parsers and preflight gating) could mis-detect platforms or SDK versions; behavior is heavily tested but affects CI gating via
--exit-code.Overview
Greenlight now covers Google Play as well as the App Store: a new
playscanscanner andgreenlight playscancommand check Android manifests and Gradle builds against Play policies (target API deadlines, restricted permissions, foreground services, billing library version, AD_ID, account deletion, and related rules), with policy doc links on each finding.greenlight preflightauto-detects Android (including Expo/RNandroid/) and runs Play checks alongside Apple scanners when appropriate; Android-only repos no longer get Apple-only findings (e.g. missingPrivacyInfo.xcprivacy). Cross-platform repos are scanned against both stores in one pass. Preflight output gains Play context (package,targetSdk), named-policy formatting (no erroneous § prefix), anddocURLs on findings.Built artifacts (
--apk/--aab) decode merged manifests in pure Go (binary XML + protobuf) and add 16 KB page size checks onarm64-v8anative libs. Source scans still reflect pre-merge manifests; README and CLI document that gap.README and root CLI copy are updated to describe dual-store scope and the new command.
Reviewed by Cursor Bugbot for commit c078a31. Bugbot is set up for automated code reviews on this repo. Configure here.