Real-device test process (example app)
Both example builds compile llama.rn's C++ from source (RNLLAMA_BUILD_FROM_SOURCE=1
in example/ios/Podfile, rnllamaBuildFromSource=true in
example/android/gradle.properties), so changes under cpp/ and vendor/ are exercised for
real. First builds are long (iOS ~5-10 min, Android ~20 min including the
Hexagon HTP Docker build); incremental rebuilds are much faster. Run long
builds in the background and verify artifacts when they finish.
Shared prerequisite: Metro
The Debug apps load JS from Metro. Start it detached and confirm it's up:
cd example && (yarn start > /tmp/metro.log 2>&1 &)
curl -s http://localhost:8081/status # -> packager-status:running
iOS on Mac (real Metal GPU)
"Designed for iPad" runs the arm64 iphoneos binary natively on Apple
Silicon - no simulator, real Metal GPU. The build itself is standard; the
install goes through an ipa because LaunchServices refuses to open a raw
iphoneos .app ("incorrect executable format") and direct exec dies silently.
1. Find the Mac destination id
cd example/ios
xcodebuild -showdestinations -workspace RNLlamaExample.xcworkspace \
-scheme RNLlamaExample 2>/dev/null | grep platform:macOS
# { platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:<MAC_UDID>, name:My Mac }
Use the id. Do NOT pass the variant string in -destination - xcodebuild
splits destination specs on commas, so Designed for [iPad,iPhone] fails with
exit 64 ("unreadable input 'iPhone]'").
2. Build
cd example/ios
xcodebuild -workspace RNLlamaExample.xcworkspace -scheme RNLlamaExample \
-configuration Debug -destination "platform=macOS,arch=arm64,id=<MAC_UDID>" \
-quiet CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \
GCC_OPTIMIZATION_LEVEL=0 COMPILER_INDEX_STORE_ENABLE=NO build
Judge success by the exit code, not by grepping for "error:" - Obj-C selector
names like stat:path error: in deprecation warnings produce false matches.
The product lands in DerivedData under Build/Products/Debug-iphoneos/; locate
it with:
xcodebuild -showBuildSettings -workspace RNLlamaExample.xcworkspace \
-scheme RNLlamaExample -configuration Debug \
-destination "platform=macOS,arch=arm64,id=<MAC_UDID>" 2>/dev/null \
| grep -m1 ' BUILT_PRODUCTS_DIR'
3. Install via ipa and launch
Every open <ipa> install creates a NEW numbered copy in /Applications
(RNLlamaExample 2.app, 3.app, ...) - it never replaces an existing one,
even when the app is not running. The bundles are root-owned so rm fails;
Finder can trash them without an admin prompt. So: quit the app and trash
stale dev copies first, keeping any non-dev install (e.g. TestFlight) alone:
pkill -x RNLlamaExample 2>/dev/null
osascript -e 'tell application "Finder" to delete POSIX file "/Applications/RNLlamaExample 2.app"' 2>/dev/null
# repeat for any " 3", " 4", ... copies from earlier installs
Then package and install:
cd "$BUILT_PRODUCTS_DIR" # or work in a scratch dir with the .app copied in
rm -rf Payload dev.ipa && mkdir Payload
cp -R RNLlamaExample.app Payload/
zip -qry dev.ipa Payload
open dev.ipa # macOS installs it silently into /Applications
The install is async - poll until the new copy appears, confirm it is this build by binary mtime, and launch it:
stat -f "%Sm %N" /Applications/RNLlamaExample*.app/Wrapper/RNLlamaExample.app/RNLlamaExample
open "/Applications/RNLlamaExample 2.app" # the copy whose mtime matches the build
ps aux | grep "[R]NLlamaExample" # process alive = launched
4. Verify GPU
In the app, download/load a model with GPU layers > 0; llama.rn logs Metal
device initialization on context load (log stream --process RNLlamaExample
shows native logs). The Parallel Decoding screen is the stress surface for
slots, state files, and multimodal.
Android with ggml-hexagon (Snapdragon HTP/NPU)
1. Check device and prerequisites
adb devices -l # device connected?
adb shell getprop ro.soc.model # e.g. SM8750 (Snapdragon 8 Elite) - needs a Snapdragon with HTP
ls ~/.hexagon-sdk/6.4.0.2 # Hexagon SDK (or HEXAGON_SDK_ROOT/HEXAGON_TOOLS_ROOT env)
docker info >/dev/null 2>&1 && echo docker-ok # required on macOS for the HTP toolchain
Gradle auto-detects the SDK: the build log prints
✅ Hexagon SDK detected — enabling DSP build (or 🚫 ... building CPU-only).
Prebuilt HTP DSP libs in bin/arm64-v8a/libggml-htp-v{69,73,75,79,81}.so are
synced into APK assets by the prepareHTP gradle task; when the SDK+Docker are
present they are rebuilt as part of the build.
2. Build
cd example/android && ./gradlew assembleDebug
# APK: example/android/app/build/outputs/apk/debug/app-debug.apk
3. Verify the APK actually contains hexagon
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep -iE "hexagon|htp"
# expect: lib/arm64-v8a/librnllama_v8_2_dotprod_i8mm_hexagon_opencl.so (+ _jni_)
# assets/ggml-hexagon/libggml-htp-v{69,73,75,79,81}.so
If these are missing the build silently fell back to CPU-only - fix the SDK / Docker prerequisites before wasting device time.
4. Install, connect Metro, launch
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb reverse tcp:8081 tcp:8081 # Debug JS loads from the host's Metro
adb shell am start -n com.rnllamaexample/.MainActivity
5. Verify the Hexagon runtime is alive
adb shell pidof com.rnllamaexample
adb logcat -d | grep -iE "fastrpc|hexagon|htp" | tail
Healthy signs: nativeloader loading librnllama_jni_..._hexagon_opencl.so,
fastrpc_apps_user_init done with a DSP domain, libcdsprpc.so loaded, and
the HTP extraction dir /data/user/0/com.rnllamaexample/app_rnllama-htp
referenced. Then load a model in-app to exercise the NPU; watch
adb logcat | grep -i htp during context init and inference.
Standard test pass
Run this sequence once the build is deployed. Steps 1-3 are the smoke baseline for ANY native change; step 4 scopes deeper testing to the diff. Report each step's evidence (a quoted log line or a screenshot), not just "works".
- Deploy fresh. Quit/uninstall the previous instance, install this
build, launch, confirm the process is alive and the JS console shows
Metro-served activity. (Android:
adb logcatshowsReactNativeJS: Running "RNLlamaExample".) - Load a model → accelerator init. Open SimpleChat and initialize a
model (see UI driving below). Any downloaded model is fine; small ones are
fastest. Accelerator evidence differs by platform:
- Android (definitive, in logcat at context init):
n_gpu_layers: 99, devices: [ 'HTP0', 'HTP1' ],ggml-hex: Loading driver libcdsprpc.so,using device HTP0 (Hexagon). Absent = CPU-only fallback; stop and diagnose. - Mac: ggml's Metal logs are stderr, NOT os_log, so
log streamshows nothing and Metro doesn't get them either - the device-init line is only visible in Xcode's console. Use the indirect proof instead: the RN dylib links Metal and exports the backend (below), and generation runs at GPU-consistent speed (step 3). To force the log line, run the app from Xcode (Product > Run) rather than a plain launch.APP="/Applications/RNLlamaExample 2.app/Wrapper/RNLlamaExample.app" otool -L "$APP/RNLlamaExample.debug.dylib" | grep Metal.framework nm -gU "$APP/RNLlamaExample.debug.dylib" | grep -c ggml_backend_metal # > 0
- Android (definitive, in logcat at context init):
- Completion smoke. In SimpleChat send
What is 2+3? One line only.(r-free on Android, see the double-R trap below). Expect a coherent reply that stops on its own (verified good runs: Android/HTP2 + 3 = 5.at ~11 tok/s on a 4B; Mac/MetalThe answer is 5.at ~50 tok/s on a 1B - the speed itself is GPU evidence). Garbage tokens, an empty reply, or a never-ending stream are native-level failures worth bisecting first. - Diff-scoped screens. From the table below, run every row whose area
the change touches. For each, capture the named evidence from the logs.
Two rows deserve extra rigor because their failures are intermittent:
- ParallelDecoding: send the example batch twice; every request must
complete with a non-empty reply both times, and the second pass must
log
cache_n > 0in itsTimings:objects (state/prefix reuse). - Multimodal: ask about the same image twice (expects cache reuse), then a different image with the same text (expects NO stale answer).
- ParallelDecoding: send the example batch twice; every request must
complete with a non-empty reply both times, and the second pass must
log
Driving the UI
Android (fully scriptable)
Never tap raw hardcoded coordinates for labeled controls - layouts shift and a
tap that lands wrong can long-press into an OS context menu or navigate away.
Instead locate elements by their visible text and tap the CENTER. scripts/adb_ui.sh
does this off uiautomator dump (no accessibility grant needed):
source .claude/skills/device-test/scripts/adb_ui.sh
adb logcat -c
ui_tap "Simple Chat" # navigate by button label
ui_tap "Initialize" # load the (already-downloaded) model
ui_wait "Type your message" 90 # block until the chat screen appears
ui_tap "Type your message" # focus the input
ui_type "What is 2+3? One line only." # no r/R: two R keys = RN dev reload
Only genuinely unlabeled controls (the send paper-plane icon) need a coordinate tap - read it off a screenshot rather than guessing:
adb exec-out screencap -p > /tmp/chat.png # then Read the PNG to find the icon
adb shell input tap 1295 1607 # send icon (this device's layout)
Verify results the same way: adb exec-out screencap -p for the reply bubble
(shows the answer and a tok/s chip), adb logcat -d | grep RNLlama for the
Timings/loadPrompt lines. ui_dump prints all on-screen text when you need
to discover an anchor.
Two traps when driving a params modal (TTS / completion / context params):
- Do NOT press Back (
keyevent 4) to dismiss the keyboard inside a modal. RNModaltreats it asonRequestCloseand closes the whole modal, so nothing gets saved. Tap the modal's header "Save" directly - the keyboard does not cover it.keyevent 82(menu) does not open the RN dev menu on Samsung either; it backgrounds the app to the launcher. ui_tapmatches the FIRST element whose text contains the query, in dump order. After the modal has closed,ui_tap "Save"silently hits "Save as WAV" on the TTS screen. Confirm withui_dumpthat the modal header (Cancel/TTS Parameters/Save) is still listed first, and prove a save landed by reading it back from AsyncStorage:adb shell "run-as com.rnllamaexample cat databases/RKStorage" | strings | grep speakerConfig.
ui_type sends the whole string as fast key events. In a Debug build RN's
dev support treats two R key presses within ~200 ms as "reload JS", so any
prompt containing two r/R characters (e.g. "Reply in one short sentence")
reloads the bundle mid-flow, drops the loaded model, and lands on Home. Use
r-free prompts (What is 2+3? One line only.,
Use the calculate tool to compute 12 times 7.) or paste via clipboard.
Also, Metro Fast Refresh does not reliably reach the device after editing
example JS - curl -s "http://localhost:8081/index.bundle?platform=android&dev=true" | grep -c <marker>
proves Metro rebuilt, but the app may still run the old bundle. Force it with
adb shell am force-stop com.rnllamaexample + am start (the model must be
re-initialized afterwards).
iOS on Mac (scriptable via System Events coordinate clicks)
The RN UI renders as one Metal surface, so its AX tree is unlabeled nested
groups - System Events can't find buttons by name. But click at {x,y} and
keystroke DO work (verified), including on a secondary display with negative
coordinates. Read the target coordinate off a screenshot; there is no text
anchoring like Android, so this is coordinate-based and you re-screenshot after
each step to confirm.
Get the window origin/size once, capture it, and map screenshot fractions to
screen points (screen = origin + fraction * size):
osascript -e 'tell application "System Events" to tell process "RNLlamaExample" \
to get {position, size} of window 1' # e.g. -1073,443 900x839
screencapture -x -o -R-1073,443,900,839 -t png /tmp/mac.png # then Read it
Click, and type (the process must be frontmost first or keystrokes are dropped):
osascript -e 'tell application "System Events" to click at {-623, 678}' # a button
osascript <<'OSA'
tell application "System Events"
set frontmost of process "RNLlamaExample" to true
delay 0.3
click at {-713, 1259} # focus the text field
delay 0.4
keystroke "Reply in one short sentence: what is 2+3?"
end tell
OSA
osascript -e 'tell application "System Events" to click at {-201, 1257}' # send icon
Re-screenshot to read the reply bubble and its tok/s chip. This works over
the user's live workspace, so if the app is on their active display, say what
you're about to drive first.
5. Perf gate (perf-relevant changes only). Bench screen: record
tokens/sec for prompt and generation, compare against a pre-change run of
the same model/device; flag regressions beyond run-to-run noise (~5%).
Screen reference
The Home screen lists all screens; each downloads its own models on first use. Three log surfaces carry the evidence while driving the app:
- Android (everything - JS console.log, native ggml, HTP/fastrpc):
adb logcat | grep -iE "rnllama|ReactNativeJS|htp|hexagon". - Mac: native ggml logs go to stderr, which a plain launch does not
capture (
log streamand Metro both miss them) - use Xcode's console if you need them. Otherwise verify from the on-screentok/schip and screenshots.
| Changed area | Screen(s) | What to check |
|---|---|---|
| Completion, sampling, chat templates | SimpleChat, TextCompletion | streamed text is coherent, stop tokens honored, no truncated UTF-8 |
| Parallel decoding, slots, state files | ParallelDecoding | "Send N Examples" / "Send MM Examples"; in the JS console each completion logs Timings: { cache_n, prompt_n, ... } - resend the same prompts and expect cache_n > 0 (state reuse) and no empty replies |
| Multimodal (mtmd, mmproj) | Multimodal, ParallelDecoding MM examples | image/audio answers are grounded (the dog is a dog); re-asking about the same image reuses cache; swapped images do not leak stale answers |
| Grammar / JSON schema | StructuredOutput | output validates against the schema |
| Tool calling / MCP | ToolCalling | tool_call parsed, arguments well-formed |
| Embeddings / rerank | Embeddings | similarity ranking is sane across the sample set |
| Speculative decoding (MTP) | MTPSpeculative | acceptance rate shown, output matches non-speculative quality |
| TTS | TTS | audio plays, no native crash |
| Performance regressions | Bench | tokens/sec prompt+gen vs the numbers before the change |
| Context shifting / long runs | StressTest | context-full handling, no position errors in native logs |
| Model loading / metadata | ModelInfo | GGUF metadata parses, capabilities detected |
Accelerator proof by platform: Android has the definitive logcat lines during
model load (htp/fastrpc/Hexagon); Mac relies on the linked-Metal-backend
check plus GPU-consistent tok/s, since ggml's Metal log isn't captured outside
Xcode. Capture the concrete evidence you used.
Reporting
State plainly what was verified at which level: compile-only, installed, launched, or model-inference-on-GPU/NPU. A successful build + launch does not by itself prove the accelerator ran - the model-load log lines do.
If a level can't be reached (the phone is PIN-locked, the Mac app can't be driven), say so with the concrete blocker and what remains, rather than implying full coverage.