Skip to content

Immersive OpenXR goggle mode for Quest / Android XR - #2

Closed
iflyhere wants to merge 24 commits into
integration/feature-1from
feature/xr-goggle-mode
Closed

Immersive OpenXR goggle mode for Quest / Android XR#2
iflyhere wants to merge 24 commits into
integration/feature-1from
feature/xr-goggle-mode

Conversation

@iflyhere

@iflyhere iflyhere commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Warning

Working on a Quest 3, not yet flown. Session, layer orientation, head lock,
passthrough and hand-tracking input are all confirmed by eye on hardware (see Hardware
status
). What is still untested is a live video stream through the layer - that needs the
wifi adapter. Fork-only; not for upstream until it has flown.

Why

On a headset PixelPilot runs as a 2D Android panel. That costs three things at once:

  1. Sharpness. The panel's render resolution is chosen by the system, not by the app.
    1080p is downsampled to the panel, then the compositor resamples that quad onto the
    display — two resampling steps for a stream that was already the bottleneck.
  2. Latency. app → view hierarchy → SurfaceFlinger → compositor quad → display.
  3. It is world locked. The panel stays where you put it. For FPV you want the image
    glued to your view like a pair of goggles.

What this does instead

xrCreateSwapchainAndroidSurfaceKHR hands out an Android Surface that is the producer
side of a compositor swapchain. MediaCodec decodes straight into it and the runtime
composites it as a quad layer. The view hierarchy, SurfaceFlinger and the panel resolution
leave the video path entirely. There is no GL blit and no readback — the EGL context exists
only because OpenXR requires a graphics binding for the session.

New module :app:xr

XrGoggleSession (native, ~950 lines) + a thin Java wrapper that owns the frame thread.

Layer XrCompositionLayerQuad, head locked in XR_REFERENCE_SPACE_TYPE_VIEW
World locked mode quad anchored in LOCAL with a yaw-only pose, recenterable
Sharpening XR_FB_composition_layer_settings, QUALITY_SHARPENING_BIT_FB
Passthrough XR_FB_passthrough as an underlay — see the radio, a spotter, the ground. Started on demand, not at session begin, so the cameras are not running all flight
Refresh rate XR_FB_display_refresh_rate; "auto" takes the highest offered
Input /interaction_profiles/oculus/touch_controller, khr/simple_controller fallback
Haptics controller buzz on link degradation

Controls: A recenter · B passthrough · X DVR record · Y head-lock toggle ·
right stick screen size · left stick distance.

Every optional extension is probed at instance creation and the session runs without any of
them. The three that are genuinely required (XR_KHR_android_create_instance,
XR_KHR_opengl_es_enable, XR_KHR_android_surface_swapchain) produce a clean error and a
fallback instead of a crash.

The OpenXR loader comes from org.khronos.openxr:openxr_loader_for_android through prefab,
so no vendor SDK is vendored into the tree and the ABI set already matches the project's
(arm64-v8a, armeabi-v7a).

New XrVideoActivity

  • Resolves USB permission while still on a flat panel and only then starts the session —
    a permission dialog is much easier to deal with before the compositor takes over.
  • Sizes the swapchain from the last stream resolution it saw (persisted), so after one
    flight the swapchain matches the stream exactly and nothing is resampled twice. The
    layer's imageRect is updated live from the decoded resolution.
  • DVR toggle on X, using an already granted folder. Picking a folder needs an Activity
    result, so that stays in flat mode.
  • No teardown in onPause/onStop. An immersive runtime pauses and resumes the app for
    its own reasons (system menu, proximity sensor), and dropping the USB link there costs
    seconds of black screen on the way back.
  • Any failure falls back to VideoActivity with the reason in a toast.

Supporting changes

  • WfbLinkManager reported status through ActivityVideoBinding, which only the flat
    activity has. It now takes a LinkStatusView; both activities implement it. No behaviour
    change for the flat path.
  • DVR file naming and creation moved out of VideoActivity into DvrFiles so both
    activities share one implementation rather than duplicating it.

Nothing changes on a phone

  • The whole Settings → VR goggles (OpenXR) submenu is hidden unless the device looks
    like a headset (android.hardware.vr.headtracking, android.software.xr.immersive, or
    the Horizon shell package).
  • android.hardware.vr.headtracking is declared required="false", so the APK still
    installs and runs everywhere.
  • libXrGoggle failing to load is caught, and the flat app carries on.
  • The flat activity's layout, decoder path and USB filters are untouched.

