Skip to main content

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 testGuessing resource-id / accessibility-id values
Get the screen (activity/view-controller) map up frontBooting a device just to see what screens exist
Decide whether a live device crawl is even neededAlways 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).

FrameworkstaticallyInspectableScreensElementsWhy
Native Androidtruehighcandidatedex/resources store strings delimited — recovers resource-ids + activities
Native iOStruehighcandidateMach-O __cstring is null-delimited — recovers accessibility ids + view controllers
React Native — plain-JStruecandidatenonescreen 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 — HermesfalsenonenoneHermes bytecode packs the string table with no delimiters (a name like StorePage merges into its neighbours), so even screen names degrade to noise
FlutterfalsenonenoneUI 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-rn build. Its plain-JS iOS bundle yields a clean 12-screen map (StorePage, ProductPage, Cart, Checkout, AboutPage, QrCodePage, WebviewPage, … + the app's *StackNavigators) at candidate confidence — the framework's *Screen/*Page/*Navigator class 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" }
}
needsLiveCrawlWhenWhat to do
"required"Hermes RN / Flutter (staticallyInspectable=false)Static gave you nothing — run a live device inspection.
"recommended"Native, and plain-JS RNTrust 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

MethodPathPurpose
POST/api/mobile-apps/{id}/uploadUpload 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

FieldTypeNotes
app, platform, frameworkstringframeworkNATIVE_ANDROID | NATIVE_IOS | REACT_NATIVE | FLUTTER | UNKNOWN
bundleTypestringNATIVE | HERMES | PLAIN_JS | UNKNOWN
staticallyInspectablebooleanCan this framework be inspected statically at all?
contentHashstringDedup / change key over screens + elements
levelstringminimal | standard
screens[]array{name, kind, order} — reliable layer (native)
elements[]array{id, type?, source?} — candidate layer (native); empty when not inspectable
recommendationobject{needsLiveCrawl, reason, coverage} — the decision signal
summaryobject{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-rn iOS build (8 SCREEN + 4 app NAVIGATOR), 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:

LayerSource (Android)Source (iOS)Confidence
Screens*Activity / *Fragment class names in the dex + manifest*ViewController names in the Mach-Ohigh
Element candidatesresource 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 via environmentId; 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's tag).

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 via GET /api/mobile-apps/{id}/elements).
  • screens[]mobile_inspection_results (screen_name, screen_order; per-screen elements is empty in this pass — see edges below).
  • staticallyInspectable, confidence, layers, contentHash, guidancemobile_apps.inspection_summary.
  • recommendation is 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:

  1. Static (cheap, seconds, no device) → screen map + a first cut of element candidates, and a recommendation telling you whether/where to crawl.
  2. Live crawl (device) → confirms which candidates are real, visible, tappable elements; resolves per-screen edges; discovers dynamic/server-driven screens static can't see.
  3. 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

  • preview confidence even for native — the current pass is a delimited-string scan. Format-aware parsing (resources.arsc type=id / AXML for Android, .storyboardc scenes for iOS) will promote element candidates above candidate and 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.