Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 47 additions & 19 deletions .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,60 @@ jobs:
restore-keys: |
gradle-

# Optional custom signing: set the ANDROID_KEYSTORE_BASE64,
# ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD
# secrets to sign with your own key. Without them (e.g. on fork PRs,
# where secrets are unavailable) the build falls back to the debug
# keystore committed in android/.
- name: Set up signing keystore (optional)
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then
echo -n "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/picasim.keystore"
{
echo "PICASIM_KEYSTORE_FILE=$RUNNER_TEMP/picasim.keystore"
echo "PICASIM_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD"
echo "PICASIM_KEY_ALIAS=$ANDROID_KEY_ALIAS"
echo "PICASIM_KEY_PASSWORD=$ANDROID_KEY_PASSWORD"
} >> "$GITHUB_ENV"
echo "Signing with the custom keystore from secrets."
else
echo "Signing secrets not (fully) configured - using the repo debug keystore."
fi

- name: Generate asset manifest
run: |
python3 tools/generate_asset_manifest.py data data/assets_manifest.txt packaging_excludes.txt

- name: Build debug APK
- name: Build debug APKs (phone + quest)
working-directory: android
run: |
./gradlew assembleDebug
./gradlew assemblePhoneDebug assembleQuestDebug -PpicasimBuildNumber=${{ github.run_number }}

- name: Upload debug APK
- name: Upload phone debug APK
uses: actions/upload-artifact@v4
with:
name: picasim-android-debug
path: android/app/build/outputs/apk/debug/app-debug.apk
path: android/app/build/outputs/apk/phone/debug/app-phone-debug.apk
retention-days: 14

# Name the quest APK with the bundle id, version (VERSIONS.txt) and CI
# build number so sideloaded builds are identifiable on the headset.
- name: Rename quest APK
run: |
VERSION=$(grep -oE 'Version [0-9.]+' VERSIONS.txt | head -1 | sed 's/Version //')
cp android/app/build/outputs/apk/quest/debug/app-quest-debug.apk \
"com.rowlhouse.picasim-quest-v${VERSION}-b${{ github.run_number }}.apk"

- name: Upload quest debug APK
uses: actions/upload-artifact@v4
with:
name: picasim-quest-debug
path: com.rowlhouse.picasim-quest-v*.apk
retention-days: 14

# Smoke test: install the APK on an emulator and verify it doesn't crash at startup.
Expand Down Expand Up @@ -99,21 +139,9 @@ jobs:
target: default
arch: x86_64
emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect
script: |
adb install apk/app-debug.apk
adb shell am start -n com.rowlhouse.picasim/.PicaSimActivity
# Poll for up to 60 seconds for the process to appear
for i in $(seq 1 12); do
sleep 5
if adb shell ps -A | grep -q com.rowlhouse.picasim; then
echo "PicaSim is running OK"
exit 0
fi
echo "Waiting for PicaSim... ($((i*5))s)"
done
echo "PicaSim did not start within 60 seconds"
adb logcat -d | grep -E "com.rowlhouse.picasim|SDL|FATAL|AndroidRuntime" | tail -100
exit 1
# The action runs each script line as a separate sh -c command, so
# multi-line shell constructs break - keep the logic in a script.
script: ./tools/android_smoke_test.sh

- name: Collect logs
if: always()
Expand Down
29 changes: 27 additions & 2 deletions .github/workflows/github-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,44 @@ jobs:
yes | "$SDKMANAGER" --licenses > /dev/null || true
"$SDKMANAGER" --install "ndk;25.2.9519653"

# Optional custom signing: set the ANDROID_KEYSTORE_BASE64,
# ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD
# secrets to sign with your own key. Without them (e.g. on fork PRs,
# where secrets are unavailable) the build falls back to the debug
# keystore committed in android/.
- name: Set up signing keystore (optional)
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then
echo -n "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/picasim.keystore"
{
echo "PICASIM_KEYSTORE_FILE=$RUNNER_TEMP/picasim.keystore"
echo "PICASIM_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD"
echo "PICASIM_KEY_ALIAS=$ANDROID_KEY_ALIAS"
echo "PICASIM_KEY_PASSWORD=$ANDROID_KEY_PASSWORD"
} >> "$GITHUB_ENV"
echo "Signing with the custom keystore from secrets."
else
echo "Signing secrets not (fully) configured - using the repo debug keystore."
fi

- name: Generate asset manifest
run: |
python3 tools/generate_asset_manifest.py data data/assets_manifest.txt packaging_excludes.txt

- name: Build debug APK
working-directory: android
run: |
./gradlew assembleDebug
./gradlew assemblePhoneDebug

- name: Rename APK
run: |
VERSION=$(grep -oE 'Version [0-9.]+' VERSIONS.txt | head -1 | sed 's/Version //')
cp android/app/build/outputs/apk/debug/app-debug.apk "PicaSim-${VERSION}-android-debug.apk"
cp android/app/build/outputs/apk/phone/debug/app-phone-debug.apk "PicaSim-${VERSION}-android-debug.apk"

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/quest-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Quest Build