Known rough edges

  • A second launcher entry ("PixelPilot VR") is how the immersive activity gets the
    com.oculus.intent.category.VR category without disturbing the flat activity's
    USB_DEVICE_ATTACHED filter. On a phone that means two icons. Before this could go
    upstream it wants a product flavour so the mobile APK only ships one.
  • No in-headset menu. Channel, bandwidth and the DVR folder are still set in flat mode
    before putting the headset on. A second android-surface swapchain fed by
    Surface.lockHardwareCanvas() would let the existing OSD views be composited as their own
    quad at native resolution — that is the obvious next step.
  • Debug builds only so far, so the R8/proguard path for the new JNI entry points is
    unverified.

Hardware status

Working on a Quest 3, Horizon OS, Android 14 (API 34). Confirmed by eye in the headset:

  • immersive session reaches XR_SESSION_STATE_FOCUSED, frame loop runs
  • the video quad composites, correctly oriented, at the right size and distance
  • head locked: the panel stays centred as you turn your head
  • passthrough toggles live (right-hand grasp)
  • hand tracking input fires (right-hand pinch recenters) - no controllers needed
  • launches with no controllers on and the launch-check bypass off, i.e. purely on
    oculus.software.handtracking + com.oculus.permission.HAND_TRACKING
  • live video through the layer - still untested, needs the wifi adapter
  • sharpening and refresh-rate switching judged by eye
  • DVR from immersive mode
runtime exposes 76 instance extensions
optional extensions: passthrough=1 layerSettings=1 refreshRate=1 handInteraction=1 imageLayout=1
android surface swapchain 1920x1080 created (ignored fields zeroed)
hand interaction bindings suggested (pinch/grasp)
session state -> 1 → 2 → 3 → 4 → 5
OpenXR_Passthrough: xrCreatePassthroughLayerFB: Created Insight MR layer

What the hardware brought up

wirelessInfo() NPE static WifiManager only ever assigned by VideoActivity.initializeUI(); any other host of WfbLinkManager crashed in onResume
SIGABRT on launch the native aggregators open files/gs.key in their constructor and throw; only VideoActivity was provisioning it
adaptive link / TX power / FEC silently ignored initDefaultOptions() had no equivalent in the immersive path
xrCreateSwapchainAndroidSurfaceKHRXR_ERROR_VALIDATION_FAILURE the extension documents format/sampleCount/faceCount/arraySize/mipCount as ignored, but Meta's runtime validates them and wants them zeroed
picture upside down anything producing into an Android Surface has its origin top-left, the compositor samples bottom-left. Fixed with XR_FB_composition_layer_image_layout + VERTICAL_FLIP
XR_FB_passthrough absent from the extension list Horizon OS only exposes it to an app declaring com.oculus.feature.PASSTHROUGH
launch blocked by RequiresControllersLaunchInterceptor an app declared with the VR category is controller-required unless it advertises hand tracking; Horizon decides on currently available input
nothing visible at all with no stream an Android surface swapchain has no image until a producer posts a buffer, so the quad sampled an empty swapchain. Hence the status screen, which posts one.

One earlier conclusion in this branch's history was wrong and is corrected in the code: the
Khronos loader does resolve the Horizon runtime by itself, through the
org.khronos.openxr.runtime_broker provider. Querying that provider from adb shell
returns no rows only because the shell does not hold
org.khronos.openxr.permission.OPENXR. resolveRuntime() is kept as a fallback for a
device offering neither the broker nor active_runtime.json; on a Quest its first
candidate ("change nothing") wins.

Update — launcher entry and packet queue

The immersive activity's launcher entry now ships disabled (android:enabled="false") and
VideoActivity.onCreate turns it on only where XrGoggleSession.isSupportedDevice() agrees.
That was the last thing keeping this branch from being upstreamable: a phone install is now
indistinguishable from before the feature existed, with no second icon.

Verified on a Quest 3 — after install only .VideoActivity resolves for MAIN/LAUNCHER and
nothing resolves for com.oculus.intent.category.VR; after one flat launch .XrVideoActivity
resolves for both, and a second launch is a no-op rather than another package-manager write.

Also merged in the BufferedPacketQueue fix, which went upstream separately as
OpenIPC#120 — a lost packet used to hold back everything behind it until the buffer
hit fifteen packets, because the escape hatch meant to catch exactly that had its arguments
reversed and was gated on the constant it was compared against.

Measured, for the record

Glass-to-glass, photographed through the lens with a millisecond counter visible both in the
FPV image and in passthrough: the FPV image runs ~87 ms behind passthrough. Relative, not
absolute — passthrough has its own latency on top.

The decoder's own averages over a 138k-frame session on this branch:

Parsing: 11.89 ms | WaitInputBuffer: 0.55 ms | Decoding: 6.39 ms

Parsing is not CPU time — creationTime is set when the first RTP packet of a NALU
arrives, so it is one frame's packetization spread on the wire. WaitInputBuffer at 0.55 ms
means the decoder is never starved for input buffers. So the decode path totals ~19 ms and has
no large win left in it; the rest sits upstream of this app, in the camera encoder and the
wfb-ng FEC block.

