Skip to content

feat(playscan): scan Android projects against Google Play policies - #17

Merged
ethanzhoucool merged 3 commits into
mainfrom
feat/google-play-scan
Jul 27, 2026
Merged

feat(playscan): scan Android projects against Google Play policies#17
ethanzhoucool merged 3 commits into
mainfrom
feat/google-play-scan

Conversation

@ethanzhoucool

@ethanzhoucool ethanzhoucool commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Adds a Google Play tier alongside the existing App Store checks.

greenlight preflight . detects Android projects 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.

Why now

August 31, 2026 is a double deadline, ~5 weeks out:

  • New apps and updates must target Android 16 (API 36). Apps below API 35 already lose distribution to new users on newer devices. docs
  • Play Billing Library 7 loses support, with no direct v7 to v9 path. docs

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:

  • SMS and Call Log, including the July 15 2026 change that drops phone-call account verification as a permitted READ_CALL_LOG use
  • All files access, QUERY_ALL_PACKAGES, REQUEST_INSTALL_PACKAGES, background location
  • Broad photo/video access over the system Photo Picker
  • Accessibility service, overlays, device admin, usage stats, contacts

Manifest and build:

  • Foreground service types missing their FOREGROUND_SERVICE_* permission. Throws at startForeground() on Android 14+, so the feature crashes on every current device
  • android:exported missing on components with an intent filter (API 31+), which fails to install
  • debuggable, cleartext traffic
  • Ads SDK without AD_ID, which silently returns a zeroed advertising ID rather than failing loudly
  • Account creation without both in-app and web deletion paths

Every 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 in internal/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.targetSdk and testOptions.targetSdk are 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.xml is 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:

  • 16 KB page size — ELF LOAD segment alignment on arm64-v8a, 16 KB zip alignment of uncompressed libraries, and GNU_RELRO presence. Play requires apps targeting Android 15+ to support 16 KB pages; an unaligned library crashes at startup on those devices. docs

Both containers are decoded in pure Go with no new dependencies, so no Android SDK, aapt2, or bundletool is needed:

  • APK — Android binary XML: string pool in both UTF-8 and UTF-16 form, plus 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, 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/--aab to close it.

Testing

62 tests across internal/playscan and internal/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:

Repo targetSdk Result
nowinandroid 36 clean
AnkiDroid 35 3 findings, all verified at the cited lines
Thunderbird 35 verified
Pocket Casts 36 verified
DuckDuckGo 35 verified
Mattermost (RN) 36 verified

Real-world testing caught four bugs that fixtures alone would have shipped:

  1. Multi-module repos picked the wrong manifest. AnkiDroid's app module is named AnkiDroid, not app, so a benchmark module won on path score and hid every real permission. Now selects on the launcher activity.
  2. targetSdk was unresolvable on 3 of 4 real repos, which used convention plugins, set("targetSdkVersion", 36), and properties variables.
  3. Android-only repos got the bogus Apple CRITICAL described above.
  4. 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-v8a libraries 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: attributeStart is an offset from the attrExt struct 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 playscan scanner and greenlight playscan command 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 preflight auto-detects Android (including Expo/RN android/) and runs Play checks alongside Apple scanners when appropriate; Android-only repos no longer get Apple-only findings (e.g. missing PrivacyInfo.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), and doc URLs on findings.

Built artifacts (--apk / --aab) decode merged manifests in pure Go (binary XML + protobuf) and add 16 KB page size checks on arm64-v8a native 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.

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.
@ethanzhoucool
ethanzhoucool marked this pull request as ready for review July 27, 2026 23:47
@ethanzhoucool
ethanzhoucool merged commit 7223ea2 into main Jul 27, 2026
3 checks passed
@ethanzhoucool
ethanzhoucool deleted the feat/google-play-scan branch July 27, 2026 23:47

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c078a31. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant