Portal device
Build, sideload, and iterate on Android apps for Meta Portal devices (touch displays and TV). Use when the user asks to create a Portal app, sideload an APK to a Portal, debug a Portal app, or work with Portal-specific design constraints (large tabletop displays, far-viewing-distance UI). Also covers the hzdb MCP server's tool surface — screenshots, device logcat, Perfetto tracing, Meta docs search.From its SKILL.md
npx -y skills add ZainRizvi/meta-portal-skill --skill portal-deviceAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
10.0 KB, as published. Nobody here has run it
Portal device development
Meta Portal devices (Portal+, Portal TV, etc.) run a customized Android. Apps are built with the standard Android toolchain (Kotlin + Jetpack Compose work well) but have a few platform quirks you need to know about.
This skill walks through the dev loop, captures hard-won lessons, and points at deeper reference docs in docs/.
When you're starting a brand-new Portal app
Use the portal-samples repo as the template:
- GitHub: https://github.com/meta-quest/portal-samples
- It demonstrates project layout, Gradle config, manifest entries (camera/mic/contacts/location permissions), Material 3 + Compose theming, and idiomatic Portal SDK usage.
The fastest start is to copy that repo's app/build.gradle.kts, app/src/main/AndroidManifest.xml, gradle/libs.versions.toml, and ui/theme/ into your new project, then build from there.
Dev environment setup
If ./gradlew assembleDebug fails with "SDK location not found", you need the Android SDK installed. On macOS:
brew install --cask android-commandlinetools
brew install --cask temurin # JDK, if not present
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
yes | sdkmanager --licenses >/dev/null 2>&1
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
Then create <project>/local.properties (gitignored — never commit):
sdk.dir=/opt/homebrew/share/android-commandlinetools
Match the platform/build-tools versions to the project's compileSdk / targetSdk in app/build.gradle.kts. The portal-samples repo targets API 36 at time of writing.
→ Deeper detail: docs/sdk-setup.md
The build / sideload loop
- Enable ADB on the Portal: Settings → Developer options → enable USB debugging. Connect via USB-C and accept the "Allow USB debugging?" prompt on the device.
- Build:
./gradlew assembleDebug - Verify the device is seen:
adb devices -lshould list the Portal (modelPortal_or similar). - Install:
adb install -r app/build/outputs/apk/debug/app-debug.apk(the-rreinstalls over an existing version). - Launch from CLI:
adb shell monkey -p <package.name> -c android.intent.category.LAUNCHER 1
→ Deeper detail: docs/sideload.md
Portal-specific design constraints
Portal devices have form factors that differ meaningfully from a phone:
- Tabletop displays: 1280×800 or larger landscape orientation, viewed from ~1 meter. Touch targets and text need to be larger than phone defaults. Buttons under ~48dp will be hard to hit.
- Portal TV: viewed across a room with a remote. Plan for D-pad navigation, focus states, and very large text.
- Always landscape: don't assume portrait layouts. Use
Row+weight-based layouts that fill the width. - No Play Services on Portal: don't pull in Firebase, GMS, ads SDKs, etc. They'll either crash or silently no-op. Stick to AndroidX.
Official Meta references (open these if the user asks about design specifics):
- Portal development docs: https://developers.meta.com/horizon/documentation/android-apps/portal-development/
- Design requirements: https://developers.meta.com/horizon/documentation/android-apps/portal-design-requirements
- AI tooling: https://developers.meta.com/horizon/documentation/android-apps/portal-ai-tooling
Hard-won lessons
Only document things here that took real iteration to figure out — not "the error message told me what to do." Each entry should describe the symptom, the cause that was non-obvious, and the fix.
Drag-and-drop overlays inside layout-constrained parents
Symptom: the floating element appears far from the user's finger (typically offset by ~the height of the play area).
Non-obvious cause: if you render the dragged element inside a child column that uses Arrangement.Bottom, the column lays it out at the bottom of the column first, then applies your offset { IntOffset(x, y) }. So the offset is applied on top of an already-displaced layout position, not from the column's origin. Reasoning about "the disk should be at the top of the stack so my offset is from the top" is wrong — the disk is at the bottom of the stack because of the arrangement, and your math is off by the arrangement's worth of pixels.
Fix: lift the dragged overlay out of the constrained parent entirely. Render it as a child of the outer container Box that holds the play area. Track that Box's root position with onGloballyPositioned. Position the overlay absolutely with offset { IntOffset(x, y) } computed from the container's origin. Leave a same-sized Spacer in the original column so the static layout doesn't shift while dragging.
Trying to fix this with relative math inside the constrained column ate two debug cycles. Don't go down that path.
Compose Column + Arrangement.Bottom lays children top-to-bottom
Symptom: building a Tower-of-Hanoi-style stacked column, the disks visually appear with the smallest on the bottom and largest on the top — inverted from intent.
Non-obvious cause: verticalArrangement = Arrangement.Bottom only tells the Column where the stack of children sits in the available space, not the order of the children. Children still flow in declaration order (first emitted → topmost child within the stack, last emitted → bottommost child within the stack). It feels like "Bottom = build upward" but it isn't.
Fix: if your data is ordered bottom-to-top (e.g., disks[0] is the largest at the base, disks.last() is the smallest at the top), iterate in reverse when emitting children, so the largest disk is emitted last and lands at the visual bottom:
for (index in disks.indices.reversed()) {
Disk(size = disks[index])
}
Material 3 ships almost no icons by default
Symptom: Unresolved reference 'Icons' after writing Icons.Filled.ArrowBack or Icons.Filled.Science.
Non-obvious cause: androidx.compose.material3:material3 does not transitively include androidx.compose.material.icons.*. Many Compose examples online assume both are present; they aren't, by default, in a clean Portal-samples-style project.
Fix: either add implementation("androidx.compose.material:material-icons-extended:<version>"), or — for 1–3 icons total — just use Unicode/emoji inside Text(...). The latter avoids ~10 MB of icon resources.
→ More: docs/material3-icons.md
adb shell input tap doesn't reliably hit Compose IconButtons on Portal
Symptom: scripted adb shell input tap X Y taps land inside a Compose button's visual bounds but the button never fires — confirmed by screenshots before/after showing no state change. Burns iteration cycles when verifying UI changes from CLI.
Non-obvious cause: unclear, but tap injection on Portal's Compose UI seems to require a longer-duration touch than the instant input tap provides. Empirically Compose's gesture detector treats the synthesized tap as a moved-during-press cancellation.
Fix: use a swipe with identical start and end coordinates plus a long hold duration:
adb shell input swipe <x> <y> <x> <y> 200
The 200ms hold reliably registers as a tap on Compose buttons, IconButtons, and clickable Cards. (Real finger taps on the device always work — this is a CLI-only issue.)
Building 3D scenes (SceneView / Filament)
Portal renders 3D scenes well via SceneView + Filament. There's a body of non-obvious gotchas around camera framing, model loading, and glTF scaling.
→ See docs/3d-modeling.md before starting any SceneView work on Portal.
hzdb MCP — your Portal toolbelt
When the hzdb MCP server is connected (via .mcp.json at the project root or user-level config), you get a set of Portal-specific tools. Use them proactively:
mcp__hzdb__take_screenshot— capture the current device screen. Passmethod: "screencap"for plain Android screencap (works reliably on Portal). Use this to verify any UI change is actually rendering correctly after install.mcp__hzdb__get_device_logcat— pull logcat. Use when an app crashes after install or behavior diverges from expectation.mcp__hzdb__meta_docs_search+mcp__hzdb__meta_docs_get_page— search and fetch official Meta developer docs. Prefer this over guessing API names.mcp__hzdb__capture_perfetto_trace/load_trace_for_analysis/analyze_trace— performance work. Use when investigating jank or startup time.mcp__hzdb__search_api_reference+get_api_details— Horizon OS API lookup.
To install the server at the project level so it ships with the repo:
npx -y @meta-quest/hzdb mcp install project --dotfile true -y
That writes .mcp.json — commit it so teammates and CI pick it up automatically.
→ Deeper detail: docs/hzdb-mcp.md
Verifying a change really worked
After every install, launch the app and screenshot it before declaring the change done. Type-checks and unit tests verify code correctness, not feature correctness. The hzdb take_screenshot tool makes this cheap — there's no excuse to skip it.
If the Portal has locked itself, wake it first:
adb shell input keyevent KEYCODE_WAKEUP
adb shell input keyevent KEYCODE_MENU
Then re-launch the app.
Growing this skill
When you discover a new Portal-specific gotcha, capture it here (or in docs/ if it's long) before moving on. Each entry should describe the symptom, the cause, and the fix — not just the fix in isolation. Future-you will thank present-you.