Presents the stream as a head locked compositor quad layer instead of a 2D
panel that the headset resamples. The decoder writes straight into an Android
surface swapchain (XR_KHR_android_surface_swapchain), so the video path loses
the view hierarchy, SurfaceFlinger and the panel's system-chosen resolution.
There is no GL blit and no readback: the EGL context only exists because
OpenXR requires a graphics binding.

New module :app:xr
- XrGoggleSession (native): loader init, instance, session, LOCAL + VIEW
  reference spaces, the android surface swapchain, action set, frame loop.
- Head locked by default (quad in VIEW space); world locked mode anchors a
  yaw-only pose in LOCAL space and can be recentered.
- XR_FB_composition_layer_settings quality sharpening on the video layer.
- XR_FB_passthrough as an underlay so the pilot can see the radio, a spotter
  and the ground without taking the headset off.
- XR_FB_display_refresh_rate, "auto" picks the highest the headset offers.
- Touch controller bindings: A recenter, B passthrough, X record, Y head lock
  toggle, right stick screen size, left stick distance. Simple controller
  bindings as a fallback.
- Controller haptics on link degradation - a warning a goggle cannot give.
- Every optional extension is probed; a runtime without them still flies.
- OpenXR loader comes from org.khronos.openxr:openxr_loader_for_android via
  prefab, so no vendor SDK is vendored into the tree.

New XrVideoActivity
- Resolves USB permission while still flat (a permission dialog is much easier
  to deal with before the compositor takes over), then starts the session.
- Sizes the swapchain from the last seen stream resolution so the compositor
  never resamples twice; updates the layer's imageRect live.
- DVR toggle on X using the already granted folder; picking a folder still
  needs flat mode.
- No teardown in onPause/onStop: the runtime pauses and resumes an immersive
  session for its own reasons, and dropping USB there costs seconds of black.
- Any failure falls back to VideoActivity with the reason.

Supporting changes
- WfbLinkManager reported status through ActivityVideoBinding, which only the
  flat activity has. It now takes a LinkStatusView, implemented by both.
- DVR file naming/creation moved from VideoActivity into DvrFiles so both
  activities share one implementation.
- Settings -> VR goggles (OpenXR): enter, head lock, passthrough, sharpening,
  refresh rate. The whole submenu is hidden unless the device looks like a
  headset, so nothing changes on a phone.
Passthrough was started at session begin and merely withheld from the frame's
layer list when disabled, so the cameras and the reconstruction pipeline kept
running for the whole flight. On a headset that is already thermally tight and
on battery that is a real cost for nothing.

xrPassthroughStartFB / xrPassthroughPauseFB now follow the setting, checked
once per frame before the layers are submitted.
wirelessInfo() is static and read the static WifiManager field, which only
VideoActivity.initializeUI() ever assigns. WfbLinkManager calls it from the
"no compatible wifi adapter found" branch, so any other host of
WfbLinkManager - the immersive activity - crashed there with

  NullPointerException: WifiManager.getConnectionInfo() on a null object
      at VideoActivity.wirelessInfo(VideoActivity.java:183)
      at WfbLinkManager.refreshAdapters(WfbLinkManager.java:174)

Confirmed on a Quest 3 (Horizon OS, Android 14): launching XrVideoActivity
with no adapter attached killed the process in onResume before the OpenXR
session could start.

wirelessInfo() now takes a Context, fetches the service itself and null
checks both the manager and the WifiInfo, and the static field is gone - so
this cannot come back through a different entry point.
Horizon OS routes an activity declared with com.oculus.intent.category.VR
through RequiresControllersLaunchInterceptor and refuses to start it while the
controllers are off:

  ActivityLaunchInterceptorController: RequiresControllersLaunchInterceptor:
    com.openipc.pixelpilot/.XrVideoActivity ->
    com.oculus.vrshell/...LaunchCheckControllerRequiredDialogActivity

An app is treated as controller-required unless it advertises hand tracking.
For a ground station that is the wrong default: the pilot is holding a radio,
and video, link and DVR do not need a controller - only the optional in-flight
shortcuts do. Declaring oculus.software.handtracking as required="false" lets
the session start regardless, and the controller bindings still work when the
controllers are on.
The generic Khronos loader cannot find a runtime on a Quest 3, verified on the
device (Horizon OS, Android 14):

  - /system/etc/openxr/1/active_runtime.json does not exist
  - the Khronos broker authorities are registered but resolve to
    internal.horizonos.openxr.DisabledRuntimeBroker /
    DisabledRuntimeSystemBroker, and querying
    content://org.khronos.openxr.runtime_broker/openxr/1/abi/arm64-v8a/runtimes/active
    returns "No result found"
  - libopenxr_loader.so from openxr_loader_for_android has no reference to
    libopenxr_forwardloader.so at all

