android-corpus-prep · git:20260528.572d3e5 · 2026-05-28 · sha256 9f28a1c592949771
android-corpus-prep git:20260528.572d3e5A
Immutable. This exact content is served forever at /api/v1/blob/9f28a1c592949771.
---
name: android-corpus-prep
description: "Use when preparing Android APK corpora — AndroZoo metadata download, popular-package selection via DuckDB, APK download from Play or AndroZoo, and per-pass provenance manifests."
allowed-tools:
- run_corpus_inventory
- bash
- read
- grep
- glob
- web_search
- web_extract
- report
license: MIT
---
# Android corpus preparation
Use this skill when the user needs APKs to analyze, wants a popular/common Android app corpus, or asks how to select targets from AndroZoo / F-Droid / device extraction. The flow is **scripts + DuckDB on Parquet** end-to-end; the only LLM tool in this skill is `run_corpus_inventory` (called after APKs are on disk).
## Outcome
Produce a reproducible corpus manifest that answers:
- where each package/APK came from
- why it was selected
- which exact SHA256/version was downloaded
- what authorization/provenance constraints apply
- where the APK files and manifests live
## Corpus layout (mandatory)
Organize every corpus pass as a self-contained directory under `corpus/passes/`. The pass directory — not the downloader — owns the APKs.
```
corpus/
├── shared/
│ └── androzoo-meta/ # AndroZoo metadata (gz + parquet), shared across passes
├── selections/ # Exploratory target lists not tied to any pass
└── passes/
└── corpus-N/
├── selection.jsonl # Input target list
├── manifest-gplaydl.jsonl # Per-source download manifests
├── manifest-androzoo.jsonl # (only sources actually used)
├── apks/ # All APKs for this pass, regardless of source
├── logs/ # Downloader stdout/stderr, smoke runs, .tmp files
└── README.md # What the pass is and how to feed it forward
```
Rules:
- **One pass = one directory.** Never split APKs from the same pass across multiple folders based on which downloader produced them.
- `apks/` is the only place APKs live. `source` (`gplaydl`, `androzoo`, ...) is recorded as a field in the manifest row, not in the path.
- AndroZoo metadata files (`*.csv.gz`, `*.jsonl.gz`, Parquet) live under `corpus/shared/androzoo-meta/` — they are corpus-independent and 9 GB+.
- Exploratory target lists that don't correspond to a real download pass go to `corpus/selections/`.
- Manifest `path` fields must point at `corpus/passes/corpus-N/apks/...`.
## AndroZoo metadata-first path
This is the preferred path for popular/common APK hunting.
### 1. Fetch the metadata sources
```bash
# Google Play metadata aggregate (~1.3 GB gzipped JSONL).
python3 scripts/androzoo_gp_metadata.py download \
--kind aggregate \
--out corpus/shared/androzoo-meta/gp-metadata-aggregate.jsonl.gz \
--progress
# AndroZoo APK index (~3.6 GB gzipped CSV).
curl -L -o corpus/shared/androzoo-meta/latest_with-added-date.csv.gz \
https://androzoo.uni.lu/static/lists/latest_with-added-date.csv.gz
```
### 2. Convert both sources to ZSTD Parquet (one-time)
The shipped sources are large gzipped files you will join against repeatedly during corpus selection. Convert them to Parquet once. Working set drops 5–10× and DuckDB pushes predicates and column projections into the scan.
```bash
# ~80s for the JSONL, ~30s for the CSV on a Mac M-series.
python3 scripts/androzoo_to_parquet.py csv \
corpus/shared/androzoo-meta/latest_with-added-date.csv.gz \
corpus/shared/androzoo-meta/parquet/androzoo_latest.parquet
python3 scripts/androzoo_to_parquet.py json \
corpus/shared/androzoo-meta/gp-metadata-aggregate.jsonl.gz \
corpus/shared/androzoo-meta/parquet/androzoo_gp_metadata.parquet
```
What the script does that matters:
- CSV path runs the DuckDB CLI through a named pipe so the 7 GB decompressed CSV is never written to disk. Output ~3 GB Parquet.
- JSONL path uses **streaming pyarrow** with an **explicit schema**. DuckDB's `read_json` schema sniffer OOMs at 6 GB on the 1.3 GB source — never sniff a multi-GB JSONL. Nested `related_apks_in_AZ_info` is JSON-stringified into a single TEXT column to preserve round-trip without pulling List<Struct> typing in.
### 3. Materialize a popular-clean candidate set once per refresh
Run the popular-package selection + clean-Play-APK join straight in DuckDB. On a ~15k popular-package candidate set this is seconds with <200 MB peak RAM, and the output Parquet is ~1.3 MB — re-queryable in milliseconds:
```bash
duckdb -c "
COPY (
WITH popular AS (
SELECT pkg_name, max_nb_downloads AS dl, max_star_rating AS rating, max_ratingsCount AS ratings
FROM 'corpus/shared/androzoo-meta/parquet/androzoo_gp_metadata.parquet'
WHERE max_nb_downloads >= 10000000
AND max_star_rating >= 3.5
AND max_ratingsCount >= 1000
),
candidates AS (
SELECT
l.sha256, l.pkg_name, l.vercode, l.apk_size, l.markets, l.added, l.vt_detection, l.dex_date,
p.dl, p.rating, p.ratings,
ROW_NUMBER() OVER (PARTITION BY l.pkg_name ORDER BY l.added DESC NULLS LAST, l.vercode DESC) AS rn
FROM 'corpus/shared/androzoo-meta/parquet/androzoo_latest.parquet' l
JOIN popular p USING (pkg_name)
WHERE l.markets LIKE '%play.google.com%'
AND coalesce(try_cast(l.vt_detection AS INTEGER), 0) <= 0
AND coalesce(try_cast(l.apk_size AS BIGINT), 0) BETWEEN 1000000 AND 200000000
)
SELECT * EXCLUDE rn FROM candidates WHERE rn = 1
) TO 'corpus/shared/androzoo-meta/parquet/popular_latest_clean.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
"
```
Use this as the canonical input for per-corpus filter passes (banking, messaging, healthcare, etc.). Cuts the cost of "try a different filter" from 30s to 50ms.
### 4. Per-pass filter → selection.jsonl
Pick the subset for this pass with a focused DuckDB query against the canonical candidate set. Examples:
```bash
# Wallets — selection.jsonl with the columns the downloaders need.
# Tune the LIKE / IN lists for the target class; the example below is purely illustrative.
duckdb -c "
COPY (
SELECT pkg_name AS package, sha256, vercode AS version_code, apk_size, dl, rating
FROM 'corpus/shared/androzoo-meta/parquet/popular_latest_clean.parquet'
WHERE lower(pkg_name) LIKE ANY ('%wallet%','%crypto%','%coin%','%defi%')
ORDER BY dl DESC
LIMIT 30
) TO 'corpus/passes/corpus-wallet/selection.jsonl' (FORMAT JSON);
"
```
### 5. Download the APKs
```bash
mkdir -p corpus/passes/corpus-wallet/{apks,logs}
```
**Primary — `gplaydl_bulk.py` for any selection that targets currently-listed Play apps.** Roughly 30–60× faster than AndroZoo per APK and needs no API key (anonymous Google Play auth via [Aurora Store](https://auroraoss.com/)'s public token dispenser):
```bash
python3 scripts/gplaydl_bulk.py \
corpus/passes/corpus-wallet/selection.jsonl \
--out-dir corpus/passes/corpus-wallet/apks \
--manifest-out corpus/passes/corpus-wallet/manifest-gplaydl.jsonl \
--jobs 8
```
The selection JSONL needs at minimum `{"package": "com.foo"}` per line; the script ignores `sha256` because Google Play serves the current version directly. Failures are written to the manifest; common errors are "App not found" (delisted) and "No download URL returned" (region-locked or paid).
**Fallback — `androzoo_download.py` for delisted, paid, or AndroZoo-only historical versions.** `--out-dir` is the SAME `apks/` directory as the gplaydl primary; the pass directory is the unit, not the downloader.
```bash
python3 scripts/androzoo_download.py \
corpus/passes/corpus-wallet/fallback-androzoo-selection.jsonl \
--out-dir corpus/passes/corpus-wallet/apks \
--manifest-out corpus/passes/corpus-wallet/manifest-androzoo.jsonl \
--jobs 10
```
AndroZoo throttles each connection to ~440 KB/s but allows ~20 concurrent. The default `--jobs 12` is a safe sweet spot. Use `--api-key-file` or `ANDROZOO_API_KEY` env var; never inline the key.
**Empirical comparison on an 89-package, 6.8 GB corpus:**
| Path | Wall time | Speed |
|---|---|---|
| `gplaydl_bulk` jobs=6 (78 succeed) + AndroZoo fallback (10 left) | ~10 min total | ~12 MB/s aggregate |
| Old serial AndroZoo only | ~3 h | ~440 KB/s |
See `../android-semantic-vuln-hunting/references/corpus-acquisition.md` for the full source-by-source landscape, terms, and tradeoffs.
## Other corpus sources
Read `../android-semantic-vuln-hunting/references/corpus-acquisition.md` before recommending a source. Short version:
- User-provided/internal APKs are best for actionable findings.
- Official device extraction is best for curated popular apps when authorization/terms allow.
- F-Droid is best for clean smoke testing and reproducible open-source samples.
- APK mirrors are fallback only; record URL, timestamp, version, SHA256, and signature/provenance caveats.
## Selection bias for semantic bugs
Prioritize packages with likely high-impact logic:
- account/login/session flows
- OAuth, SSO, identity, wallet, payments
- retail/travel/loyalty/telecom/health/enterprise SaaS
- WebView-heavy hybrid flows
- partner/campaign/shortlink/deferred deep link SDKs
- file sharing, messaging, documents, support chat
- React Native, Flutter, Cordova, Capacitor bridges
Avoid spending first-pass cycles on simple utilities, games, launchers, or static content unless metadata/inventory suggests rich entrypoints.
## Output manifest
For every corpus pass, preserve JSONL like:
```json
{"package":"com.example","title":"Example","selection_reason":"wallet keyword + high downloads","source":"androzoo","sha256":"...","version_code":"123","path":"corpus/passes/corpus-N/apks/com.example_123_deadbeef.apk","selection":"corpus/passes/corpus-N/selection.jsonl"}
```
`source` is the downloader (`gplaydl`, `androzoo`, ...) — it's metadata on the row, not a directory in the path. Use the manifest as the entrypoint for later inventory, decompilation, and reporting.
## Hand off to inventory
Once APKs are on disk, run `run_corpus_inventory` to produce SHA256-keyed artifact directories with Androguard manifest decoding and optional APKiD packer detection. That output drives the semantic-vuln-hunting pipeline.
```python
run_corpus_inventory(
paths=["corpus/passes/corpus-N/apks"],
out_dir="findings/corpus-N/inventory",
jobs=8,
include_apkid=True,
)
```
One pass directory → one inventory directory. Don't mix passes in a single inventory unless you've thought through how findings will attribute back.