# Manual-only build of just the Meta Quest flavor, for fast iteration on the
# Quest port without spending CI minutes on the full multi-platform suite.
# Tip: push work-in-progress commits with "[skip ci]" in the message to keep
# the PR workflows quiet, then dispatch this workflow on the branch.

on:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build-quest:
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Install NDK
run: |
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
yes | "$SDKMANAGER" --licenses > /dev/null || true
"$SDKMANAGER" --install "ndk;25.2.9519653"

- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-

# Optional custom signing: set the ANDROID_KEYSTORE_BASE64,
# ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD
# secrets to sign with your own key. Without them (e.g. on forks) the
# build falls back to the debug keystore committed in android/.
- name: Set up signing keystore (optional)
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then
echo -n "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/picasim.keystore"
{
echo "PICASIM_KEYSTORE_FILE=$RUNNER_TEMP/picasim.keystore"
echo "PICASIM_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD"
echo "PICASIM_KEY_ALIAS=$ANDROID_KEY_ALIAS"
echo "PICASIM_KEY_PASSWORD=$ANDROID_KEY_PASSWORD"
} >> "$GITHUB_ENV"
echo "Signing with the custom keystore from secrets."
else
echo "Signing secrets not (fully) configured - using the repo debug keystore."
fi

- name: Generate asset manifest
run: |
python3 tools/generate_asset_manifest.py data data/assets_manifest.txt packaging_excludes.txt

- name: Build quest debug APK
working-directory: android
run: |
./gradlew assembleQuestDebug -PpicasimBuildNumber=${{ github.run_number }}

- name: Rename APK
run: |
VERSION=$(grep -oE 'Version [0-9.]+' VERSIONS.txt | head -1 | sed 's/Version //')
cp android/app/build/outputs/apk/quest/debug/app-quest-debug.apk \
"com.rowlhouse.picasim-quest-v${VERSION}-b${{ github.run_number }}.apk"

- name: Upload quest debug APK
uses: actions/upload-artifact@v4
with:
name: picasim-quest-debug
path: com.rowlhouse.picasim-quest-v*.apk
retention-days: 14
27 changes: 25 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,23 @@ if(NOT EXISTS ${CMAKE_SOURCE_DIR}/third_party/SDL2/CMakeLists.txt)
endif()

# --- Platform-specific build options ---

# Meta Quest flavor: Android build with OpenXR VR support (set by the 'quest'
# Gradle product flavor via -DPICASIM_QUEST=ON).
option(PICASIM_QUEST "Build the Meta Quest (Android + OpenXR) flavor" OFF)

if(ANDROID)
# Force VR off on Android
set(PICASIM_ENABLE_VR OFF CACHE BOOL "" FORCE)
if(PICASIM_QUEST)
# Quest: enable VR via the OpenXR loader AAR (org.khronos.openxr),
# exposed to CMake through Gradle's prefab support.
set(PICASIM_ENABLE_VR ON CACHE BOOL "" FORCE)
find_package(OpenXR CONFIG REQUIRED)
add_compile_definitions(PICASIM_VR_SUPPORT=1 PICASIM_QUEST=1)
message(STATUS "Meta Quest build: VR support ENABLED (OpenXR via prefab)")
else()
# Force VR off on non-Quest Android (phones/tablets)
set(PICASIM_ENABLE_VR OFF CACHE BOOL "" FORCE)
endif()