The runtime is reached through libopenxr_forwardloader.so in the VrDriver
APEX. Its lib directory is added to every app's linker namespace, and its only
exports are xrNegotiateLoaderRuntimeInterface and xrInitializeLoaderKHR - so
as far as a loader is concerned it *is* the runtime.

resolveRuntime() therefore writes a runtime manifest into the app's files dir
and sets XR_RUNTIME_JSON, trying in order:

  1. nothing at all, so a device whose broker does work is left alone
  2. library_path "libopenxr_forwardloader.so" (bare name = system search path)
  3. the absolute APEX path, for a loader that insists on one

Each candidate is probed by asking for the instance extension list and
requiring XR_KHR_android_surface_swapchain to be in it, so "the call did not
fail" is not mistaken for "a runtime answered". Which candidate won is logged.
Two things VideoActivity.onCreate() does that XrVideoActivity did not, both
found on a Quest 3:

1. gs.key. The native aggregators open files/gs.key in their constructor and
   throw if it is missing. VideoActivity copies the bundled key there before
   constructing WfbNgLink; the immersive activity did not, so a fresh install
   aborted the process on launch:

     libc++abi: terminating due to uncaught exception of type
       std::runtime_error: Unable to open
       /data/user/0/com.openipc.pixelpilot/files/gs.key
     Fatal signal 6 (SIGABRT) ... XrVideoActivity.onCreate

2. Adaptive link, TX power and FEC/LDPC/STBC. The native side does not read
   these itself, so without initDefaultOptions() they were silently ignored in
   immersive mode.

Both moved into GsKey and WfbOptions and used from both activities, so there is
one implementation rather than a copy that can drift.

Also hardened, because a missing key should never be fatal: WfbngLink::initAgg()
catches the aggregator constructor's exception, logs the key path and leaves the
aggregators null instead of letting it escape a JNI call and abort the process.
nativeRefreshKey() re-runs initAgg(), so fixing the key recovers without a
restart.

And XrGoggleSession now logs the runtime's full instance extension list, which
is the only way to tell from a bug report what a given headset offers.
Two findings from a Quest 3 (Horizon OS, Android 14).

1. xrCreateSwapchainAndroidSurfaceKHR returned XR_ERROR_VALIDATION_FAILURE.
   The extension documents format/sampleCount/faceCount/arraySize/mipCount as
   ignored for an Android surface swapchain, but Meta's runtime validates them,
   and the obvious 1s are rejected. Rather than swap one guess for another,
   createSwapchain() now tries the plausible shapes - ignored fields zeroed,
   set to 1, with and without COLOR_ATTACHMENT, with and without an explicit
   RGBA8 - and logs which one the runtime accepted. If all fail it dumps
   xrEnumerateSwapchainFormats so the next attempt has data instead of guesses.

2. passthrough was missing from the instance extension list on a Quest 3.
   Horizon OS only exposes XR_FB_passthrough to an app that declares
   com.oculus.feature.PASSTHROUGH in its manifest. Added as required="false",
   so nothing changes for a phone.

Also corrects the reasoning in resolveRuntime(): the Khronos loader *does*
resolve the Horizon runtime on its own, through the
org.khronos.openxr.runtime_broker provider, and the "change nothing" candidate
wins on a Quest. The earlier conclusion came from querying that provider with
`adb shell content query`, which returns no rows only because the shell does not
hold org.khronos.openxr.permission.OPENXR. The fallback is kept for a device
that offers neither the broker nor active_runtime.json, but it is a fallback,
not a workaround for the Quest.
Flying with hand tracking only and no controllers is a normal way to use a
headset as a ground station, and the Quest 3 runtime offers
XR_EXT_hand_interaction (confirmed in the instance extension list on Horizon OS
14, alongside XR_MSFT_hand_interaction and XR_META_hand_tracking_microgestures).

The four boolean actions now also get bindings on
/interaction_profiles/ext/hand_interaction_ext. A boolean action may be bound to
a float source and the runtime does the thresholding, so no gesture code and no
second input path is needed:

  right pinch  -> recenter        right grasp -> passthrough
  left  pinch  -> toggle DVR      left  grasp -> head lock

Pinch is index-to-thumb and grasp is a whole-hand squeeze, far enough apart not
to trigger each other. Controllers keep their A/B/X/Y and thumbstick bindings;
whichever is active wins. Screen size and distance stay on the thumbsticks -
there is no comfortable continuous hand equivalent.

