Static mobile inspection
Extract a test-authoring inventory — an element list and a screen map — directly from an uploaded APK/IPA, with no live device, in seconds. A build artifact is just a ZIP, and for native apps the selectors you author against are recoverable statically. This is the mobile analog of the web crawl digest and the API digest.
It runs synchronously in the backend (pure-Java ZIP + byte scan — no Appium, no BrowserStack device, no extra tooling), so it's cheap enough to run on every uploaded build.
When to use it
| Use it to… | Instead of… |
|---|---|
| Ground selectors before authoring a native mobile test | Guessing resource-id / accessibility-id values |
| Get the screen (activity/view-controller) map up front | Booting a device just to see what screens exist |
| Decide whether a live device crawl is even needed | Always paying for a device crawl |
It is not a replacement for a live crawl — it's the cheap first pass that scopes one. See Static + live crawl together.
Framework support (be honest about what works)
Static inspection is framework-gated — it only claims results where they're reliable. Branch on
the top-level staticallyInspectable flag.
Static inspection reports each layer's confidence separately — a framework can be inspectable for
screens but not elements. They're two different signals: elements = testIDs / resource-ids
(from the string/resource table); screens = component/class names (*Activity, *ViewController,
React Navigation *Screen/*Page/*Navigator).
| Framework | staticallyInspectable | Screens | Elements | Why |
|---|---|---|---|---|
| Native Android | ✅ true | high | candidate | dex/resources store strings delimited — recovers resource-ids + activities |
| Native iOS | ✅ true | high | candidate | Mach-O __cstring is null-delimited — recovers accessibility ids + view controllers |
| React Native — plain-JS | ✅ true | candidate | none | screen class names (*Screen/*Page/*Navigator) are recoverable from the JS bundle; testIDs are indistinguishable from RN/npm library strings, so elements stay none — crawl for them |
| React Native — Hermes | ❌ false | none | none | Hermes bytecode packs the string table with no delimiters (a name like StorePage merges into its neighbours), so even screen names degrade to noise |
| Flutter | ❌ false | none | none | UI is AOT-compiled into a shared library — no recoverable widget/id strings |
The tool detects bundleType (PLAIN_JS / HERMES / NATIVE), so RN gates on it automatically.
When staticallyInspectable is false, the response returns empty elements/screens plus a
summary.guidance string pointing you at the live device inspection — it never invents selectors.
Validated against the real Sauce Labs
my-demo-app-rnbuild. Its plain-JS iOS bundle yields a clean 12-screen map (StorePage,ProductPage,Cart,Checkout,AboutPage,QrCodePage,WebviewPage, … + the app's*StackNavigators) atcandidateconfidence — the framework's*Screen/*Page/*Navigatorclass names survive, and generic React Navigation library names (StackNavigator,DrawerNavigator,BottomTabNavigator) are deny-listed out. But a raw string scan merges testIDs into their neighbours and an APK∩IPA intersection returns only library noise — so elements are punted to the live crawler rather than emitting garbage. The Android build of the same app ships Hermes, where even the screen names degrade into their neighbours — which is exactly the plain-JS-vs-Hermes split in the table above.
Do I still need a device crawl? — the recommendation block
Every response carries a derived top-level recommendation so you branch on one field instead of
inferring from three. It's a pure function of staticallyInspectable + what each layer yielded
(recomputed on every read, so it can't drift).
"recommendation": {
"needsLiveCrawl": "recommended", // "required" | "recommended"
"reason": "screen map is high-confidence; element ids are candidates and per-screen edges are unmapped",
"coverage": { "screens": "high", "elements": "candidate", "edges": "none" }
}
needsLiveCrawl | When | What to do |
|---|---|---|
"required" | Hermes RN / Flutter (staticallyInspectable=false) | Static gave you nothing — run a live device inspection. |
"recommended" | Native, and plain-JS RN | Trust the screens map; crawl to confirm element candidates (native) or supply the tappable elements (RN), and map per-screen edges. |
coverage reports per-layer confidence (high | candidate | none) so you know exactly what
static covered and what a crawl must fill.
API reference
| Method | Path | Purpose |
|---|---|---|
POST | /api/mobile-apps/{id}/upload | Upload the APK/IPA (multipart file, max 500 MB) |
POST | /api/mobile-apps/{id}/inspect/static?level=&environmentId= | Run static inspection (synchronous), returns the digest + records a scan_job |
GET | /api/mobile-apps/{id}/inspection-digest?level= | Re-read the last stored digest (no re-scan); 404 if never inspected |
level: minimal (ids + screen names) or standard (adds element type + source).
Roles: POST requires ADMIN/ANALYST; the GET also allows VIEWER.
# 1. Upload a build once
curl -sf -X POST "$CSS_URL/api/mobile-apps/7/upload" \
-H "Authorization: Bearer $CSS_TOKEN" -F "file=@my-native-app.apk"
# 2. Statically inspect
curl -s -X POST -H "Authorization: Bearer $CSS_TOKEN" \
"$CSS_URL/api/mobile-apps/7/inspect/static?level=standard" | jq
# 3. Re-read the cached digest later
curl -s -H "Authorization: Bearer $CSS_TOKEN" \
"$CSS_URL/api/mobile-apps/7/inspection-digest?level=minimal" | jq
MCP
inspect_mobile_app_static(mobile_app="My Native App", level="standard")
get_mobile_inspection_digest(mobile_app="My Native App")
Both accept a name or id for mobile_app. Branch on recommendation.needsLiveCrawl /
staticallyInspectable in the returned digest.
Response schema
| Field | Type | Notes |
|---|---|---|
app, platform, framework | string | framework ∈ NATIVE_ANDROID | NATIVE_IOS | REACT_NATIVE | FLUTTER | UNKNOWN |
bundleType | string | NATIVE | HERMES | PLAIN_JS | UNKNOWN |
staticallyInspectable | boolean | Can this framework be inspected statically at all? |
contentHash | string | Dedup / change key over screens + elements |
level | string | minimal | standard |
screens[] | array | {name, kind, order} — reliable layer (native) |
elements[] | array | {id, type?, source?} — candidate layer (native); empty when not inspectable |
recommendation | object | {needsLiveCrawl, reason, coverage} — the decision signal |
summary | object | {confidence, elementCount, screenCount, layers[], guidance?, ...} |
Native (statically-inspectable) response:
{
"app": "My Native App",
"platform": "ANDROID",
"framework": "NATIVE_ANDROID",
"bundleType": "NATIVE",
"staticallyInspectable": true,
"contentHash": "a1b2c3d4",
"level": "standard",
"screens": [
{ "name": "LoginActivity", "kind": "SCREEN", "order": 0 },
{ "name": "CartActivity", "kind": "SCREEN", "order": 1 }
],
"elements": [
{ "id": "login_button", "type": "button", "source": "binary" },
{ "id": "username_field", "type": "field", "source": "binary" }
],
"recommendation": {
"needsLiveCrawl": "recommended",
"reason": "screen map is high-confidence; element ids are candidates and per-screen edges are unmapped",
"coverage": { "screens": "high", "elements": "candidate", "edges": "none" }
},
"summary": {
"confidence": "preview",
"elementCount": 2, "screenCount": 2,
"layers": [
{ "layer": "screens", "source": "dex/manifest class names", "confidence": "high", "count": 2 },
{ "layer": "elements", "source": "resources.arsc", "confidence": "candidate", "count": 2 }
]
}
}
React Native, plain-JS bundle (screens recovered, elements punted):
{
"app": "My Demo App RN",
"framework": "REACT_NATIVE",
"bundleType": "PLAIN_JS",
"staticallyInspectable": true,
"screens": [
{ "name": "StorePage", "kind": "SCREEN", "order": 0 },
{ "name": "ProductPage", "kind": "SCREEN", "order": 1 },
{ "name": "AboutPage", "kind": "SCREEN", "order": 2 },
{ "name": "MainTabNavigator", "kind": "NAVIGATOR", "order": 3 },
{ "name": "CartStackNavigator", "kind": "NAVIGATOR", "order": 4 }
],
"elements": [],
"recommendation": {
"needsLiveCrawl": "recommended",
"reason": "screen map is candidate-confidence; testIDs aren't recoverable and per-screen edges are unmapped — crawl to author against real, tappable elements",
"coverage": { "screens": "candidate", "elements": "none", "edges": "none" }
}
}
12 screens returned for the real
my-demo-app-rniOS build (8SCREEN+ 4 appNAVIGATOR), with the generic React Navigation library navigators deny-listed out. Use this map to plan coverage and seed the live crawl; the crawl supplies the tappable elements to author against.
React Native, Hermes bundle (not statically inspectable):
{
"app": "Some Hermes RN App",
"framework": "REACT_NATIVE",
"bundleType": "HERMES",
"staticallyInspectable": false,
"recommendation": {
"needsLiveCrawl": "required",
"reason": "REACT_NATIVE can't be inspected statically — run a live device inspection",
"coverage": { "screens": "none", "elements": "none", "edges": "none" }
},
"summary": {
"guidance": "Static inspection is not reliable for REACT_NATIVE: the JS is Hermes bytecode whose string table packs entries with no delimiters … Run a live device inspection (POST /api/mobile-apps/{id}/inspect)."
}
}
How extraction works — layered, source-scoped techniques
Rather than one matcher, each layer runs a technique precise for its source and is tagged with its
own confidence (reported in summary.layers). Different sources need different techniques — the
patterns are not the same across platforms:
| Layer | Source (Android) | Source (iOS) | Confidence |
|---|---|---|---|
| Screens | *Activity / *Fragment class names in the dex + manifest | *ViewController names in the Mach-O | high |
| Element candidates | resource names in resources.arsc (snake/kebab) | accessibility ids in Mach-O __cstring (camelCase) | candidate (low) |
Framework/library noise is deny-listed at both layers (AppCompatActivity, DialogFragment, the
abc_/material_/AndroidX resource namespaces, HTTP headers, style props, regex fragments). Element
candidates are deliberately not scanned from the dex — that surface is full of method names.
Execution record & provenance
Every run — like the web crawl and the live device inspection — creates a
scan_job execution record you can open at /scans/{jobId}. So "was an extraction run, from which
build, for which env, and did it use static or a crawl?" is answerable from the record, not guesswork:
jobId— the/scans/{jobId}record (the digest JSON is attached to it).source—"static"|"live-crawl".environment— the env the run was triggered for (bound at trigger viaenvironmentId; the build itself is env-agnostic, so you pick the env per run — a config can point at different envs).appVersion— the build version the locators came from (stored as the job'stag).
The RN/Flutter "not inspectable → crawl instead" verdict also creates a record, so the decision is audited.
Where results are stored
elements[]→mobile_apps.inspected_elements(also readable viaGET /api/mobile-apps/{id}/elements).screens[]→mobile_inspection_results(screen_name,screen_order; per-screenelementsis empty in this pass — see edges below).staticallyInspectable,confidence,layers,contentHash,guidance→mobile_apps.inspection_summary.recommendationis derived, not stored — recomputed each time the digest is built or read.
Static + live crawl: the two-pass model
Static inspection and the live device inspection are complementary, not either/or:
- Static (cheap, seconds, no device) → screen map + a first cut of element candidates, and a
recommendationtelling you whether/where to crawl. - Live crawl (device) → confirms which candidates are real, visible, tappable elements; resolves per-screen edges; discovers dynamic/server-driven screens static can't see.
- Reconcile → screens the crawl found that static didn't are exactly the dynamic/unreachable surface — a useful completeness signal.
Use the static screen map to seed and scope the crawl instead of exploring blindly.
Limits & roadmap
previewconfidence even for native — the current pass is a delimited-string scan. Format-aware parsing (resources.arsctype=id/ AXML for Android,.storyboardcscenes for iOS) will promote element candidates abovecandidateand add per-screen edges.- Per-screen edges (which element is on which screen) are unassigned today — run a live crawl for precise per-screen mapping.
- React Native / Flutter aren't statically inspectable (Hermes packing / AOT). A proper Hermes string-table parser would be required to change that; until then, use the live device inspection.