APK CLUES UUID Extractor
What this skill does
Given a path to a single Android .apk / .xapk file or a directory of them, this skill discovers every Bluetooth Low Energy GATT-related UUID referenced by the app's decompiled code, classifies each as GATT Service or GATT Characteristic from local code context, and emits an array of CLUES-format records to CLUES_Schema/data/CLUES_data_LLM_Android_APK_search.json (sibling of the hand-curated CLUES_Schema/data/CLUES_data_human_verified.json, both conforming to CLUES_Schema/CLUES_schema.json).
For each APK it:
Verifies the package requests Bluetooth. Runs
aapt dump permissionsand looks forandroid.permission.BLUETOOTH,BLUETOOTH_ADMIN,BLUETOOTH_SCAN, orBLUETOOTH_CONNECT. If none are present, the APK is logged as "no BT permissions; skipped" and the skill moves to the next file without running jadx — decompiling a non-BT app is wasted time.Extracts
package_id,version_code,version_namefromaapt dump badging. These populate theandroid_info_arrayfor every UUID found in the app.Decompiles to a temp dir.
jadx --no-res --no-debug-info -d <tmpdir> <apk_or_xapk>(jadx 1.5+ understands.xapknatively, so XAPKs do not need to be unzipped first). The--no-resflag skips XML resource decoding — UUIDs live in the code, not in the AndroidManifest.Scans decompiled
.javafiles for UUID patterns — regex finds the candidate sites, the script then reads the code around each hit rather than relying on the regex alone (per GreyNoise's "regex will not dynamically emulate DEX bytecode" warning):- Raw 128-bit UUID string literals (
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") — picks upUUID.fromString("..."),ParcelUuid.fromString("..."), log strings, JADX deobfuscation hints, etc. - For every literal, the script also captures the LHS Java identifier of the assignment (
public static final UUID LIBRE3_DATA_SERVICE = UUID.fromString(...)) — this becomes theUUID_nameafter light cleanup (strip trailing_UUID/Uuid, drop Javam/smember prefixes). Minified jadx leftovers (f7217p, single-letter aliases) are rejected — those records getUUID_name: "Unknown"and a later APK that has the same UUID with a human-meaningful field name will overwrite it. - When JADX has moved the semantic label away from the literal, the scanner also recovers names from lowered enum rows (
WIFI_CONTROL = new TwinService("WIFI_CONTROL", …, "uuid")), nested sealed-object constructors that pass a UUID temp intosuper(...), anonymous singleton-object fields (BulkTransfer = new e0() { … UUID.fromString("uuid") … }), constructor parameter roles preserved in another class (new BluetoothServiceConfiguration(uuid1, uuid2, uuid3, …)→notifyCharacteristicUUIDfrom the callee signature), and assigned wrapper constructors where the target field is the best surviving label (writeCharacteristic = new BleCharacteristic(serviceId, id)). - Then the script performs a read-the-code usage trace: for every
(field, uuid)pair, it searches the whole file forgetService(field)/getCharacteristic(field)/setCharacteristicNotification(...)/ etc. A hit here is authoritative — those calls are how the Android API itself decides what a UUID is, so they beat any naming-convention heuristic.
- Raw 128-bit UUID string literals (
Decides at the file level whether a
.javafile is BLE-relevant. Two ways a file qualifies (either is enough):- Hard signal: the file references one of
android.bluetooth.BluetoothGatt*/android.bluetooth.le.ScanFilter/android.os.ParcelUuidanywhere. - Constants-class fallback: the file contains ≥3
UUID.fromString("128-bit-literal")calls with no BLE imports. This catches "pure constants" classes like Abbott Libre3'sDCSGKSConstants.java(15 UUIDs, zeroBluetoothGatt*refs — the BLE-aware code lives in other classes that import this one) or Harman'se2/d.java. A non-BLE config class with a couple of analytics/telemetry UUIDs in URLs (Adobe Edge, Azure App Insights, OneTrust) won't trigger the fallback because those UUIDs sit inside string concatenations, notUUID.fromStringcalls. A file that hits neither signal is treated as "probably not BLE" — UUIDs in it only survive if aBluetoothGatt*identifier sits within ±8 lines of the literal.
- Hard signal: the file references one of
Filters Bluetooth-SIG standard UUIDs and already-published member UUIDs and curated CLUES UUIDs. Any UUID matching the SIG base
0000XXXX-0000-1000-8000-00805F9B34FBis collapsed to its 16-bit form. Then:- UUID16s in the SIG-allocated standard ranges (0x0000 / 0x0001–0x012F SDP protocols / 0x1000–0x12FF SDP service classes / 0x1800–0x18FF GATT services / 0x2800–0x29FF declarations & descriptors / 0x2A00–0x2BFF characteristics) are dropped — they are not vendor-specific.
- UUID16s already listed in
~/Blue2thprinting/Analysis/public/assigned_numbers/uuids/member_uuids.yaml(the SIG's public vendor-allocated UUID16 list) are also dropped from the output — they're already public, so there's no value re-recording them in CLUES_data_LLM. The hits are surfaced internally for the company-inference step below. - UUIDs already in
~/Blue2thprinting/Analysis/CLUES_Schema/data/CLUES_data_human_verified.json(the hand-curated, human-verified canonical CLUES file — renamed fromCLUES_data.jsonwhen data files moved underCLUES_Schema/data/) are dropped from the LLM output. Those entries are the project's highest-trust source; re-emitting an auto-extracted version risks a curator accepting a regression on merge. Configurable via--curated-clues PATH(pass an empty string to disable the skip-list). The startup log reports how many curated UUIDs were loaded. - UUID16s outside all three filters (typically vendor-internal IDs like
0xE104or0x8A21) and all UUID128s not in curated CLUES are kept.
Classifies each remaining UUID as Service vs Characteristic. Cascading decision:
- Definitive usage trace (read-the-code): if the captured field name is passed to
getService(...)anywhere in the file → Service. Passed togetCharacteristic(...)/writeCharacteristic(...)/setCharacteristicNotification(...)/ similar → Characteristic. This is the same authoritative sink set BLEScope uses for its value-set analysis. - Anchor line: if no definitive usage trace, look at the UUID's own source line for naming-convention hints (
_SERVICE/_CHAR_/BluetoothGattService/BluetoothGattCharacteristic). - Window fallback: if the anchor line doesn't disambiguate, look at the surrounding ±8 lines.
- File-level last resort: if everything is silent (minified field names, e.g.
f15558a), classify based on whether the file overall mentionsBluetoothGattService,BluetoothGattCharacteristic, or both. Files using onlyBluetoothGattCharacteristicdefault to Characteristic; otherwise default to Service.
- Definitive usage trace (read-the-code): if the captured field name is passed to
Infers the
companyfield per APK. Cascading resolution, in order of preference (always preferring SIG-official member names when they're a plausible match):KNOWN_SDK_UUIDStable hit (highest priority). If the UUID is in the curated Phase-4 cache of third-party-SDK UUIDs, the table's entry wins — beats every other inference. This is how the skill auto-attributes well-known shared SDKs (Kubi/Zoom, Nordic UART, TI OAD, Microchip ISSC, HM-10, Ojmar/OCS-Smart) without per-host-app guessing.- Single-APK third-party-SDK detection. Each UUID literal is annotated with the Java
package …;declaration of the.javafile it was found in. If that declaring-package's tokens have zero overlap with the host APK'spackage_id, the UUID is flagged as"Third-party SDK code (declared in Java package <pkg> — distinct from host <host>; Phase-4 identification needed)". This catches SDK leakage on a single-APK basis without waiting for ≥3 host apps to share the UUID (which is the Phase-4 cluster threshold). Example caught on the May 2026 corpus:5301(declared incom.stidmobileid.developmentkit) and00009800-…-00177a000000(declared incom.assaabloy.mobilekeys.api.ble) were flagged on the very first Sesame Smart Spaces scan, even though only one host APK was processed. member_uuids.yamlwith token-overlap gate. If the APK exposes a UUID16 that's listed inmember_uuids.yaml, and that member name shares at least one ≥3-letter token with the package_id, the SIG-official member name wins. Socom.abbott.lingo.wellness+ UUID160xFDE3→ "Abbott Diabetes Care" (tokenabbottmatches). The overlap gate is essential: many apps reference UUIDs from shared SDKs (Amazon, Bose, Sony, Qualcomm, Taobao, …) and we must not mis-tag a fitness app as "Taobao" just because it links a Taobao-published UUID16. No overlap → fall through to the package heuristic.- Package-name heuristic. Hard-coded
KNOWN_VENDOR_PACKAGESmap covers ~50 common vendors (Abbott, Dexcom, Harman, Garmin, Withings, Nordic, etc.); fallback is the first non-TLD-like component capitalized (com.proctur.app222423→ "Proctur (inferred from package id)";branded.com.publicansmanhasset→ "Publicansmanhasset (inferred from package id)"). TLD-like and whitelabel tokens (com,co,branded,app,android, …) are skipped. - Last resort:
"Unknown"only when all sources are silent.
Merges across APKs with semantic upgrades. When the same UUID is seen in multiple APKs, the record's
UUID_nameis replaced whenever a new APK has a higher-scoring identifier (e.g.,LED_COLOR_UUIDbeatsf7217pbeatsUnknown). Same goes forcompany— a non-Unknownvalue replaces anUnknown. Each new APK appends a row toandroid_info_arrayand an entry toevidence_array, so the full provenance is preserved.Evidence deduplication (
dedupe_evidence_array). Two evidence entries are treated as duplicates and collapsed into a single survivor when any of these holds:- They share the same
URL(case-insensitive, after strip) — one URL evidence per Phase-3 mapping, no matter how many sibling records merged in. - Both descriptions begin with
Found in decompiled DEX of <pkg> v<ver> (versionCode <code>)with identical(package_id, version_name, version_code). This is the user-reported pattern where a single APK has the same UUID referenced from multiple Java files — Phase 1 would emit one evidence entry per file (each with a differentClassification source:or Java field name), but the underlying fact ("this UUID is in this APK build") is one. Theandroid_info_arrayalready carries the unique-per-(pkg, version_code) view of the same fact, so multiple evidence rows are pure noise. - The full
(description, submitter)tuple matches (and neither has a URL).
When two entries collide, the more informative survivor wins — preferring entries that captured a Java field name, an authoritative
usage:classification source, or a third-party-SDK declaring-package annotation. Order is preserved: the survivor lives at the position of the first occurrence, so curator-edited entries stay at the top.Dedup runs in three places, so re-running phases or merging on top of existing data is idempotent:
- Inside
merge_recordsafter each per-UUID extend. - As a final pass in
save_outputbefore writing. - Inside
apply_apk_url_evidence.pyandapply_uuid_name_resolution.pyafter their append step, so re-running those scripts never restacks the same URL or Phase-5 note.
- They share the same
Attempts to pair Characteristics with their parent Service when the Service UUID appears within the same
.javafile. If no parent can be found locally,parent_UUIDis omitted (allowed by the schema).Emits one CLUES record per unique UUID. Each record carries an
android_info_arrayentry tagging which package(s) it was found in. When the same UUID turns up in multiple APKs, the entries are merged: the UUID gets a single record with multipleandroid_info_arrayitems.Deletes the temp jadx output dir. Every APK's decompilation is removed before the next one starts. The persistent output is only the JSON file.
Prerequisites — confirm before starting
jadx≥ 1.5 must be installed.brew install jadx. Earlier versions don't understand.xapkand would need a manualunzip → base.apkstep.aaptfrom the Android build-tools must be reachable.brew install android-commandlinetoolsinstalls it under/opt/homebrew/share/android-commandlinetools/build-tools/<ver>/aapt. The script auto-discovers the newest installedaapt.python3(any 3.9+).- Disk space for jadx output. A single decompilation can produce 1–4× the APK size in
.javafiles. The script writes to$TMPDIR(or/tmpif unset). Make sure the tmpfs has at least 4 GB free before pointing the skill at a folder of fat APKs (e.g. Dexcom Stelo 170 MB → ~600 MB of Java).
How to invoke
User-facing (Claude Code slash-command form)
End users trigger the skill through the standard slash-command. The arguments after the slash are interpreted as one or more paths (APK files, XAPK files, or directories), optionally followed by any of the Python CLI flags listed below:
/apk-clues-extract /Volumes/2TB_ExFAT/__BLUUID_APKS/0002 \
/Volumes/2TB_ExFAT/__BLUUID_APKS/0003 \
"/Users/user/Downloads/DexcomStelo/Stelo by Dexcom_2.1.0.2972_APKPure.xapk"
Slash-arg pass-through. The harness gives the agent the entire free-form argument string, so any flag of extract_clues.py (--parallel, --output, --session-output, --no-resume, --submitter, …) is valid directly on the slash command. The agent appends them verbatim to the Python invocation:
/apk-clues-extract /Volumes/2TB_ExFAT/__BLUUID_APKS/0002 --parallel 4 --output /tmp/clues.json --session-output /tmp/just-the-new-ones.json
Append behavior (important). With no flag changes, the skill merges into the existing --output file — it does NOT start fresh. New UUIDs append; UUIDs already in the file gain new android_info_array / evidence_array entries from this run. The checkpoint sidecar (<output>.processed.json) skips APKs whose SHA-1 is already recorded.
To start fresh: either point --output at a new path, delete the file beforehand, or pass --no-resume (which ignores the checkpoint but still merges into the existing JSON — usually not what you want for "start fresh").
Capturing just-this-run changes. When you want a separate file listing only the UUIDs that this invocation added to the corpus (so you can review or share what one APK / one folder contributed without diffing the whole file by hand), pass --session-output:
/apk-clues-extract /Volumes/2TB_ExFAT/__BLUUID_APKS/0004 \
--output CLUES_data_LLM_Android_APK_search.json \
--session-output /tmp/run-0004-new-uuids.json
After the run, /tmp/run-0004-new-uuids.json contains only records whose UUID was not present in the --output file at the start of the run. UUIDs that this run merely added new package observations to are NOT included — their full state is in --output, and they pre-date the session.
What the agent runs internally
When the skill is invoked, the agent translates the user's paths into one call to extract_clues.py. The full command-line interface:
python3 /Users/user/.claude/skills/apk-clues-extract/scripts/extract_clues.py \
<path1> [<path2> ...] \
[--output CLUES_data_LLM_Android_APK_search.json] \
[--session-output FILE.json] # write only UUIDs not present in --output at start of run
[--semantic-upgrades-output FILE.json] # write Unknown->named / Unknown->company upgrades
[--submitter "Claude (Opus 4.7)"] \
[--parallel N] # default 1 (serial). N=4 is a good fit for an 8-core box.
[--progress-interval 100] # checkpoint progress + Unknown-name count every N APKs
[--no-resume] # ignore checkpoint and re-process every APK from scratch
[--search-unknown] # re-process APKs that contributed UUID_name=='Unknown', then run deep Phase-5 RE
[--phase5-deep-re] # run only the deep Phase-5 RE pass on existing Unknown records
[--phase5-deep-re-output FILE.json] # optional mapping report from the deep Phase-5 RE pass
[--keep-tmp] # only for debugging; default is to wipe the tempdir after each file
<pathN> can be any mix of APK files, XAPK files, and directories — directories are walked recursively. Duplicates (same APK reached via multiple paths) are skipped via the checkpoint.
Worked example — the agent's bash translation of the slash-command above:
python3 /Users/user/.claude/skills/apk-clues-extract/scripts/extract_clues.py \
/Volumes/2TB_ExFAT/__BLUUID_APKS/0002 \
/Volumes/2TB_ExFAT/__BLUUID_APKS/0003 \
"/Users/user/Downloads/DexcomStelo/Stelo by Dexcom_2.1.0.2972_APKPure.xapk" \
--output /Users/user/Blue2thprinting/Analysis/CLUES_Schema/data/CLUES_data_LLM_Android_APK_search.json \
--parallel 4
Equivalent serial form (each call accumulates into the same output file via the merge logic — useful when inputs arrive incrementally, e.g. the user pulls one APK at a time from a phone):
OUT=data/CLUES_data_LLM_Android_APK_search.json # path is relative to CLUES_Schema/
python3 .../extract_clues.py /Volumes/2TB_ExFAT/__BLUUID_APKS/0002 --output "$OUT" --parallel 4
python3 .../extract_clues.py /Volumes/2TB_ExFAT/__BLUUID_APKS/0003 --output "$OUT" --parallel 4
python3 .../extract_clues.py "/Users/user/Downloads/DexcomStelo/Stelo by Dexcom_2.1.0.2972_APKPure.xapk" --output "$OUT"
The single-invocation form is preferred when the inputs are known upfront — the parallel pool stays warm across all paths. Both produce identical output because of the checkpoint + merge.
Default behavior: all six phases + a final post-flight sort run. After Phase 1, the agent automatically runs Phase 2 (Play Store company enrichment), Phase 3 (re-download URL evidence), Phase 4 (shared-SDK identification), Phase 5 (re-investigation of UUIDs still named "Unknown" in this session), Phase 6 (extract lessons learned from Phases 2–5 and patch extract_clues.py so the next first-pass benefits), and a final post-flight invocation of CLUES_Schema/scripts/SortCLUES.py that re-sorts every data file in CLUES_Schema/data/ into the project's canonical (company, UUID_purpose, UUID) order so git diffs stay reviewable. These phases are agent-driven loops, not separate slash commands. The user gets a fully-resolved CLUES file and a smarter Phase-1 extractor from one invocation rather than having to remember 7 separate commands.
To opt out of one or more phases, pass any of these flags on the slash command and the agent will skip them:
--phase1-only— stop after Phase 1 (raw, offline extraction; also skips the post-flight sort)--no-phase-2— skip Play Store company enrichment--no-phase-3— skip URL evidence--no-phase-4— skip shared-SDK cluster identification--no-phase-5— skip re-investigation of Unknown UUID names--no-phase-6— skip self-improvement (do not patchextract_clues.py)--no-sort— skip the post-flightSortCLUES.pyre-sort (use when sibling data files have uncommitted hand-edits the user wants preserved)
Reasons to opt out:
- Reproducibility-sensitive runs: Phase 1 is the only deterministic phase (network-independent). If you need a result that's bit-stable across re-runs, pass
--phase1-only. - 1000+ APK corpora: the Phase-4 loop is per-cluster O(decompile + WebSearch); 50 clusters can add 1-3 hours. If you'll do that curation later, pass
--no-phase-4. - Read-only audit: if the user is reviewing the skill without authorizing source edits, pass
--no-phase-6soextract_clues.pyis untouched. - Offline environment:
--no-phase-2 --no-phase-3 --no-phase-4 --no-phase-5 --no-phase-6is equivalent to--phase1-only.
The agent's flag-handling: it parses the slash-command args, peels off --phase1-only / --no-phase-N, passes the remaining args to extract_clues.py, then decides phase-by-phase whether to invoke the helpers below.
Parallelism (--parallel N). Each worker runs the per-APK pipeline (aapt + jadx + scan + classify) in its own process. The main thread serializes merge + output writes. jadx is internally multi-threaded (9 threads by default), so the practical ceiling is min(cpu_count() // 2, len(targets)) — beyond that you'll thrash. On a 1,000-APK corpus this brings wall time from ~10h down to ~2.5h on an 8-core machine.
Resumability (checkpoint). A sidecar file <output>.processed.json records the SHA-1 + status of every APK the driver has seen. Re-running the same command will skip APKs already recorded with status: ok:*. To force a full re-scan, pass --no-resume. The checkpoint also survives Ctrl-C: at most the in-progress APKs are lost. SHA-1 is over the first 4 MiB of the APK + its size — fast and sufficient to detect a republished build.
Targeted re-extraction + deep Phase-5 RE (--search-unknown). Re-runs Phase 1 against only the APKs that originally yielded records with UUID_name: "Unknown" (or empty/missing) in the current --output, then runs the deep Phase-5 reverse-engineering pass over those same Unknown UUIDs. The target set is derived from each Unknown record's android_info_array[].package_path field, deduped to unique APK paths. For each derived APK, the checkpoint's status: ok:* is ignored (so the APK is re-processed even though it's already in the sidecar), but the rest of the checkpoint is preserved untouched — non-target APKs aren't re-scanned and aren't dropped from the sidecar. This is the right tool when Phase 1's classifier has improved or when the remaining names need behavioral recovery from decompiled code. Positional path args act as a filter when given — only Unknown-record APKs whose absolute path matches or sits under one of those paths are processed. Paths that no longer exist on disk are skipped with a warning. The flag makes the positional path argument optional. Example:
# retry every Unknown-yielding APK in the corpus
python3 .../extract_clues.py --search-unknown --output data/CLUES_data_LLM_Android_APK_search.json --parallel 4
# retry only Unknown-yielding APKs that live under one specific folder
python3 .../extract_clues.py /Volumes/2TB_ExFAT/__BLUUID_APKS/0998 --search-unknown --output data/CLUES_data_LLM_Android_APK_search.json --parallel 4
Idempotent + convergent: each invocation re-derives the Unknown set from the current --output, so successive runs queue progressively fewer APKs as names get resolved. In --search-unknown mode, --session-output can legitimately contain zero records because no UUIDs were newly discovered; use --semantic-upgrades-output /tmp/upgrades.json when you want the audit artifact that matters for this mode: existing records whose UUID_name or company improved. If --search-unknown and --session-output are both set but --semantic-upgrades-output is omitted, the extractor automatically writes a sibling *_semantic_upgrades.json report. Long runs emit built-in checkpoint progress every --progress-interval APKs (default 100), including the remaining UUID_name: "Unknown" count.
The deep Phase-5 portion of --search-unknown decompiles each target APK with jadx --show-bad-code in addition to the normal flags, because JADX often hides the callback/listener body that contains the useful BLE dataflow unless that flag is enabled. It then performs a targeted local reverse-engineering pass:
- Captures loose UUID assignments, including instance fields such as
this.avion_char_out_uuid = "...", not onlyUUID.fromString(...). - Traces obfuscated UUID constants through service-discovery comparisons into
BluetoothGattCharacteristicalias fields. - Classifies service UUIDs from
BluetoothGattService.getUuid().compareTo(...)andgetService(...), correcting Phase-1 file-level defaults when needed. - Reads alias behavior: write helpers,
writeCharacteristic,readCharacteristic,setCharacteristicNotification, descriptor notification enables, andonCharacteristicChanged. - Uses nearby UI/log/action strings (
BluConsole,EXTRAS_CONSOLE_TEXT, send-button command paths, proxy PDU flow) to synthesize conservative names such asARUBA_BLUCONSOLE_INPUT_CHARACTERISTICor to preserve exact recovered field names such asavion_char_out_uuid.
Use --phase5-deep-re when you want only this deep Phase-5 pass against the current --output without re-running normal extraction first. It uses the same Unknown-record target derivation and accepts the same optional positional path filter:
python3 .../extract_clues.py /tmp/two-apks \
--output data/CLUES_data_LLM_Android_APK_search.json \
--phase5-deep-re \
--phase5-deep-re-output /tmp/deep_phase5_mapping.json \
--semantic-upgrades-output /tmp/deep_phase5_upgrades.json
- The default
--outputisdata/CLUES_data_LLM_Android_APK_search.json(relative to whatever directory the script is run from — typicallyCLUES_Schema/, which is what the agent always passes explicitly). Pre-existing entries in that file are loaded at start so re-running on more APKs accumulates results. --submitterdefaults to the active LLM model name (Claude (Opus 4.7)for this skill). Override only if running under a different model.--keep-tmpretains the per-APK jadx output (under$TMPDIR/apk_clues_<pid>_<sha>/) for manual inspection. Off by default — production runs should delete to save disk.
Output format
The output JSON conforms to CLUES_schema.json and is a flat top-level array of records. A typical record looks like:
{
"UUID": "f8083535-849e-531c-c594-30f1f86a4ea5",
"company": "Unknown",
"UUID_name": "Unknown",
"UUID_purpose": "Custom GATT Service observed in Android app com.dexcom.stelo (Stelo by Dexcom).",
"UUID_usage_array": [ "GATT Service" ],
"evidence_array": [
{
"description": "Found in decompiled DEX of com.dexcom.stelo v2.1.0.2972 (XAPK 'Stelo by Dexcom_2.1.0.2972_APKPure.xapk'). Referenced in BluetoothGattService construction.",
"submitter": "Claude (Opus 4.7)"
}
],
"android_info_array": [
{
"package_id": "com.dexcom.stelo",
"version_code": 2972,
"version_name": "2.1.0.2972",
"package_path": "/Users/user/Downloads/DexcomStelo/Stelo by Dexcom_2.1.0.2972_APKPure.xapk",
"description": "Detected by apk-clues-extract scan; usage 'GATT Service'."
}
]
}
Characteristics carry parent_UUID when the parent service was identifiable in the same Java file:
{
"UUID": "f8083536-849e-531c-c594-30f1f86a4ea5",
"company": "Unknown",
"UUID_name": "Unknown",
"UUID_purpose": "GATT Characteristic under f8083535-849e-531c-c594-30f1f86a4ea5 in com.dexcom.stelo.",
"UUID_usage_array": [ "GATT Characteristic" ],
"parent_UUID": "f8083535-849e-531c-c594-30f1f86a4ea5",
"evidence_array": [ ... ],
"android_info_array": [ ... ]
}
Because the extractor cannot infer the marketing/legal company name from an APK alone (only the package_id), every record starts with company: "Unknown" and UUID_name: "Unknown". A human reviewer (or a separate LLM pass) is expected to fill those in later by cross-referencing the package_id against the developer's public docs / store listing — that step is intentionally out of scope.
Storage layout — single file or 16 hex-bucket shards
Once data/CLUES_data_LLM_Android_APK_search.json grows past ~100 MB it stops being a useful single file: git diffs become unreviewable, editors hang loading it, and GitHub rejects pushes that contain it (100 MB hard cap, no LFS). The skill solves this by sharding the output across 16 files named after the first hex character of each record's UUID:
data/CLUES_data_LLM_Android_APK_search_0.json # UUIDs starting with "0"
data/CLUES_data_LLM_Android_APK_search_1.json # UUIDs starting with "1"
…
data/CLUES_data_LLM_Android_APK_search_f.json # UUIDs starting with "f"
The split is storage only — the global (company, UUID_purpose, UUID) sort order is preserved across the combined dataset; shards just hold contiguous slices of that order. Whether a given file is sharded or single-file is decided exclusively by CLUES_Schema/scripts/SortCLUES.py (via its SPLIT_FILES allowlist). Every script in this skill (extract_clues.py, all Phase 2–6 helpers) calls clues_io.load_clues(path) / clues_io.save_clues(data, path) — those helpers transparently read either form and preserve whatever layout they find on disk when writing back. No script in this skill ever decides to split or un-split on its own.
Implications:
- For the agent. Always pass the unsplit base path (
data/CLUES_data_LLM_Android_APK_search.json) to every CLI in this skill, regardless of which form is actually on disk. The loaders DTRT. Never hand-edit individual shards — use the helper scripts or write your edits throughsave_clues, otherwise the next post-flight sort will reorder records across shard boundaries and your hand-edits may end up in a different shard than expected. - For schema validation.
check-jsonschemadoesn't transparently combine shards. Glob the file pattern when the output is split:data/CLUES_data_LLM_Android_APK_search_*.json(see the Validation section below). The pattern works for either form because globs match the single-file basename too when no shards exist. - For child characteristics whose parent service has a UUID starting with a different hex letter. The child still carries
parent_UUID, but the parent's record lives in a different shard. This is intentional — the alternative (moving children to follow parents) would defeat the by-UUID bucketing and makeload_cluesnon-trivial. Consumers that want parent/child pairing should followparent_UUIDafter loading the combined dataset. - For Phase 6 self-improvement.
KNOWN_NON_BLE_UUIDSandKNOWN_SDK_JAVA_PACKAGESupdates live inscripts/extract_clues.py, not in the data files, so the split doesn't affect Phase 6 at all.
Phase 2 — web-enrichment of "(inferred from package id)" records
The static extractor (Phase 1) produces the strongest company value it can without a network. When neither KNOWN_VENDOR_PACKAGES nor a member_uuids.yaml overlap fires, the field falls back to a capitalized package component plus the marker (inferred from package id). In-APK strings are almost never enough to identify the publisher — they describe the product or the device family, not the developer account. Example: com.tntkhang.gtswatchface. The APK contains zero strings mentioning the publisher; the closest in-APK hint is amazfitwf-46c6d.firebaseio.com (just says "Amazfit watch face" — the product). Only a web search of the package id reliably resolves the publisher (it returns Play Store / AppBrain / APKPure listings consistently crediting "SmartWatchCenter").
Phase 2 is a Claude Code agent loop that automates this lookup. The two helper scripts in scripts/ make it a 3-step recipe — any agent with WebSearch plus the standard read/write/bash tools can run it without further code:
Step 2.1 — list which packages still need enrichment
python3 /Users/user/.claude/skills/apk-clues-extract/scripts/list_inferred_companies.py \
/path/to/CLUES_data_LLM_Android_APK_search.json
Stdout is a JSON document with two keys: needs_enrichment (a list of {package_id, current_company, record_count} for packages still tagged (inferred from package id)) and already_resolved (a list of package_ids that already have a curated company). Records the agent should focus on are the ones in needs_enrichment, sorted by record_count so high-impact packages go first.
Step 2.2 — bulk-scrape the Play Store first
For most public apps, the publisher is in plain HTML on play.google.com/store/apps/details?id=<package>. There's a helper that scrapes that page (<meta itemprop="author"> / JSON-LD "author":{...,"name":"..."} / /store/apps/dev?id=... patterns) for many package_ids in one shot:
python3 /Users/user/.claude/skills/apk-clues-extract/scripts/list_inferred_companies.py CLUES.json \
| jq -r '.needs_enrichment[].package_id' \
| python3 /Users/user/.claude/skills/apk-clues-extract/scripts/scrape_play_store_companies.py \
> /tmp/scrape_mapping.json
Stdout is a flat {package_id: company_name} object — feed it straight to apply_company_enrichment.py (Step 2.3). Stderr reports per-package status (ok / unreachable / not_listed / no_author_field).
For packages the scraper couldn't resolve (typically: removed from Play Store, region-locked, never published publicly), fall back to a free-form WebSearch per the next paragraph.
Step 2.2b — web-search any package_ids the scraper couldn't resolve
For each package_id in needs_enrichment, the agent should call WebSearch with a query like:
<package_id> developer publisher android app
and read the top 5–10 results. Reliable signals, in order of preference:
- Play Store listing (
play.google.com/store/apps/details?id=<package_id>) — the "developer" attribution is authoritative. - AppBrain developer page (
appbrain.com/dev/<DeveloperName>/) — usually titles itself with the dev account name. - APKPure / APKCombo / APKamp — explicit "App by " lines in the page body.
- Microsoft 365 / Capterra / LinkedIn — useful when the publisher rebranded (e.g. "Witco de MONBUILDING &CO" on Microsoft 365 disambiguates the rebrand history).
Special cases the agent should handle:
- Rebrand history. If the search results show a name change (
MonBuilding → Witco,St. Jude Medical → Abbott, etc.), record the current legal name and the SIG-style"<Current> (formerly <Old>)"form so the value lines up with how Bluetooth SIG names absorbed members. Cross-check by also searching the old name to confirm it's the same legal entity. - White-label / customer-specific packages.
com.monbuilding.app.legendeis a white-labeled instance of the Witco platform for the Legende building — the BLE UUIDs belong to Witco, not to "Legende". Use the platform publisher, not the customer name.branded.com.<X>is another common white-label pattern —<X>is the customer, and the publisher you want is the white-label-platform vendor (search the white-label vendor's site for a customer list to confirm). - Ambiguous results. If two reasonable answers exist (e.g., the SDK vendor vs. the app publisher), prefer the app publisher — the UUIDs as-shipped in this APK are tied to the publisher's identity, not the SDK's.
- No clear answer. Leave the package out of the mapping — don't guess. Records stay as
(inferred from package id)for a future curator.
The output is a single flat JSON object mapping resolved package_ids to confirmed company names, e.g.:
{
"com.foo.bar": "Foo Corp",
"com.legacy.app": "NewName (formerly OldName)",
"branded.com.acme": "Acme Whitelabel Platforms"
}
Save it to disk (any path; /tmp/enrichment.json is fine).
Company-field rule. Each value must be the bare company name — nothing else. Do NOT append country, product line, app name, what BLE is used for, acquisition history, or any other context inside the value. Bad:
"Canon Inc. (Japan) — Camera Connect app; BLE for EOS / PowerShot cameras". Good:"Canon Inc.". Bad:"Anki (now Digital Dream Labs)". Good:"Anki"(use the historically-attributed name; acquisition notes go elsewhere). The lone exceptions are: (a) the(formerly OldName)rebrand suffix, which SIG-style member entries also use, and (b) acronyms that are part of the formal name like(IBV).apply_company_enrichment.pyruns the value throughcompany_sanitizer.sanitize_for_writeand will auto-strip any other parenthetical / em-dash commentary — appending the stripped text to the record'sevidence_arrayand emitting a stderr[sanitize]warning — so the data ends up clean either way. The warning means "put it in evidence next time, not in the company field".
Step 2.3 — apply the mapping
python3 /Users/user/.claude/skills/apk-clues-extract/scripts/apply_company_enrichment.py \
/path/to/CLUES_data_LLM_Android_APK_search.json \
/tmp/enrichment.json
This script:
- Replaces
companyon every record whoseandroid_info_arrayreferences a mapped package — but only when the existing value is the(inferred from package id)placeholder orUnknown. A previously curated value is never overwritten. - Re-sorts the JSON by UUID for stable diffs and writes it back atomically.
- Prints to stdout a suggested
KNOWN_VENDOR_PACKAGESdiff (one"<vendor-key>": "<company>",line per resolved package), keyed by the same first-non-TLD-like component the Phase-1 extractor uses for lookup. The agent should thenEditscripts/extract_clues.pyto paste those lines into the dict — that way every future Phase-1 run hits the table directly and the same package never needs enrichment again. This is the preferred outcome over patching records ad hoc; it's reusable and benefits the entire CLUES community.
After applying, re-validate the schema and you're done:
cd /Users/user/Blue2thprinting/Analysis/CLUES_Schema/
source venv/bin/activate
check-jsonschema --base-uri ./CLUES_schema.json \
--schemafile ./CLUES_schema.json ./data/CLUES_data_LLM_Android_APK_search.json
Opt-out conditions (Phase 2)
Phase 2 runs by default. Skip it via --no-phase-2 (or --phase1-only) when:
- The user only wants raw extraction (e.g., they're running on a corpus of 1,000+ APKs and will curate companies later). Phase 2 is per-package O(1 web search), which scales linearly with distinct unknown packages — fine for tens, costly for hundreds.
- The CLUES file is going to be reviewed by a human before merge — let the human do the lookups so the citations they leave in commit messages are theirs, not the agent's.
- Some
(inferred from package id)entries are intentional placeholders pending more APK samples; the user should flag those explicitly so the enrichment loop skips them.
Phase 3 — add a re-download URL to every record's evidence_array
A CLUES record without a re-download URL is hard to reproduce: another researcher who wants to validate a UUID has no way to grab the exact same APK build. Phase 3 closes that gap by finding an apkpure / apkcombo / archive.org / Play Store URL for each (package_id, version_name) pair in the output and adding it as a CLUES evidence_array item. Every URL is CLI-verified before commit, so the JSON never ships a dead link.
Three helpers in scripts/ make this a 3-step loop, parallel in structure to Phase 2:
Step 3.1 — list which APK versions still need a URL
python3 /Users/user/.claude/skills/apk-clues-extract/scripts/list_apk_versions.py \
/path/to/CLUES_data_LLM_Android_APK_search.json
Stdout is a JSON document with needs_url (list of {package_id, version_name, version_code, record_count, company, local_path}, sorted by record_count so high-impact APKs go first) and already_has_url (list of "<pkg>@<ver>" keys that already carry an APK-cache URL in their evidence_array). The Phase-3 agent should only work on entries in needs_url.
Step 3.2 — find a URL per APK version and CLI-verify
For each (package_id, version_name), the agent should:
- Search the web with a query like:
<package_id> <version_name> apkpure OR apkcombo
…(truncated)