Also adds com.oculus.permission.HAND_TRACKING, which the handtracking feature
needs before Horizon OS will actually hand the app tracked hands, and records
the now-confirmed swapchain finding in the comment: Meta's runtime wants the
"ignored" image description fields zeroed.
With no stream the video quad is an opaque black rectangle, which from inside
the headset is indistinguishable from "nothing is being composited at all" -
exactly the situation on a first run with no adapter attached, and impossible to
diagnose while wearing the headset.

The swapchain's producer side is an ordinary Android Surface, so before the
decoder takes it over the activity paints into it with lockHardwareCanvas():
a border and a centre cross, so the panel's real extent, centre, size and
distance can be judged with nothing else on screen, plus the current link
status, channel/bandwidth and the UDP address a stream can be pushed to.

It redraws once a second and stops on the first decoded frame, since a buffer
queue has a single producer and MediaCodec becomes it. If the canvas is refused
it gives up quietly and leaves the layer to the decoder.
Anything producing into an Android Surface - MediaCodec, and the Canvas used for
the status screen - has its origin at the top left, while the compositor samples
a swapchain image from the bottom left. Confirmed on a Quest 3: the layer
composited correctly, in the right place and the right size, just vertically
mirrored.

XR_FB_composition_layer_image_layout exists for exactly this. The quad now
carries XrCompositionLayerImageLayoutFB with VERTICAL_FLIP, chained together with
the sharpening settings when both apply. Gated on the extension being present,
like the others.
With a head-locked panel its height relative to your natural line of sight
matters - too high or too low and you fly with your neck craned - and there was
no way to change it.

Pinch and grasp are already spent on recenter, passthrough, DVR and the head
lock toggle, so the nudge goes on XR_META_hand_tracking_microgestures instead:
a thumb swipe along the index finger, which is still an index-finger/thumb
control but costs nothing that was already bound.

  right thumb swipe forward  -> panel up
  right thumb swipe backward -> panel down

That extension defines no structs or entry points, only binding paths, so it is
suggested as part of the hand profile. Suggestions are atomic per profile, so a
rejected microgesture path would take the working pinch/grasp bindings with it;
if the call fails the microgesture entries are dropped and it is retried, and
which variant took is logged.

The controller mapping is reorganised so the same adjustment exists there and is
intuitive - the left stick now moves the panel, the right stick sizes it:

  left stick  up/down    -> panel up/down   (was: distance)
  left stick  left/right -> nearer/farther
  right stick up/down    -> panel size      (unchanged)

The offset is applied to the quad pose in both head-locked and world-locked mode
and persisted as xr_quad_height.
Reported from the headset: the hand gestures work but the right thumbstick does
nothing, and the trigger/grip do not move the panel.

The likely cause of the dead stick is that only one interaction profile is live
at a time. With hands tracked the runtime picks the hand profile and the touch
controller bindings never fire, which looks exactly like a broken stick. The
runtime offers XR_META_simultaneous_hands_and_controllers, so that is enabled
and xrResumeSimultaneousHandsAndControllersTrackingMETA is called once the
session starts. To make this visible rather than guessed at, the active
interaction profile per hand is now logged on
XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED and at session start.

The mapping is also mirrored so a single controller in either hand is enough,
and the trigger/grip take over distance:

  thumbstick up/down     screen up/down
  thumbstick left/right  screen smaller/bigger
  trigger (analog)       pull the screen closer
  grip (analog)          push the screen away
  A / X (lower button)   recenter
  B / Y (upper button)   toggle passthrough
  thumbstick click       toggle recording

A/B and X/Y sit in the same places on their respective controllers, so binding
both to one action gives the same thumb positions whichever controller was
picked up. Trigger and grip are float actions, so the movement is proportional
to how far they are pulled.

Head lock loses its controller binding - there is no button left that exists on
both controllers - and stays on the left-hand grasp gesture and in the flat
settings menu, which is where a set-once option belongs.
@iflyhere
iflyhere force-pushed the feature/xr-goggle-mode branch from f18fa27 to 6cf71ba Compare September 2, 2026 16:30
… of the way

Three problems reported from a session with the antenna connected.

1. Gestures triggered by accident - picking up the drone turned passthrough on.
   A whole-hand squeeze is exactly what grabbing a drone or a radio looks like,
   so the grasp bindings are gone. Pinch survives but now has to be *held*:
   separate hand-only actions fire once after 700 ms, so a momentary squeeze
   while handling the aircraft does nothing. The controller buttons are
   deliberately left immediate - they cannot be pressed by accident - which is
   why the hand variants are separate actions rather than a hold on the shared
   ones. Losing passthrough to a stray grab mid-flight is worse than not having
   the shortcut at all.

   Head lock loses its last input binding as a consequence and is now only in
   the flat settings menu. It is a set-once option, not an in-flight one.