# 16KB page size alignment required for Android 15+ (API 35)
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384")
Expand Down Expand Up @@ -254,6 +268,9 @@ target_link_libraries(Framework PUBLIC
)
if(ANDROID)
target_link_libraries(Framework PUBLIC GLESv2)
if(PICASIM_QUEST)
target_link_libraries(Framework PUBLIC GLESv3)
endif()
elseif(IOS)
# OpenGLES framework linked via PicaSim target (iOS frameworks need to be on the app)
else()
Expand Down Expand Up @@ -307,6 +324,9 @@ target_link_libraries(Platform PUBLIC
)
if(ANDROID)
target_link_libraries(Platform PUBLIC GLESv2 EGL log android)
if(PICASIM_QUEST)
target_link_libraries(Platform PUBLIC GLESv3 OpenXR::openxr_loader)
endif()
elseif(IOS)
# OpenGLES and system frameworks linked via PicaSim target
else()
Expand Down Expand Up @@ -416,6 +436,9 @@ target_link_libraries(PicaSim PRIVATE
)
if(ANDROID)
target_link_libraries(PicaSim PRIVATE SDL2main GLESv2 EGL log android)
if(PICASIM_QUEST)
target_link_libraries(PicaSim PRIVATE GLESv3 OpenXR::openxr_loader)
endif()
elseif(IOS)
target_link_libraries(PicaSim PRIVATE SDL2main)
target_link_libraries(PicaSim PRIVATE
Expand Down
95 changes: 95 additions & 0 deletions QUEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# PicaSim on Meta Quest

The `quest` Gradle product flavor builds the Android app for Meta Quest
headsets (Quest 2 / Quest Pro / Quest 3) with OpenXR VR support enabled.
The regular Android build is the `phone` flavor and is unchanged.

## What the quest flavor does

- Passes `-DPICASIM_QUEST=ON` to CMake, which enables `PICASIM_ENABLE_VR`,
defines `PICASIM_VR_SUPPORT=1` and `PICASIM_QUEST=1`, and links the OpenXR
loader and `GLESv3` in addition to `GLESv2`.
- Pulls the Khronos OpenXR loader from Maven Central
(`org.khronos.openxr:openxr_loader_for_android:1.1.49`), exposed to the
CMake build via Gradle's prefab support (`find_package(OpenXR CONFIG)`).
- Merges a Quest manifest overlay (`android/app/src/quest/AndroidManifest.xml`):
head-tracking feature requirement, `com.oculus.intent.category.VR` launcher
category, `com.oculus.supportedDevices` metadata, and removal of the
phone-oriented `screenOrientation` lock.
- Uses the same application id as the phone flavor
(`com.rowlhouse.picasim`), so only one of the two can be installed on a
device at a time.

## Building

```sh
cd android
./gradlew assembleQuestDebug # -> app/build/outputs/apk/quest/debug/app-quest-debug.apk
./gradlew assemblePhoneDebug # regular phone build
```

CI (`.github/workflows/android-build.yml`) builds both flavors and uploads
the quest APK as the `picasim-quest-debug` artifact.

## Installing (sideload)

1. Enable developer mode on the headset (requires a Meta developer account:
pair the headset with the Meta Horizon phone app, then toggle Developer
Mode) and connect via USB-C.
2. Install with adb:

```sh
adb install android/app/build/outputs/apk/quest/debug/app-quest-debug.apk
```

3. Launch from **Library → Unknown Sources** on the headset.

### Fast local builds with Docker

CI round-trips are slow for iteration; `./quest_docker_build.sh` builds the
APK locally in a container (image built once from
`docker/android-build.Dockerfile`, with JDK + Android SDK/NDK). Gradle caches
persist in the `picasim-gradle` named volume and native build outputs live in
the repo bind-mount, so incremental builds are fast. Any gradle task can be
passed as argument (default `assembleQuestDebug`); `--rebuild-image` forces
an image rebuild. On Apple Silicon enable "Use Rosetta for x86_64/amd64
emulation" in Docker Desktop (the NDK has no linux/arm64 host toolchain).
Typical loop:

```sh
./quest_docker_build.sh && ./quest_run.sh android/app/build/outputs/apk/quest/debug/app-quest-debug.apk
```

### One-step install + launch + logs

`./quest_run.sh [path/to.apk]` (repo root) installs the APK if given,
(re)launches the app via adb (USB or WiFi) and streams the app's logcat
filtered by PID — dropping all the Quest system noise while keeping PicaSim
traces, SDL and OpenXR loader/runtime output. The stream is also saved to
`picasim_quest_run.log`, and on Ctrl-C it appends any native crash report.
CI names the quest APK `com.rowlhouse.picasim-quest-v<version>-b<build>.apk`
so sideloaded builds are identifiable.

### Signing

Debug builds (local and CI) are signed with `android/debug.keystore`,
committed to the repo with the standard Android debug credentials, so every
build shares one signature and installs over the previous one. To sign CI
builds with your own key instead, set the repository secrets
`ANDROID_KEYSTORE_BASE64` (the keystore file, base64), `ANDROID_KEYSTORE_PASSWORD`,
`ANDROID_KEY_ALIAS` and `ANDROID_KEY_PASSWORD` — with any of them missing,
the workflows fall back to the committed keystore.

## Input

There is no VR (Touch) controller support. Fly with an RC transmitter
connected over USB-C or Bluetooth — it shows up as a standard SDL gamepad,
just like on phones. A Bluetooth mouse works for navigating the menus.

## Status / known gaps

- **Experimental** — the build-system support is in place, but the rendering
path is still being ported to GLES3 for the Quest's OpenXR runtime.
- Not yet tested on real hardware.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the hardware-testing status.

“Not yet tested on real hardware” contradicts the PR history, which says Quest device testing exposed the invisible-menu startup issue. Document the tested headset/build and retain any untested-device caveats.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@QUEST.md` at line 67, Update the hardware-testing status in QUEST.md to
document the Quest headset and build tested, reflecting that device testing
exposed the invisible-menu startup issue. Retain caveats for any Quest models or
configurations that remain untested.

- No hand tracking, no Touch controller bindings, no Quest-specific UI
(menus render on the 2D layer).
Loading