2. Replugging the antenna opened the flat activity next to the running
   immersive one. The USB_DEVICE_ATTACHED filter lives on VideoActivity, so the
   system starts it on every attach. Two activities then fight over one adapter
   and one decoder and neither gets video. VideoActivity now steps aside when an
   immersive session is active and the launch came from that intent; the
   immersive activity already handles the attach itself through its own
   receiver.

3. No video, in either mode, with no crash. Diagnosis needs data the log did not
   have, so the whole chain is now visible: gs.key logs the byte count it wrote,
   and the wfb counters (all / decOk / decErr / fecRecovered / lost / bad / rssi)
   are logged every two seconds and drawn on the status screen. That separates
   "nothing is arriving from the air" from "packets arrive but nothing decodes",
   which are completely different bugs.

   Note for that hunt: WfbngLink::initAgg() no longer aborts the process when
   gs.key is unusable, so what used to be a loud crash is now silent - the log
   line is the only signal.
Diagnosed from a thread dump on a Quest 3 while the app was unresponsive:

      3 S wfb-001/002        <- three live RX loops, one dongle
      1 S nipc.pixelpilot    <- main thread asleep in join()

Two libusb handles on the same interface means neither gets usable video, which
is why no picture arrived in either the flat or the immersive mode, and the
unbounded join() in a lifecycle callback turned it into a five second ANR.

How three threads happen:

- activeWifiAdapters in WfbLinkManager was static, so the flat and the immersive
  activity shared it while each owned its own WfbNgLink. startAdapters() only
  guards on wfbLink.isRunning(), which is per instance, so both decided the
  adapter still needed starting.
- linkThreads.put(usbDevice, t) overwrites the previous entry, so the older
  thread is orphaned and never joined, and stopAll() only ever joins the last.
- nothing refused a second RX loop for a device that already had one.

Fixes:

- a process-wide claim on the device name, keyed on the owning thread, so a
  second RX loop is refused no matter which WfbNgLink instance asks. Keying on
  the thread means a claim left behind by a thread that died is ignored instead
  of locking the adapter out permanently; the claim is released in a finally
  block around nativeRun().
- a per-instance check for an already-live thread, so the common case does not
  even reach the claim.
- joins are bounded to 1500 ms and log when a driver thread does not come back.
  These calls come from Activity lifecycle callbacks on the main thread, where
  an unbounded join is an ANR waiting to happen.
- activeWifiAdapters is no longer static.

The real answer is a single owner for the link - a foreground service - but this
stops the immediate damage.
"No compatible wifi adapter found." was shown whenever no adapter ended up
running, which covers three unrelated problems: nothing plugged in, a dongle
whose id is not in usb_device_filter.xml, and a dongle that was found but failed
to start. Only the middle one is what the message claims, and the last one
became reachable when OpenIPC#116 stopped recording an adapter as active unless it
actually came up.

- getAttachedAdapters() logs every attached device with VID:PID, manufacturer
  and product name, and whether the filter matched. That id is exactly what a
  bug report needs in order to add an adapter, and there is no other way to read
  it - sysfs is not accessible to the shell on Horizon OS and dumpsys usb does
  not print host devices there.
- the message now distinguishes "none attached", "found but could not be
  started - see the log" and "waiting for permission".

Also: joinBounded() now releases the device claim even when the driver thread
outstays the timeout. Holding it would leave the adapter unopenable until the
process restarts, and the user would be told there is no compatible adapter -
a worse failure than the duplicate RX loop it guards against, which the
per-instance check and the single-owner handoff already cover.
Flat mode has video again once USB permission is granted, but entering immersive
mode then reported that the adapter could not be opened.

The handoff is the problem. VideoActivity.onPause() calls stopAll(), which asks
the driver to stop and waits for the thread. StopRxLoop() only breaks the
receive loop - the thread then still has to stop the TX frame, stop the adaptive
link, power the chip down, release the USB interface and exit libusb. That takes
longer than the 1500 ms bound added with the ANR fix, so the wait gave up, the
immersive activity tried to claim an interface that was still held, failed once,
and gave up too.

- the join bound goes to 3000 ms, which is about what the unwind needs, while
  still being bounded so a stuck driver cannot freeze the UI.
- the immersive activity retries bringing the adapter up every 700 ms for up to
  14 s instead of trying once, and logs each attempt. The session is started
  regardless, so the pilot gets the status screen with a reason rather than a
  silent flat activity.
- onXrReady() no longer calls startAdapters(): refreshAdapters() already brings
  adapters up and now retries, and two paths racing for one dongle is what
  caused the duplicate RX loops in the first place.
- WfbLinkManager gained hasActiveAdapter() so a caller can tell "up" from
  "not up yet" rather than inferring it.

Also: VideoActivity.showLinkMessage() now logs. It only ever reached the
overlay, so a bug report had no record of what the app said about the link -
a gap that cost real time in this hunt.
… by nobody

With video working in flat mode, switching to immersive mode and back left no
video in either. The activity dump explains it:

  topResumedActivity: .VideoActivity     t5249
  topResumedActivity: .XrVideoActivity   t5253
  threads: xr-goggle          (no wfb-* at all)

Both activities were alive in separate tasks - Horizon OS keeps the immersive
one in its own task, so its onDestroy(), and with it stopAdapters(), never ran -
while the flat one had also given the adapter up. Each activity constructs its
own WfbNgLink, so with two of them the adapter belongs to neither.

ModeOwner makes that impossible: whoever starts last evicts the other, whose
onDestroy() releases the adapter. A WeakReference so a finished activity is not
retained.

The real answer is a single owner outliving both - a foreground service, which
would also fix the flat mode dropping the link in onPause - but this removes the
failure without that rework.

Also corrects a wrong reading in the previous commit: three threads named
"wfb-001/002" is *normal* for one adapter, because the TX and adaptive-link
threads inherit the RX thread's name - nothing renames them. It is not three RX
loops. The duplicate guard is still correct, since two activities each with
their own WfbNgLink genuinely can double-start, but the evidence quoted for it
was misread.

Confirmed on hardware in the same run: the MediaCodec low-latency keys from
OpenIPC#113 are accepted by Meta's H.265 decoder -
"low-latency: 1, vendor.qti-ext-dec-low-latency.enable: 1, priority: 0" followed
by "AMediaCodec_configure: OK".
The adapter, the wfb-ng link and the decoder belonged to whichever Activity was
on screen. That does not survive contact with a ground station:

- the flat activity tore the link down in onPause(), so a system menu, a file
  picker or taking the headset off cost seconds of black screen and a fresh USB
  enumeration
- with a second presentation mode there were two owners for one adapter. Both
  activities stayed alive in separate tasks (Horizon OS keeps the immersive one
  in its own task, so its onDestroy never ran) and the adapter ended up owned by
  neither - no video in either mode
- every mode switch reopened the adapter, which is also what made the handoff
  racy enough to need a claim guard, a bounded join and a retry loop

LinkService now owns all three and outlives both activities. Activities bind for
their whole lifetime, register as a LinkService.Client and hand over a Surface.
Switching modes only swaps the decoder's output surface: reception and the
adapter are never touched, so there is no reconnect and no second USB
permission prompt. It is a foreground service with a Stop action, because it has
to keep running while the pilot is looking at something else.

- LinkClientActivity: shared bind/unbind, bound in onCreate and unbound in
  onDestroy rather than around start/stop, which is the point.
- VideoPlayer gained attachSurface()/detachSurface(): a surface change rebuilds
  the decoder without stopping the receivers. stopAndRemoveReceiverDecoder()
  stopped the receivers too and nothing restarted them, which is why a hot
  surface swap was not possible before.
- VideoPlayer and WfbNgLink take a Context instead of an AppCompatActivity. A
  decoder and a radio link have no business requiring an Activity, and the
  service cannot provide one.
- the USB attach/detach receiver moved to the service: WfbLinkManager is that
  receiver, and it belongs to whoever owns the adapter. Registered by an
  activity, plug events were missed whenever none was around.
- the flat activity no longer stops the link in onPause/onStop, and no longer
  tears it down to show a folder picker.
- the service replays link status, resolution and stats to a client on attach,
  so a freshly opened mode never starts out blank.

ModeOwner stays: one mode at a time is still the right model, and two surfaces
on index 0 would fight.
Smoke test of the service refactor: the link came up and packets arrived, but
the decoder logged "inputPipeClosed." on every NALU - it had no output surface.

bindService() is asynchronous and the view hierarchy does not wait for it, so
SurfaceView.surfaceCreated() fires before onServiceConnected(). The callback
found link == null and silently dropped the surface, and nothing handed it over
afterwards.

The surface is now parked and attached either immediately or as soon as the
service connects, whichever comes second. The immersive activity is unaffected -
its surface comes from the XR session, which is only started from
onLinkServiceConnected().
There was none: every controller button and both pinch gestures were already
bound, so the only way back to the flat view was launching it from the launcher
and letting ModeOwner evict the session.

- left menu button, the conventional app button and the last one still free
- right thumb swipe left, so it also works without controllers, next to the
  swipe forward/backward that moves the panel

A single gesture rather than a held one is fine here because leaving is now
cheap: the link lives in LinkService, so switching back to the flat view keeps
the adapter open and the video running - a stray trigger costs a mode switch,
not a reconnect.

The status screen also lists the controls now. It is the only surface the
immersive mode has, and with everything on unlabelled buttons there was no way
to discover any of them.
BufferedPacketQueue reorders the RTP stream before it reaches the parser. When a packet is
missing it holds everything behind the gap, which is right for a reorder and wrong for a loss
- and past wfb-ng, on a loopback socket, a gap is almost always a loss that FEC could not
recover. Three things made that wait longer than it needs to be.

The monotonic-increase escape hatch, which exists precisely to notice "the sequence numbers
keep climbing but the gap is not filling", never fires. calculateDistance(a, b) is how far b
is ahead of a - seqLessThan reads it that way - but the call site asks for the distance from
the incoming packet to the last delivered one. For a gap ahead of us that is negative, so the
else branch clears the counter on every single packet.

Fixing the argument order alone is not enough: the counter is only incremented while
abs(dist) < MONOTONIC_THRESHOLD, and dist grows by one with every packet held back. Compared
against the same constant that gates it, the counter tops out at three and can never reach
five. The gate is now its own constant, so a run past a gap releases the buffer after five
packets instead of never.

That leaves MAX_BUFFER_SIZE as the only bound, and how much latency fifteen packets is depends
entirely on the packet rate: about 20ms on a 1080p video stream, but roughly 300ms on the
audio stream, which runs at a fraction of it. The buffer is now also bounded in time, so the
worst case is the same on both. Twenty milliseconds is a little over one frame at 60fps and
several orders of magnitude more than a loopback socket needs to reorder anything.

Nothing is dropped by any of this - the buffered packets are still delivered, just without
waiting on one that is not coming.

The test target could not be built on a case-sensitive filesystem, since CMakeLists.txt
spelled the source BufferedPacketqueue_test.cpp. The existing two cases both turn out to feed
a strictly in-order stream, so neither reached the buffer at all; the new ones cover a
permanent gap, the age bound, a reorder that resolves inside it, and a jump too large to be
one. They drive the clock themselves rather than reading it, so the age bound cannot make them
flaky on a loaded machine.
@iflyhere
iflyhere force-pushed the feature/xr-goggle-mode branch from e147b32 to 7d693e4 Compare September 2, 2026 18:14
The immersive activity carries its own MAIN/LAUNCHER entry so Horizon OS can start the app
straight into VR. On a phone that is a second icon which leads to an activity that cannot
create a session and immediately hands back, and there is no manifest condition for "this
device has an XR runtime".

So it ships disabled and VideoActivity.onCreate switches it on where isSupportedDevice()
agrees. A phone install is then indistinguishable from before this feature existed. On a
headset the first launch is the flat activity, which enables the entry, and the VR entry is
in the library from then on.

Verified on a Quest 3: after install only .VideoActivity resolves for MAIN/LAUNCHER and
nothing resolves for the VR category; after one flat launch .XrVideoActivity resolves for
both, and a second launch is a no-op rather than another package-manager write.

The menu item and the entry are gated on the same predicate, so an XR device that
isSupportedDevice() fails to recognise gets neither - widening that check is then the fix.
Two problems with the flush, both found in review.

restartBuffering() set mLastPacketIdx to the packet that triggered the flush rather than the
newest one delivered. With 1..5 delivered, 6 lost, then 7 8 9 12 10, the flush hands over
7 8 9 10 12 but leaves the pointer on 10 - so 11 would be delivered after 12, and 13..16 are
held until 17 arrives. The queue re-created the stall it exists to prevent. bufferPacket()
always runs before the flush, so the triggering packet is in the buffer either way and
mLastPacketIdx = drainBufferInOrder() is all that was needed; restartBuffering() is gone.

That alone would have broken the resync on a large jump, because drainBufferInOrder() seeded
`highest` from mLastPacketIdx and compared with calculateDistance(), which reads as negative
past half the sequence space. RTP starts at a random sequence number, so a VTX that reboots
mid-session lands exactly there: fed 1, 2, then 40000, 40001, ..., neither the seed nor the
comparison ever moves and every packet comes out one flush late, forever. On video the
fifteen-packet cap used to paper over it by rewinding to currPacketIdx; on audio nothing did.
`highest` is now seeded from a packet that is actually in the buffer.

While sorting the flush: by raw value a block straddling the wrap point (65534, 65535, 0, 1)
sorts to 0, 1, 65534, 65535 and was handed to the parser in that order. Sorting by distance
from the last delivered packet fixes the order and makes the last element the newest.

Three tests added, one per problem. The large-jump case uses 40000 - the existing
LargeJumpFallsBackToTheBufferCap uses 30000, which is still positive as an int16 and does not
reach any of this.
@iflyhere
iflyhere deleted the branch integration/feature-1 September 3, 2026 17:43
@iflyhere iflyhere closed this Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant