diff --git a/app/build.gradle b/app/build.gradle
index 66bbd35b..656a8089 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -59,6 +59,7 @@ dependencies {
implementation(project(":app:mavlink"))
implementation(project(":app:videonative"))
implementation(project(":app:wfbngrtl8812"))
+ implementation(project(":app:xr"))
implementation(libs.appcompat)
implementation(libs.material)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1ad6bc61..bbd64ffd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,24 @@
+
+
+
+
+
+
@@ -19,6 +37,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/openipc/pixelpilot/DvrFiles.java b/app/src/main/java/com/openipc/pixelpilot/DvrFiles.java
new file mode 100644
index 00000000..3db7e67b
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/DvrFiles.java
@@ -0,0 +1,98 @@
+package com.openipc.pixelpilot;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+
+import androidx.documentfile.provider.DocumentFile;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * DVR file naming and creation, shared by the flat and the immersive activity.
+ *
+ *
Everything here works off the already granted {@code dvr_folder_} tree URI. Picking a
+ * folder needs an Activity result and stays with the flat activity.
+ */
+public final class DvrFiles {
+ private static final String TAG = "pixelpilot";
+ private static final String PREF_DVR_FILENAME = "dvr_filename";
+ private static final String DEFAULT_TEMPLATE = "pixelpilot_[yyyyMMdd-HHmmss]";
+ private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\[([^\\]]*)\\]");
+
+ private DvrFiles() {
+ }
+
+ public static String template(Context context) {
+ return context.getSharedPreferences("general", Context.MODE_PRIVATE)
+ .getString(PREF_DVR_FILENAME, DEFAULT_TEMPLATE);
+ }
+
+ public static void setTemplate(Context context, String template) {
+ context.getSharedPreferences("general", Context.MODE_PRIVATE)
+ .edit().putString(PREF_DVR_FILENAME, template).apply();
+ }
+
+ public static boolean fragmentedMp4(Context context) {
+ return context.getSharedPreferences("general", Context.MODE_PRIVATE)
+ .getBoolean("dvr_fmp4", true);
+ }
+
+ /** Expands the {@code [pattern]} placeholder in a file name template. */
+ public static String fileName(String template, LocalDateTime time) {
+ Matcher matcher = TEMPLATE_PATTERN.matcher(template);
+ String fallbackTime = time.format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
+
+ if (matcher.find()) {
+ String prefix = template.substring(0, matcher.start());
+ String pattern = matcher.group(1);
+ String suffix = template.substring(matcher.end());
+
+ try {
+ String timePart = time.format(DateTimeFormatter.ofPattern(pattern));
+ return prefix + timePart + suffix;
+ } catch (IllegalArgumentException e) {
+ return prefix + fallbackTime + suffix;
+ }
+ }
+ return "pixelpilot_" + fallbackTime;
+ }
+
+ public static boolean hasRecordingFolder(Context context) {
+ return !context.getSharedPreferences("general", Context.MODE_PRIVATE)
+ .getString("dvr_folder_", "").isEmpty();
+ }
+
+ /**
+ * Creates the next recording file in the configured folder.
+ *
+ * @return its URI, or null if no folder is configured or it is not writable
+ */
+ public static Uri createRecording(Context context) {
+ String dvrFolder = context.getSharedPreferences("general", Context.MODE_PRIVATE)
+ .getString("dvr_folder_", "");
+ if (dvrFolder.isEmpty()) {
+ Log.e(TAG, "dvrFolder is empty");
+ return null;
+ }
+ DocumentFile pickedDir = DocumentFile.fromTreeUri(context, Uri.parse(dvrFolder));
+ if (pickedDir == null || !pickedDir.canWrite()) {
+ return null;
+ }
+ String name = fileName(template(context), LocalDateTime.now()) + ".mp4";
+ DocumentFile newFile = pickedDir.createFile("video/mp4", name);
+ if (newFile == null) {
+ Log.e(TAG, "dvr newFile null");
+ return null;
+ }
+ return newFile.getUri();
+ }
+
+ /** File name the next {@link #createRecording} would use, for UI previews. */
+ public static String nextFileName(Context context) {
+ return fileName(template(context), LocalDateTime.now()) + ".mp4";
+ }
+}
diff --git a/app/src/main/java/com/openipc/pixelpilot/GsKey.java b/app/src/main/java/com/openipc/pixelpilot/GsKey.java
new file mode 100644
index 00000000..3f5d8e3c
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/GsKey.java
@@ -0,0 +1,87 @@
+package com.openipc.pixelpilot;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Base64;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * The wfb-ng ground station key, shared by the flat and the immersive activity.
+ *
+ *
The key lives in preferences (so a user-picked one survives) and has to be
+ * materialised as {@code files/gs.key} before {@code WfbNgLink} is constructed: the native
+ * aggregators open that path in their constructor and throw if it is missing.
+ */
+public final class GsKey {
+ private static final String TAG = "pixelpilot";
+ private static final String PREF_KEY = "gs.key";
+ private static final String FILE_NAME = "gs.key";
+
+ private GsKey() {
+ }
+
+ public static byte[] get(Context context) {
+ String pref = context.getSharedPreferences("general", Context.MODE_PRIVATE)
+ .getString(PREF_KEY, "");
+ return Base64.decode(pref, Base64.DEFAULT);
+ }
+
+ public static void set(Context context, InputStream inputStream) throws IOException {
+ ByteArrayOutputStream result = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int length;
+ while ((length = inputStream.read(buffer)) != -1) {
+ result.write(buffer, 0, length);
+ }
+ SharedPreferences prefs = context.getSharedPreferences("general", Context.MODE_PRIVATE);
+ prefs.edit().putString(PREF_KEY, Base64.encodeToString(result.toByteArray(), Base64.DEFAULT)).apply();
+ }
+
+ /** Seeds preferences from the bundled asset the first time round. */
+ public static void importDefaultIfMissing(Context context) {
+ if (get(context).length > 0) {
+ Log.d(TAG, "gs.key already saved in preferences.");
+ return;
+ }
+ try (InputStream inputStream = context.getAssets().open(FILE_NAME)) {
+ Log.d(TAG, "Importing default gs.key...");
+ set(context, inputStream);
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to import default gs.key", e);
+ }
+ }
+
+ /** Writes the key from preferences to {@code files/gs.key}. */
+ public static boolean writeToFilesDir(Context context) {
+ byte[] keyBytes = get(context);
+ if (keyBytes.length == 0) {
+ Log.e(TAG, "No gs.key available to write");
+ return false;
+ }
+ File file = new File(context.getApplicationContext().getFilesDir(), FILE_NAME);
+ try (OutputStream out = new FileOutputStream(file)) {
+ out.write(keyBytes, 0, keyBytes.length);
+ Log.d(TAG, "Wrote gs.key (" + keyBytes.length + " bytes) to " + file.getAbsolutePath());
+ return true;
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to write gs.key", e);
+ return false;
+ }
+ }
+
+ /**
+ * Everything that has to happen before {@code new WfbNgLink(...)}. Call from any
+ * activity that brings up the link.
+ */
+ public static boolean ensure(Context context) {
+ importDefaultIfMissing(context);
+ return writeToFilesDir(context);
+ }
+}
diff --git a/app/src/main/java/com/openipc/pixelpilot/LinkClientActivity.java b/app/src/main/java/com/openipc/pixelpilot/LinkClientActivity.java
new file mode 100644
index 00000000..65f9a6c4
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/LinkClientActivity.java
@@ -0,0 +1,98 @@
+package com.openipc.pixelpilot;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.openipc.videonative.DecodingInfo;
+import com.openipc.wfbngrtl8812.WfbNGStats;
+
+/**
+ * Binds to {@link LinkService} for the lifetime of the activity.
+ *
+ *
Bound in {@code onCreate} and unbound in {@code onDestroy} rather than around
+ * start/stop: the point of the service is that the link survives the activity being paused,
+ * so a system menu or taking the headset off no longer costs a reconnect.
+ */
+public abstract class LinkClientActivity extends AppCompatActivity implements LinkService.Client {
+
+ private static final String TAG = "pixelpilot";
+
+ @Nullable
+ protected LinkService link;
+ private boolean bound;
+
+ private final ServiceConnection connection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ link = ((LinkService.LocalBinder) service).service();
+ link.addClient(LinkClientActivity.this);
+ Log.i(TAG, "attached to the link service");
+ onLinkServiceConnected(link);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ // The process is going away; nothing useful to do but stop talking to it.
+ link = null;
+ }
+ };
+
+ /** Called on the main thread once the link is available. */
+ protected abstract void onLinkServiceConnected(LinkService service);
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Intent intent = new Intent(this, LinkService.class);
+ // Started as well as bound, so it keeps running while no activity is attached - that
+ // is what carries the link across a mode switch.
+ startForegroundService(intent);
+ bound = bindService(intent, connection, Context.BIND_AUTO_CREATE);
+ if (!bound) {
+ Log.e(TAG, "could not bind the link service");
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (link != null) {
+ link.removeClient(this);
+ }
+ if (bound) {
+ unbindService(connection);
+ bound = false;
+ }
+ link = null;
+ super.onDestroy();
+ }
+
+ // Defaults so a subclass only overrides what it draws.
+
+ @Override
+ public void onLinkStatus(String message) {
+ }
+
+ @Override
+ public void onLocalStreamHint(String url) {
+ }
+
+ @Override
+ public void onVideoResolution(int width, int height) {
+ }
+
+ @Override
+ public void onDecodingInfo(DecodingInfo info) {
+ }
+
+ @Override
+ public void onWfbStats(WfbNGStats stats) {
+ }
+}
diff --git a/app/src/main/java/com/openipc/pixelpilot/LinkService.java b/app/src/main/java/com/openipc/pixelpilot/LinkService.java
new file mode 100644
index 00000000..72d81d0a
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/LinkService.java
@@ -0,0 +1,405 @@
+package com.openipc.pixelpilot;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.usb.UsbManager;
+import android.os.Binder;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.util.Log;
+import android.view.Surface;
+
+import androidx.annotation.Nullable;
+
+import com.openipc.videonative.DecodingInfo;
+import com.openipc.videonative.IVideoParamsChanged;
+import com.openipc.videonative.VideoPlayer;
+import com.openipc.wfbngrtl8812.WfbNGStats;
+import com.openipc.wfbngrtl8812.WfbNGStatsChanged;
+import com.openipc.wfbngrtl8812.WfbNgLink;
+
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Sole owner of the wifi adapter, the wfb-ng link and the decoder.
+ *
+ *
These used to belong to whichever Activity was on screen, which does not survive contact
+ * with a ground station: the flat activity tore the link down in {@code onPause()} (so
+ * glancing at a system menu cost seconds of black screen), and with a second presentation
+ * mode there were two owners for one adapter - both activities ended up alive in separate
+ * tasks with the adapter owned by neither, and no video in either mode.
+ *
+ *
Now the link outlives the activities. They bind, register as a {@link Client}, and hand
+ * over a Surface; switching modes only swaps the Surface, so the adapter is never reopened
+ * and the USB permission dialog is not asked again. The service is a foreground service
+ * because it must keep running while the pilot is looking at something else.
+ */
+public class LinkService extends Service implements LinkStatusView, IVideoParamsChanged, WfbNGStatsChanged {
+
+ public static final String ACTION_STOP = "com.openipc.pixelpilot.LINK_STOP";
+
+ private static final String TAG = "pixelpilot";
+ private static final String CHANNEL_ID = "link";
+ private static final int NOTIFICATION_ID = 1;
+
+ /**
+ * Grace period after the last client unbinds. A mode switch unbinds one activity and
+ * binds the next, and tearing the link down in between is exactly what this service
+ * exists to avoid.
+ */
+ private static final long IDLE_SHUTDOWN_MS = 20_000;
+
+ /** What an attached activity gets told. All callbacks arrive on the main thread. */
+ public interface Client {
+ void onLinkStatus(String message);
+
+ void onLocalStreamHint(String url);
+
+ void onVideoResolution(int width, int height);
+
+ void onDecodingInfo(DecodingInfo info);
+
+ void onWfbStats(WfbNGStats stats);
+ }
+
+ public class LocalBinder extends Binder {
+ public LinkService service() {
+ return LinkService.this;
+ }
+ }
+
+ private final LocalBinder binder = new LocalBinder();
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private final CopyOnWriteArrayList clients = new CopyOnWriteArrayList<>();
+
+ private WfbNgLink wfbLink;
+ private WfbLinkManager wfbLinkManager;
+ private VideoPlayer videoPlayer;
+
+ private boolean ready;
+ private boolean usbReceiverRegistered;
+ private String lastStatus = "";
+ private WfbNGStats lastStats;
+ private int videoWidth;
+ private int videoHeight;
+
+ private final Runnable idleShutdown = () -> {
+ Log.i(TAG, "no client came back, stopping the link service");
+ stopSelf();
+ };
+
+ // ------------------------------------------------------------------------------
+ // lifecycle
+ // ------------------------------------------------------------------------------
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ startForegroundNotification();
+
+ // The native aggregators open files/gs.key in their constructor and throw if it is
+ // missing, so this has to happen before WfbNgLink exists.
+ if (!GsKey.ensure(this)) {
+ Log.e(TAG, "no usable gs.key, the link cannot come up");
+ }
+
+ wfbLink = new WfbNgLink(this);
+ wfbLink.SetWfbNGStatsChanged(this);
+ // Not read by the native side on its own: adaptive link, TX power, FEC/LDPC/STBC.
+ WfbOptions.applyDefaults(this, wfbLink);
+
+ wfbLinkManager = new WfbLinkManager(this, this, wfbLink);
+
+ videoPlayer = new VideoPlayer(this);
+ videoPlayer.setIVideoParamsChanged(this);
+ videoPlayer.setLowLatency(VideoActivity.getLowLatencySetting(this));
+ // Receivers come up once and stay up; only the output surface changes with the mode.
+ videoPlayer.start();
+ videoPlayer.startAudio();
+
+ registerUsbReceiver();
+
+ ready = true;
+ Log.i(TAG, "link service up");
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent != null && ACTION_STOP.equals(intent.getAction())) {
+ Log.i(TAG, "stop requested from the notification");
+ stopSelf();
+ return START_NOT_STICKY;
+ }
+ return START_STICKY;
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ main.removeCallbacks(idleShutdown);
+ return binder;
+ }
+
+ @Override
+ public void onRebind(Intent intent) {
+ main.removeCallbacks(idleShutdown);
+ }
+
+ @Override
+ public boolean onUnbind(Intent intent) {
+ if (clients.isEmpty()) {
+ main.postDelayed(idleShutdown, IDLE_SHUTDOWN_MS);
+ }
+ // Allow onRebind, which is how a mode switch keeps the link alive.
+ return true;
+ }
+
+ @Override
+ public void onDestroy() {
+ Log.i(TAG, "link service going down");
+ main.removeCallbacks(idleShutdown);
+ ready = false;
+ if (usbReceiverRegistered) {
+ try {
+ unregisterReceiver(wfbLinkManager);
+ } catch (IllegalArgumentException ignored) {
+ }
+ usbReceiverRegistered = false;
+ }
+ if (wfbLinkManager != null) {
+ wfbLinkManager.stopAdapters();
+ }
+ if (videoPlayer != null) {
+ videoPlayer.stopAudio();
+ videoPlayer.stop();
+ }
+ super.onDestroy();
+ }
+
+ /**
+ * WfbLinkManager is itself the USB attach/detach receiver, and it has to be registered by
+ * whoever owns the adapter - previously an Activity, which meant plug events were missed
+ * whenever no activity was around.
+ */
+ private void registerUsbReceiver() {
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
+ filter.addAction(WfbLinkManager.ACTION_USB_PERMISSION);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(wfbLinkManager, filter, Context.RECEIVER_NOT_EXPORTED);
+ } else {
+ registerReceiver(wfbLinkManager, filter);
+ }
+ usbReceiverRegistered = true;
+ }
+
+ private void startForegroundNotification() {
+ NotificationManager manager = getSystemService(NotificationManager.class);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && manager != null
+ && manager.getNotificationChannel(CHANNEL_ID) == null) {
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID, "Video link", NotificationManager.IMPORTANCE_LOW);
+ channel.setShowBadge(false);
+ manager.createNotificationChannel(channel);
+ }
+
+ Intent stop = new Intent(this, LinkService.class).setAction(ACTION_STOP);
+ PendingIntent stopIntent = PendingIntent.getService(
+ this, 0, stop, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+
+ Notification notification = new Notification.Builder(this, CHANNEL_ID)
+ .setContentTitle("PixelPilot link")
+ .setContentText("Holding the wifi adapter and decoder")
+ .setSmallIcon(R.drawable.baseline_settings_24)
+ .setOngoing(true)
+ .addAction(new Notification.Action.Builder(null, "Stop", stopIntent).build())
+ .build();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, notification,
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+ } else {
+ startForeground(NOTIFICATION_ID, notification);
+ }
+ }
+
+ // ------------------------------------------------------------------------------
+ // client API
+ // ------------------------------------------------------------------------------
+
+ public boolean isReady() {
+ return ready;
+ }
+
+ /** Registers a client and replays the state it missed, so it never starts out blank. */
+ public void addClient(Client client) {
+ clients.addIfAbsent(client);
+ main.removeCallbacks(idleShutdown);
+ if (!lastStatus.isEmpty()) {
+ client.onLinkStatus(lastStatus);
+ }
+ if (videoWidth > 0 && videoHeight > 0) {
+ client.onVideoResolution(videoWidth, videoHeight);
+ }
+ WfbNGStats stats = lastStats;
+ if (stats != null) {
+ client.onWfbStats(stats);
+ }
+ }
+
+ public void removeClient(Client client) {
+ clients.remove(client);
+ }
+
+ /** Points the decoder at a new output surface. The link keeps running throughout. */
+ public void attachSurface(Surface surface, int index) {
+ if (videoPlayer != null) {
+ videoPlayer.attachSurface(surface, index);
+ }
+ }
+
+ public void detachSurface(int index) {
+ if (videoPlayer != null) {
+ videoPlayer.detachSurface(index);
+ }
+ }
+
+ /** Brings the adapter up if it is not already, using the persisted channel/bandwidth. */
+ public void ensureAdapters() {
+ if (wfbLinkManager == null) {
+ return;
+ }
+ wfbLinkManager.setChannel(VideoActivity.getChannel(this));
+ wfbLinkManager.setBandwidth(VideoActivity.getBandwidth(this));
+ wfbLinkManager.refreshAdapters();
+ }
+
+ public boolean hasActiveAdapter() {
+ return wfbLinkManager != null && wfbLinkManager.hasActiveAdapter();
+ }
+
+ public void setChannel(int channel) {
+ if (wfbLinkManager == null) {
+ return;
+ }
+ wfbLinkManager.stopAdapters();
+ wfbLinkManager.setChannel(channel);
+ wfbLinkManager.startAdapters();
+ }
+
+ public void setBandwidth(int bandwidth) {
+ if (wfbLinkManager == null) {
+ return;
+ }
+ wfbLinkManager.stopAdapters();
+ wfbLinkManager.setBandwidth(bandwidth);
+ wfbLinkManager.startAdapters();
+ }
+
+ public void refreshKey() {
+ if (wfbLinkManager != null) {
+ wfbLinkManager.refreshKey();
+ }
+ }
+
+ /** For the settings menus that drive the native link options directly. */
+ @Nullable
+ public WfbNgLink link() {
+ return wfbLink;
+ }
+
+ public void startDvr(int fd, boolean fragmentedMp4) {
+ if (videoPlayer != null) {
+ videoPlayer.startDvr(fd, fragmentedMp4);
+ }
+ }
+
+ public void stopDvr() {
+ if (videoPlayer != null) {
+ videoPlayer.stopDvr();
+ }
+ }
+
+ public void setUdpForwarding(String ip, int port, boolean enabled) {
+ if (videoPlayer != null) {
+ videoPlayer.setUdpForwarding(ip, port, enabled);
+ }
+ }
+
+ public void setLowLatency(boolean enabled) {
+ if (videoPlayer != null) {
+ videoPlayer.setLowLatency(enabled);
+ }
+ }
+
+ public int videoWidth() {
+ return videoWidth;
+ }
+
+ public int videoHeight() {
+ return videoHeight;
+ }
+
+ // ------------------------------------------------------------------------------
+ // fan-out to the attached activity
+ // ------------------------------------------------------------------------------
+
+ @Override
+ public void showLinkMessage(String message) {
+ Log.i(TAG, "link: " + message);
+ lastStatus = message;
+ main.post(() -> {
+ for (Client c : clients) {
+ c.onLinkStatus(message);
+ }
+ });
+ }
+
+ @Override
+ public void showLocalStreamHint(String url) {
+ main.post(() -> {
+ for (Client c : clients) {
+ c.onLocalStreamHint(url);
+ }
+ });
+ }
+
+ @Override
+ public void onVideoRatioChanged(int width, int height) {
+ videoWidth = width;
+ videoHeight = height;
+ main.post(() -> {
+ for (Client c : clients) {
+ c.onVideoResolution(width, height);
+ }
+ });
+ }
+
+ @Override
+ public void onDecodingInfoChanged(DecodingInfo decodingInfo) {
+ main.post(() -> {
+ for (Client c : clients) {
+ c.onDecodingInfo(decodingInfo);
+ }
+ });
+ }
+
+ @Override
+ public void onWfbNgStatsChanged(WfbNGStats stats) {
+ lastStats = stats;
+ main.post(() -> {
+ for (Client c : clients) {
+ c.onWfbStats(stats);
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/openipc/pixelpilot/LinkStatusView.java b/app/src/main/java/com/openipc/pixelpilot/LinkStatusView.java
new file mode 100644
index 00000000..3d74a53b
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/LinkStatusView.java
@@ -0,0 +1,13 @@
+package com.openipc.pixelpilot;
+
+/**
+ * Where {@link WfbLinkManager} reports adapter/link status to. The flat activity draws it
+ * into its overlay; the immersive activity has no view hierarchy and logs it instead.
+ */
+public interface LinkStatusView {
+ /** Adapter or link state the pilot needs to see (starting, no permission, ...). */
+ void showLinkMessage(String message);
+
+ /** Shown when no compatible adapter is attached: where to push a stream instead. */
+ void showLocalStreamHint(String url);
+}
diff --git a/app/src/main/java/com/openipc/pixelpilot/ModeOwner.java b/app/src/main/java/com/openipc/pixelpilot/ModeOwner.java
new file mode 100644
index 00000000..80443d10
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/ModeOwner.java
@@ -0,0 +1,45 @@
+package com.openipc.pixelpilot;
+
+import android.app.Activity;
+import android.util.Log;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * Guarantees that only one of the two presentation modes is alive at a time.
+ *
+ * The flat and the immersive activity each construct their own {@code WfbNgLink}, and the
+ * wifi adapter can only belong to one of them. Horizon OS keeps the immersive activity in its
+ * own task, so switching modes left both of them resumed in separate tasks with the adapter
+ * owned by neither, and no video in either mode.
+ *
+ *
Rather than trying to make two owners cooperate, whoever starts last evicts the other
+ * and its {@code onDestroy()} releases the adapter. The proper answer is a single owner that
+ * outlives both - a foreground service - but this removes the failure without that rework.
+ */
+final class ModeOwner {
+ private static final String TAG = "pixelpilot";
+
+ private static WeakReference current = new WeakReference<>(null);
+
+ private ModeOwner() {
+ }
+
+ /** Registers {@code activity} as the mode owner and finishes whoever held it before. */
+ static synchronized void claim(Activity activity) {
+ Activity previous = current.get();
+ if (previous != null && previous != activity && !previous.isFinishing()) {
+ Log.i(TAG, "mode switch: finishing " + previous.getClass().getSimpleName()
+ + " so " + activity.getClass().getSimpleName() + " can own the adapter");
+ previous.finish();
+ }
+ current = new WeakReference<>(activity);
+ }
+
+ /** Clears the registration if {@code activity} still holds it. */
+ static synchronized void release(Activity activity) {
+ if (current.get() == activity) {
+ current = new WeakReference<>(null);
+ }
+ }
+}
diff --git a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
index e126fa2e..7bd5a00f 100644
--- a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
+++ b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
@@ -16,6 +16,7 @@
import android.hardware.usb.UsbManager;
import android.net.Uri;
import android.net.VpnService;
+import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.BatteryManager;
import android.os.Build;
@@ -60,11 +61,8 @@
import com.openipc.pixelpilot.osd.OSDElement;
import com.openipc.pixelpilot.osd.OSDManager;
import com.openipc.videonative.DecodingInfo;
-import com.openipc.videonative.IVideoParamsChanged;
-import com.openipc.videonative.VideoPlayer;
import com.openipc.wfbngrtl8812.WfbNGStats;
-import com.openipc.wfbngrtl8812.WfbNGStatsChanged;
-import com.openipc.wfbngrtl8812.WfbNgLink;
+import com.openipc.xr.XrGoggleSession;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
@@ -92,6 +90,7 @@
import android.os.SystemClock;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import android.view.SurfaceHolder;
import android.view.TextureView;
import android.graphics.SurfaceTexture;
import android.view.Surface;
@@ -100,8 +99,8 @@
// Most basic implementation of an activity that uses VideoNative to stream a video
// Into an Android Surface View
-public class VideoActivity extends AppCompatActivity implements IVideoParamsChanged,
- WfbNGStatsChanged, MavlinkUpdate, SettingsChanged {
+public class VideoActivity extends LinkClientActivity
+ implements MavlinkUpdate, SettingsChanged {
private static final String TAG = "pixelpilot";
private static final int PICK_KEY_REQUEST_CODE = 1;
private static final int PICK_DVR_REQUEST_CODE = 2;
@@ -114,7 +113,6 @@ public class VideoActivity extends AppCompatActivity implements IVideoParamsChan
private static final long MODEL_LITE2_BYTES = 23096891L;
private static final String PREF_OD_CUSTOM_MODEL_URI = "od_custom_model_uri";
private static final String PREF_OD_CUSTOM_MODEL_NAME = "od_custom_model_name";
- private static WifiManager wifiManager;
final Handler handler = new Handler(Looper.getMainLooper());
final Runnable runnable = new Runnable() {
public void run() {
@@ -124,9 +122,7 @@ public void run() {
};
protected DecodingInfo mDecodingInfo;
int lastVideoW = 0, lastVideoH = 0, lastCodec = 1;
- WfbLinkManager wfbLinkManager;
BroadcastReceiver batteryReceiver;
- VideoPlayer videoPlayer;
private ActivityVideoBinding binding;
private OSDManager osdManager;
private ParcelFileDescriptor dvrFd = null;
@@ -137,9 +133,11 @@ public void run() {
// Which view the main video is rendered into. SurfaceView is the low latency
// default; the TextureView is only used when object detection needs getBitmap().
private boolean videoUsesTextureView = false;
+ // A Surface can become available before the service has bound - binding is async and the
+ // view hierarchy does not wait for it - so it is parked here until the link exists.
+ private final Surface[] pendingSurfaces = new Surface[2];
private ConstraintLayout constraintLayout;
private ConstraintSet constraintSet;
- private WfbNgLink wfbLink;
private ObjectDetectorHelper objectDetectorHelper;
private ExecutorService objectDetectionExecutor;
@@ -178,8 +176,22 @@ public static int getBandwidth(Context context) {
Context.MODE_PRIVATE).getInt("bandwidth", 20);
}
- public static String wirelessInfo() {
- int address = wifiManager.getConnectionInfo().getIpAddress();
+ /**
+ * IPv4 address of the phone/headset on its own wifi, used to tell the pilot where to
+ * push a stream when no adapter is attached. Returns null when there is nothing to
+ * report - a headset in particular may have no WifiManager state to read at all.
+ */
+ public static String wirelessInfo(Context context) {
+ WifiManager manager =
+ (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ if (manager == null) {
+ return null;
+ }
+ WifiInfo info = manager.getConnectionInfo();
+ if (info == null) {
+ return null;
+ }
+ int address = info.getIpAddress();
return (address == 0) ? null : Formatter.formatIpAddress(address);
}
@@ -284,6 +296,20 @@ protected void onCreate(Bundle savedInstanceState) {
Log.d(TAG, "lifecycle onCreate");
super.onCreate(savedInstanceState);
+ // The USB_DEVICE_ATTACHED filter is on this activity, so replugging the adapter
+ // starts it even while the immersive activity owns the link. Two activities fighting
+ // over one adapter and one decoder means neither gets video, so step aside.
+ if (XrVideoActivity.isActive()
+ && UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(getIntent().getAction())) {
+ Log.i(TAG, "adapter attached while the immersive session is running, staying out of it");
+ finish();
+ return;
+ }
+ ModeOwner.claim(this);
+
+ // Keep the immersive launcher entry in step with what this device can run.
+ XrVideoActivity.syncLauncherEntry(this);
+
// UI Setup
initializeUI();
@@ -332,7 +358,6 @@ private void initializeUI() {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
- wifiManager = (WifiManager) getSystemService(WIFI_SERVICE);
}
// ----------------------------------------------------------------------------
@@ -343,12 +368,13 @@ private void initializeUI() {
* Initializes WFB-NG related logic such as setting default gs.key and linking
* to WFB-NG stats changes.
*/
+ /**
+ * The adapter, the link and the decoder live in {@link LinkService}; this only makes sure
+ * the key it needs exists before the service constructs the native side.
+ */
private void initializeWfbNg() {
setDefaultGsKey();
copyGSKey();
- wfbLink = new WfbNgLink(this);
- wfbLink.SetWfbNGStatsChanged(this);
- wfbLinkManager = new WfbLinkManager(this, binding, wfbLink);
}
// ----------------------------------------------------------------------------
@@ -359,10 +385,6 @@ private void initializeWfbNg() {
* Initializes VideoPlayer and configures surfaces for VR or standard mode.
*/
private void initializeVideoPlayers() {
- videoPlayer = new VideoPlayer(this);
- videoPlayer.setIVideoParamsChanged(this);
- videoPlayer.setLowLatency(getLowLatencySetting(this));
-
isVRMode = getVRSetting();
if (isVRMode) {
@@ -378,8 +400,8 @@ private void initializeVideoPlayers() {
private void setupVRVideoPlayers() {
binding.mainVideo.setVisibility(View.GONE);
binding.mainVideoSurface.setVisibility(View.GONE);
- binding.surfaceViewLeft.getHolder().addCallback(videoPlayer.configure1(0));
- binding.surfaceViewRight.getHolder().addCallback(videoPlayer.configure1(1));
+ binding.surfaceViewLeft.getHolder().addCallback(surfaceCallback(0));
+ binding.surfaceViewRight.getHolder().addCallback(surfaceCallback(1));
}
/**
@@ -398,14 +420,74 @@ private void setupStandardVideoPlayer() {
if (videoUsesTextureView) {
binding.mainVideoSurface.setVisibility(View.GONE);
binding.mainVideo.setVisibility(View.VISIBLE);
- binding.mainVideo.setSurfaceTextureListener(videoPlayer.configureTextureView(0));
+ binding.mainVideo.setSurfaceTextureListener(surfaceTextureListener(0));
} else {
binding.mainVideo.setVisibility(View.GONE);
binding.mainVideoSurface.setVisibility(View.VISIBLE);
- binding.mainVideoSurface.getHolder().addCallback(videoPlayer.configure1(0));
+ binding.mainVideoSurface.getHolder().addCallback(surfaceCallback(0));
+ }
+ }
+
+ /**
+ * Hands a Surface to the service and takes it back again. Only the decoder's output
+ * changes; reception and the adapter are untouched, which is what makes switching modes
+ * and surviving a pause free of a reconnect.
+ */
+ private SurfaceHolder.Callback surfaceCallback(final int index) {
+ return new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ provideSurface(holder.getSurface(), index);
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ withdrawSurface(index);
+ }
+ };
+ }
+
+ private void provideSurface(Surface surface, int index) {
+ pendingSurfaces[index] = surface;
+ if (link != null) {
+ link.attachSurface(surface, index);
}
}
+ private void withdrawSurface(int index) {
+ pendingSurfaces[index] = null;
+ if (link != null) {
+ link.detachSurface(index);
+ }
+ }
+
+ private TextureView.SurfaceTextureListener surfaceTextureListener(final int index) {
+ return new TextureView.SurfaceTextureListener() {
+ @Override
+ public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) {
+ provideSurface(new Surface(texture), index);
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) {
+ }
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
+ withdrawSurface(index);
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(SurfaceTexture texture) {
+ }
+ };
+ }
+
// ----------------------------------------------------------------------------
// VR SEEK BARS (only in VR mode)
// ----------------------------------------------------------------------------
@@ -606,6 +688,9 @@ private void showSettingsMenu(View anchor) {
// VR submenu
setupVRSubMenu(popup);
+ // Immersive OpenXR submenu, headsets only
+ setupXrSubMenu(popup);
+
// Channel submenu
setupChannelSubMenu(popup);
@@ -659,6 +744,77 @@ private void setupVRSubMenu(PopupMenu popup) {
});
}
+ /**
+ * Submenu for the immersive OpenXR mode. Only offered on a device that looks like a
+ * headset - on a phone there is no runtime to talk to, so the entry stays hidden.
+ */
+ private void setupXrSubMenu(PopupMenu popup) {
+ if (!XrGoggleSession.isSupportedDevice(this)) {
+ return;
+ }
+ SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE);
+ SubMenu xrMenu = popup.getMenu().addSubMenu("VR goggles (OpenXR)");
+
+ xrMenu.add("Enter VR").setOnMenuItemClickListener(item -> {
+ startActivity(new Intent(this, XrVideoActivity.class));
+ return true;
+ });
+
+ MenuItem headLocked = xrMenu.add("Head locked");
+ headLocked.setCheckable(true);
+ headLocked.setChecked(prefs.getBoolean(XrVideoActivity.PREF_HEAD_LOCKED, true));
+ headLocked.setOnMenuItemClickListener(item -> {
+ boolean enabled = !item.isChecked();
+ item.setChecked(enabled);
+ prefs.edit().putBoolean(XrVideoActivity.PREF_HEAD_LOCKED, enabled).apply();
+ item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW);
+ item.setActionView(new View(this));
+ return false;
+ });
+
+ MenuItem passthrough = xrMenu.add("Passthrough");
+ passthrough.setCheckable(true);
+ passthrough.setChecked(prefs.getBoolean(XrVideoActivity.PREF_PASSTHROUGH, false));
+ passthrough.setOnMenuItemClickListener(item -> {
+ boolean enabled = !item.isChecked();
+ item.setChecked(enabled);
+ prefs.edit().putBoolean(XrVideoActivity.PREF_PASSTHROUGH, enabled).apply();
+ item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW);
+ item.setActionView(new View(this));
+ return false;
+ });
+
+ MenuItem sharpening = xrMenu.add("Layer sharpening");
+ sharpening.setCheckable(true);
+ sharpening.setChecked(prefs.getBoolean(XrVideoActivity.PREF_SHARPENING, true));
+ sharpening.setOnMenuItemClickListener(item -> {
+ boolean enabled = !item.isChecked();
+ item.setChecked(enabled);
+ prefs.edit().putBoolean(XrVideoActivity.PREF_SHARPENING, enabled).apply();
+ item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW);
+ item.setActionView(new View(this));
+ return false;
+ });
+
+ SubMenu refresh = xrMenu.addSubMenu("Display refresh rate");
+ float currentHz = prefs.getFloat(XrVideoActivity.PREF_REFRESH_HZ, 0f);
+ final float[] options = {0f, 72f, 90f, 120f};
+ for (float hz : options) {
+ String label = hz <= 0f ? "Auto (highest)" : String.format(Locale.US, "%.0f Hz", hz);
+ MenuItem entry = refresh.add(label);
+ entry.setCheckable(true);
+ entry.setChecked(Math.abs(currentHz - hz) < 0.5f);
+ final float selected = hz;
+ entry.setOnMenuItemClickListener(item -> {
+ prefs.edit().putFloat(XrVideoActivity.PREF_REFRESH_HZ, selected).apply();
+ return true;
+ });
+ }
+
+ MenuItem help = xrMenu.add("Controls: A recenter, B passthrough, X record, Y lock");
+ help.setEnabled(false);
+ }
+
/**
* Submenu that lists available channels and allows the user to select one.
*/
@@ -716,7 +872,7 @@ private void setupVideoSubMenu(PopupMenu popup) {
item.setChecked(enabled);
getSharedPreferences("general", MODE_PRIVATE).edit()
.putBoolean("low_latency_decoder", enabled).apply();
- videoPlayer.setLowLatency(enabled);
+ if (link != null) link.setLowLatency(enabled);
Toast.makeText(this, "Low latency " + (enabled ? "enabled" : "disabled")
+ ", applies on next video start.", Toast.LENGTH_SHORT).show();
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW);
@@ -791,8 +947,7 @@ private void setupAdaptiveLinkSubMenu(PopupMenu popup) {
SharedPreferences.Editor editor = getSharedPreferences("general", MODE_PRIVATE).edit();
editor.putBoolean("adaptive_link_enabled", newState);
editor.apply();
- // Call instance method on the WfbNgLink instance via the wfbLinkManager.
- wfbLink.nativeSetAdaptiveLinkEnabled(newState);
+ if (link != null && link.link() != null) link.link().nativeSetAdaptiveLinkEnabled(newState);
return true;
});
@@ -814,8 +969,7 @@ private void setupAdaptiveLinkSubMenu(PopupMenu popup) {
SharedPreferences.Editor editor = getSharedPreferences("general", MODE_PRIVATE).edit();
editor.putInt("adaptive_tx_power", power);
editor.apply();
- // Call instance method on the WfbNgLink instance via the wfbLinkManager.
- wfbLink.nativeSetTxPower(power);
+ if (link != null && link.link() != null) link.link().nativeSetTxPower(power);
return true;
});
}
@@ -832,8 +986,7 @@ private void setupAdaptiveLinkSubMenu(PopupMenu popup) {
SharedPreferences.Editor editor = getSharedPreferences("general", MODE_PRIVATE).edit();
editor.putBoolean("custom_fec_enabled", newState);
editor.apply();
- // Call instance method on the WfbNgLink instance via the wfbLinkManager.
- wfbLink.nativeSetUseFec(newState ? 1 : 0);
+ if (link != null && link.link() != null) link.link().nativeSetUseFec(newState ? 1 : 0);
return true;
});
@@ -848,7 +1001,7 @@ private void setupAdaptiveLinkSubMenu(PopupMenu popup) {
SharedPreferences.Editor editor = getSharedPreferences("general", MODE_PRIVATE).edit();
editor.putBoolean("custom_ldpc_enabled", newState);
editor.apply();
- wfbLink.nativeSetUseLdpc(newState ? 1 : 0);
+ if (link != null && link.link() != null) link.link().nativeSetUseLdpc(newState ? 1 : 0);
return true;
});
@@ -863,7 +1016,7 @@ private void setupAdaptiveLinkSubMenu(PopupMenu popup) {
SharedPreferences.Editor editor = getSharedPreferences("general", MODE_PRIVATE).edit();
editor.putBoolean("custom_stbc_enabled", newState);
editor.apply();
- wfbLink.nativeSetUseStbc(newState ? 1 : 0);
+ if (link != null && link.link() != null) link.link().nativeSetUseStbc(newState ? 1 : 0);
return true;
});
@@ -932,35 +1085,17 @@ private void showFecThresholdsDialog() {
.show();
}
+ /** The service applies these when it constructs the link; this re-applies after edits. */
void initDefaultOptions() {
- SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE);
- boolean adaptiveEnabled = prefs.getBoolean("adaptive_link_enabled", true);
- int adaptiveTxPower = prefs.getInt("adaptive_tx_power", 20);
- wfbLink.nativeSetAdaptiveLinkEnabled(adaptiveEnabled);
- wfbLink.nativeSetTxPower(adaptiveTxPower);
- boolean fecEnabled = prefs.getBoolean("custom_fec_enabled", true);
- wfbLink.nativeSetUseFec(fecEnabled ? 1 : 0);
-
- // LDPC and STBC default options
- boolean ldpcEnabled = prefs.getBoolean("custom_ldpc_enabled", true);
- wfbLink.nativeSetUseLdpc(ldpcEnabled ? 1 : 0);
-
- boolean stbcEnabled = prefs.getBoolean("custom_stbc_enabled", true);
- wfbLink.nativeSetUseStbc(stbcEnabled ? 1 : 0);
-
- setFecThresholdsFromPrefs();
+ if (link != null) {
+ WfbOptions.applyDefaults(this, link.link());
+ }
}
// Read FEC thresholds from prefs and call native method to apply
private void setFecThresholdsFromPrefs() {
- SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE);
- int lostTo5 = prefs.getInt("fec_lost_to_5", 2);
- int recTo4 = prefs.getInt("fec_recovered_to_4", 30);
- int recTo3 = prefs.getInt("fec_recovered_to_3", 24);
- int recTo2 = prefs.getInt("fec_recovered_to_2", 14);
- int recTo1 = prefs.getInt("fec_recovered_to_1", 8);
- if (wfbLink != null) {
- wfbLink.setFecThresholds(lostTo5, recTo4, recTo3, recTo2, recTo1);
+ if (link != null) {
+ WfbOptions.applyFecThresholds(this, link.link());
}
}
@@ -1097,8 +1232,8 @@ private void updateUdpForwardingState() {
String ip = prefs.getString("forward_udp_ip", "192.168.1.100");
int port = prefs.getInt("forward_udp_port", 5600);
- if (videoPlayer != null) {
- videoPlayer.setUdpForwarding(ip, port, enabled);
+ if (link != null) {
+ link.setUdpForwarding(ip, port, enabled);
}
}
@@ -1267,24 +1402,12 @@ private void startVpnService() {
}
private Uri openDvrFile() {
- String dvrFolder = getSharedPreferences("general",
- Context.MODE_PRIVATE).getString("dvr_folder_", "");
- if (dvrFolder.isEmpty()) {
- Log.e(TAG, "dvrFolder is empty");
- return null;
- }
- Uri uri = Uri.parse(dvrFolder);
- DocumentFile pickedDir = DocumentFile.fromTreeUri(this, uri);
- if (pickedDir != null && pickedDir.canWrite()) {
- LocalDateTime now = LocalDateTime.now();
- String filename = getDvrFileName(getDvrFileNameTemplate(), now) + ".mp4";
- DocumentFile newFile = pickedDir.createFile("video/mp4", filename);
+ String filename = DvrFiles.nextFileName(this);
+ Uri target = DvrFiles.createRecording(this);
+ if (target != null) {
Toast.makeText(this, "Recording to " + filename, Toast.LENGTH_SHORT).show();
- if (newFile == null)
- Log.e(TAG, "dvr newFile null");
- return newFile != null ? newFile.getUri() : null;
}
- return null;
+ return target;
}
private void startStopDvr() {
@@ -1293,10 +1416,7 @@ private void startStopDvr() {
if (dvrUri != null) {
startDvr(dvrUri);
} else {
- wfbLinkManager.stopAdapters();
- videoPlayer.stop();
- videoPlayer.stopAudio();
-
+ // No need to tear the link down for a file picker any more.
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addCategory(Intent.CATEGORY_DEFAULT);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION |
@@ -1315,7 +1435,7 @@ private void startDvr(Uri dvrUri) {
}
try {
dvrFd = getContentResolver().openFileDescriptor(dvrUri, "rw");
- videoPlayer.startDvr(dvrFd.getFd(), getDvrMP4());
+ if (link != null) link.startDvr(dvrFd.getFd(), getDvrMP4());
binding.imgBtnRecord.setImageResource(R.drawable.recording);
} catch (IOException e) {
Log.e(TAG, "Failed to open dvr file ", e);
@@ -1352,7 +1472,7 @@ private void stopDvr() {
}
binding.imgRecIndicator.setVisibility(View.INVISIBLE);
binding.imgBtnRecord.setImageResource(R.drawable.record);
- videoPlayer.stopDvr();
+ if (link != null) link.stopDvr();
if (recordTimer != null) {
recordTimer.cancel();
recordTimer.purge();
@@ -1384,7 +1504,7 @@ protected void onActivityResult(int requestCode, int resultCode, @Nullable Inten
InputStream inputStream = getContentResolver().openInputStream(uri);
setGsKey(inputStream);
copyGSKey();
- wfbLinkManager.refreshKey();
+ if (link != null) link.refreshKey();
inputStream.close();
} catch (IOException e) {
Log.e(TAG, "Failed to import gs.key from " + uri);
@@ -1430,40 +1550,19 @@ protected void onActivityResult(int requestCode, int resultCode, @Nullable Inten
}
public void setDefaultGsKey() {
- if (getGsKey().length > 0) {
- Log.d(TAG, "gs.key already saved in preferences.");
- return;
- }
- try {
- Log.d(TAG, "Importing default gs.key...");
- InputStream inputStream = getAssets().open("gs.key");
- setGsKey(inputStream);
- inputStream.close();
- } catch (IOException e) {
- Log.e(TAG, "Failed to import default gs.key");
- }
+ GsKey.importDefaultIfMissing(this);
}
public byte[] getGsKey() {
- String pref = getSharedPreferences("general", Context.MODE_PRIVATE).getString("gs.key", "");
- return Base64.decode(pref, Base64.DEFAULT);
+ return GsKey.get(this);
}
public void setGsKey(InputStream inputStream) throws IOException {
- ByteArrayOutputStream result = new ByteArrayOutputStream();
- byte[] buffer = new byte[1024];
- int length;
- while ((length = inputStream.read(buffer)) != -1) {
- result.write(buffer, 0, length);
- }
- SharedPreferences prefs = getSharedPreferences("general", Context.MODE_PRIVATE);
- SharedPreferences.Editor editor = prefs.edit();
- editor.putString("gs.key", Base64.encodeToString(result.toByteArray(), Base64.DEFAULT));
- editor.apply();
+ GsKey.set(this, inputStream);
}
public boolean getDvrMP4() {
- return getSharedPreferences("general", Context.MODE_PRIVATE).getBoolean("dvr_fmp4", true);
+ return DvrFiles.fragmentedMp4(this);
}
public void setDvrMP4(boolean enabled) {
@@ -1474,27 +1573,17 @@ public void setDvrMP4(boolean enabled) {
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
+ /** Only the battery overlay: the USB receiver belongs to LinkService with the adapter. */
public void registerReceivers() {
- IntentFilter usbFilter = new IntentFilter();
- usbFilter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
- usbFilter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
- usbFilter.addAction(WfbLinkManager.ACTION_USB_PERMISSION);
IntentFilter batFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
-
if (Build.VERSION.SDK_INT >= 33) {
- registerReceiver(wfbLinkManager, usbFilter, Context.RECEIVER_NOT_EXPORTED);
registerReceiver(batteryReceiver, batFilter, Context.RECEIVER_NOT_EXPORTED);
} else {
- registerReceiver(wfbLinkManager, usbFilter);
registerReceiver(batteryReceiver, batFilter);
}
}
public void unregisterReceivers() {
- try {
- unregisterReceiver(wfbLinkManager);
- } catch (IllegalArgumentException ignored) {
- }
try {
unregisterReceiver(batteryReceiver);
} catch (IllegalArgumentException ignored) {
@@ -1506,18 +1595,17 @@ protected void onPause() {
super.onPause();
stopObjectDetectionLoop();
-
unregisterReceivers();
- videoPlayer.stop();
- videoPlayer.stopAudio();
- wfbLinkManager.stopAdapters();
+ // The link deliberately stays up: it belongs to LinkService now. Tearing it down
+ // here is what used to cost seconds of black screen every time a system menu or a
+ // dialog took focus.
+ }
- // Stop VPN service
- Log.w(TAG, "onPause: stopping service");
- Intent intent = new Intent(this, WfbNgVpnService.class);
- intent.setAction("STOP_SERVICE");
- startService(intent);
+ @Override
+ protected void onDestroy() {
+ ModeOwner.release(this);
+ super.onDestroy();
}
@Override
@@ -1525,9 +1613,6 @@ protected void onStop() {
MavlinkNative.nativeStop(this);
handler.removeCallbacks(runnable);
unregisterReceivers();
- wfbLinkManager.stopAdapters();
- videoPlayer.stop();
- videoPlayer.stopAudio();
super.onStop();
}
@@ -1535,16 +1620,11 @@ protected void onStop() {
protected void onResume() {
registerReceivers();
- wfbLinkManager.setChannel(getChannel(this));
- wfbLinkManager.setBandwidth(getBandwidth(this));
-
- // On resume is called when the app is reopened, a device might have been plugged since the last time it started.
- wfbLinkManager.refreshAdapters();
-
- wfbLinkManager.startAdapters();
- videoPlayer.start();
- updateUdpForwardingState();
- videoPlayer.startAudio();
+ // An adapter may have been plugged in while we were away.
+ if (link != null) {
+ link.ensureAdapters();
+ updateUdpForwardingState();
+ }
SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE);
boolean odEnabled = prefs.getBoolean("od_enabled", false);
@@ -1557,6 +1637,18 @@ protected void onResume() {
super.onResume();
}
+ @Override
+ protected void onLinkServiceConnected(LinkService service) {
+ // Whatever the view hierarchy produced while we were still binding.
+ for (int i = 0; i < pendingSurfaces.length; i++) {
+ if (pendingSurfaces[i] != null) {
+ service.attachSurface(pendingSurfaces[i], i);
+ }
+ }
+ service.ensureAdapters();
+ updateUdpForwardingState();
+ }
+
@Override
public void onChannelSettingChanged(int channel) {
int currentChannel = getChannel(this);
@@ -1567,9 +1659,9 @@ public void onChannelSettingChanged(int channel) {
SharedPreferences.Editor editor = prefs.edit();
editor.putInt("wifi-channel", channel);
editor.apply();
- wfbLinkManager.stopAdapters();
- wfbLinkManager.setChannel(channel);
- wfbLinkManager.startAdapters();
+ if (link != null) {
+ link.setChannel(channel);
+ }
}
@Override
@@ -1582,13 +1674,13 @@ public void onBandwidthSettingChanged(int bandwidth) {
SharedPreferences.Editor editor = prefs.edit();
editor.putInt("bandwidth", bandwidth);
editor.apply();
- wfbLinkManager.stopAdapters();
- wfbLinkManager.setBandwidth(bandwidth);
- wfbLinkManager.startAdapters();
+ if (link != null) {
+ link.setBandwidth(bandwidth);
+ }
}
@Override
- public void onVideoRatioChanged(final int videoW, final int videoH) {
+ public void onVideoResolution(final int videoW, final int videoH) {
lastVideoW = videoW;
lastVideoH = videoH;
@@ -1617,7 +1709,7 @@ private void updateViewRatio(int viewId, int videoW, int videoH) {
}
@Override
- public void onDecodingInfoChanged(final DecodingInfo decodingInfo) {
+ public void onDecodingInfo(final DecodingInfo decodingInfo) {
mDecodingInfo = decodingInfo;
runOnUiThread(() -> {
if (lastCodec != decodingInfo.nCodec) {
@@ -1638,7 +1730,7 @@ public void onDecodingInfoChanged(final DecodingInfo decodingInfo) {
}
@Override
- public void onWfbNgStatsChanged(WfbNGStats data) {
+ public void onWfbStats(WfbNGStats data) {
runOnUiThread(() -> {
if (data.count_p_all > 0) {
binding.tvMessage.setVisibility(View.INVISIBLE);
@@ -1704,30 +1796,29 @@ public void onWfbNgStatsChanged(WfbNGStats data) {
});
}
+ @Override
+ public void onLinkStatus(String message) {
+ runOnUiThread(() -> {
+ binding.tvMessage.setVisibility(View.VISIBLE);
+ binding.tvMessage.setText(message);
+ });
+ }
+
+ @Override
+ public void onLocalStreamHint(String url) {
+ runOnUiThread(() -> {
+ binding.wifiMessage.setText(url);
+ binding.wifiMessage.setVisibility(View.VISIBLE);
+ });
+ }
+
@Override
public void onNewMavlinkData(MavlinkData data) {
runOnUiThread(() -> osdManager.render(data));
}
private void copyGSKey() {
- File file = new File(getApplicationContext().getFilesDir(), "gs.key");
- OutputStream out = null;
- try {
- byte[] keyBytes = getGsKey();
- Log.d(TAG, "Using gs.key:" + bytesToHex(keyBytes) + "; Copying to" + file.getAbsolutePath());
- out = new FileOutputStream(file);
- out.write(keyBytes, 0, keyBytes.length);
- } catch (IOException e) {
- Log.e(TAG, "Failed to copy asset", e);
- } finally {
- if (out != null) {
- try {
- out.close();
- } catch (IOException e) {
- // NOOP
- }
- }
- }
+ GsKey.writeToFilesDir(this);
}
private void showLoginCredentialsDialog() {
@@ -1832,22 +1923,7 @@ public void afterTextChanged(Editable editable) {}
// pixelpilot_[yyyyMMdd-HHmmss]
// ".mp4" will append later
private String getDvrFileName(String template, LocalDateTime time) {
- Matcher matcher = Pattern.compile("\\[([^\\]]*)\\]").matcher(template);
- String fallbackTime = time.format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
-
- if (matcher.find()) {
- String prefix = template.substring(0, matcher.start());
- String pattern = matcher.group(1);
- String suffix = template.substring(matcher.end());
-
- try {
- String timePart = time.format(DateTimeFormatter.ofPattern(pattern));
- return prefix + timePart + suffix;
- } catch (IllegalArgumentException e) {
- return prefix + fallbackTime + suffix;
- }
- }
- return "pixelpilot_" + fallbackTime;
+ return DvrFiles.fileName(template, time);
}
// Helper method to retrieve the drone username
@@ -1856,9 +1932,8 @@ private String getDroneUsername() {
return getSharedPreferences("general", Context.MODE_PRIVATE).getString(PREF_DRONE_USERNAME, "root");
}
- private String getDvrFileNameTemplate()
- {
- return getSharedPreferences("general", Context.MODE_PRIVATE).getString(PREF_DVR_FILENAME, "pixelpilot_[yyyyMMdd-HHmmss]");
+ private String getDvrFileNameTemplate() {
+ return DvrFiles.template(this);
}
// Helper method to save the drone username
@@ -1870,10 +1945,7 @@ private void setDroneUsername(String username) {
}
private void setDvrFileName(String fileName) {
- SharedPreferences prefs = getSharedPreferences("general", Context.MODE_PRIVATE);
- SharedPreferences.Editor editor = prefs.edit();
- editor.putString(PREF_DVR_FILENAME, fileName);
- editor.apply();
+ DvrFiles.setTemplate(this, fileName);
}
// Helper method to retrieve the drone password
diff --git a/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java b/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java
index 0c0390e0..e1fa0dae 100644
--- a/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java
+++ b/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java
@@ -7,9 +7,7 @@
import android.hardware.usb.UsbDevice;
import android.hardware.usb.UsbManager;
import android.util.Log;
-import android.view.View;
-import com.openipc.pixelpilot.databinding.ActivityVideoBinding;
import com.openipc.wfbngrtl8812.WfbNgLink;
import java.util.HashMap;
@@ -20,9 +18,12 @@
public class WfbLinkManager extends BroadcastReceiver {
public static final String ACTION_USB_PERMISSION = "com.openipc.pixelpilot.USB_PERMISSION";
private static final String TAG = "pixelpilot";
- static Map activeWifiAdapters = new HashMap<>();
+ // Not static: it used to be, and with two activities each owning its own WfbNgLink
+ // that shared map let both of them decide the adapter still needed starting, so the
+ // same dongle ended up with several RX loops.
+ private final Map activeWifiAdapters = new HashMap<>();
private final WfbNgLink wfbLink;
- private final ActivityVideoBinding binding;
+ private final LinkStatusView status;
private final Context context;
private int wifiChannel;
private Bandwidth bandWidth;
@@ -42,8 +43,8 @@ public int getValue() {
}
}
- public WfbLinkManager(Context context, ActivityVideoBinding binding, WfbNgLink wfbNgLink) {
- this.binding = binding;
+ public WfbLinkManager(Context context, LinkStatusView status, WfbNgLink wfbNgLink) {
+ this.status = status;
this.context = context;
this.wfbLink = wfbNgLink;
}
@@ -102,7 +103,12 @@ public Map getAttachedAdapters() {
}
Map res = new HashMap<>();
- for (UsbDevice dev : manager.getDeviceList().values()) {
+ Map attached = manager.getDeviceList();
+ // Without this there is no way to tell "nothing is plugged in" from "the dongle is
+ // there but its id is not in usb_device_filter.xml", and the id is what a bug report
+ // needs in order to add it.
+ Log.i(TAG, "usb devices attached: " + attached.size());
+ for (UsbDevice dev : attached.values()) {
boolean allowed = false;
for (UsbDeviceFilter filter : filters) {
if (filter.productId == dev.getProductId() && filter.vendorId == dev.getVendorId()) {
@@ -110,6 +116,13 @@ public Map getAttachedAdapters() {
break;
}
}
+ Log.i(TAG, String.format(" %s %04X:%04X %s %s -> %s",
+ dev.getDeviceName(),
+ dev.getVendorId(),
+ dev.getProductId(),
+ String.valueOf(dev.getManufacturerName()),
+ String.valueOf(dev.getProductName()),
+ allowed ? "supported" : "NOT in usb_device_filter.xml"));
if (!allowed) {
continue;
}
@@ -130,8 +143,7 @@ public synchronized void refreshAdapters() {
(android.hardware.usb.UsbManager) context.getSystemService(Context.USB_SERVICE);
for (Map.Entry entry : attachedAdapters.entrySet()) {
if (!usbManager.hasPermission(entry.getValue())) {
- binding.tvMessage.setVisibility(View.VISIBLE);
- binding.tvMessage.setText("No permission for wifi adapter(s) " + entry.getValue().getDeviceName());
+ status.showLinkMessage("No permission for wifi adapter(s) " + entry.getValue().getDeviceName());
// Android 14 refuses to deliver a PendingIntent built from an implicit
// intent to a runtime registered receiver, so the permission result never
// arrives unless the package is set explicitly.
@@ -160,6 +172,7 @@ public synchronized void refreshAdapters() {
}
// Starts newly attached adapters.
+ boolean startFailed = false;
for (Map.Entry entry : attachedAdapters.entrySet()) {
if (activeWifiAdapters.containsKey(entry.getKey())) {
continue;
@@ -168,23 +181,34 @@ public synchronized void refreshAdapters() {
// is never retried on the next refresh.
if (startAdapter(entry.getValue())) {
activeWifiAdapters.put(entry.getKey(), entry.getValue());
+ } else {
+ startFailed = true;
}
}
if (activeWifiAdapters.isEmpty()) {
- String text = "No compatible wifi adapter found.";
- binding.tvMessage.setText(text);
- binding.tvMessage.setVisibility(View.VISIBLE);
+ // These are three different problems and telling the pilot the wrong one costs
+ // a lot of pointless searching.
+ if (startFailed) {
+ status.showLinkMessage("Wifi adapter found but could not be started - see the log.");
+ } else if (attachedAdapters.isEmpty()) {
+ status.showLinkMessage("No compatible wifi adapter found.");
+ } else {
+ status.showLinkMessage("Waiting for wifi adapter permission…");
+ }
- String wifi = VideoActivity.wirelessInfo();
+ String wifi = VideoActivity.wirelessInfo(context);
if (wifi != null) {
- String local = "udp://" + wifi + ":5600";
- binding.wifiMessage.setText(local);
- binding.wifiMessage.setVisibility(View.VISIBLE);
+ status.showLocalStreamHint("udp://" + wifi + ":5600");
}
}
}
+ /** True when at least one adapter is up, so a caller can retry instead of guessing. */
+ public synchronized boolean hasActiveAdapter() {
+ return !activeWifiAdapters.isEmpty();
+ }
+
public synchronized void stopAdapters() {
try {
wfbLink.stopAll();
@@ -212,12 +236,10 @@ public synchronized void startAdapters() {
}
public synchronized boolean startAdapter(UsbDevice dev) {
- binding.tvMessage.setVisibility(View.VISIBLE);
- String text = "Starting wfb-ng channel " + wifiChannel + " with " + String.format(
- "[%04X", dev.getVendorId()) + ":" + String.format("%04X]", dev.getProductId());
- binding.tvMessage.setText(text);
+ status.showLinkMessage("Starting wfb-ng channel " + wifiChannel + " with " + String.format(
+ "[%04X", dev.getVendorId()) + ":" + String.format("%04X]", dev.getProductId()));
if (!wfbLink.start(wifiChannel, bandWidth.getValue(), dev)) {
- binding.tvMessage.setText("Could not open wifi adapter " + dev.getDeviceName());
+ status.showLinkMessage("Could not open wifi adapter " + dev.getDeviceName());
return false;
}
return true;
diff --git a/app/src/main/java/com/openipc/pixelpilot/WfbOptions.java b/app/src/main/java/com/openipc/pixelpilot/WfbOptions.java
new file mode 100644
index 00000000..a495587e
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/WfbOptions.java
@@ -0,0 +1,46 @@
+package com.openipc.pixelpilot;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import com.openipc.wfbngrtl8812.WfbNgLink;
+
+/**
+ * Pushes the persisted wfb-ng options into the native link.
+ *
+ * These are not read by the native side on its own, so any activity that constructs a
+ * {@link WfbNgLink} has to apply them explicitly or the user's adaptive link, TX power and
+ * FEC settings are silently ignored.
+ */
+public final class WfbOptions {
+ private WfbOptions() {
+ }
+
+ public static void applyDefaults(Context context, WfbNgLink wfbLink) {
+ if (wfbLink == null) {
+ return;
+ }
+ SharedPreferences prefs = context.getSharedPreferences("general", Context.MODE_PRIVATE);
+
+ wfbLink.nativeSetAdaptiveLinkEnabled(prefs.getBoolean("adaptive_link_enabled", true));
+ wfbLink.nativeSetTxPower(prefs.getInt("adaptive_tx_power", 20));
+ wfbLink.nativeSetUseFec(prefs.getBoolean("custom_fec_enabled", true) ? 1 : 0);
+ wfbLink.nativeSetUseLdpc(prefs.getBoolean("custom_ldpc_enabled", true) ? 1 : 0);
+ wfbLink.nativeSetUseStbc(prefs.getBoolean("custom_stbc_enabled", true) ? 1 : 0);
+
+ applyFecThresholds(context, wfbLink);
+ }
+
+ public static void applyFecThresholds(Context context, WfbNgLink wfbLink) {
+ if (wfbLink == null) {
+ return;
+ }
+ SharedPreferences prefs = context.getSharedPreferences("general", Context.MODE_PRIVATE);
+ wfbLink.setFecThresholds(
+ prefs.getInt("fec_lost_to_5", 2),
+ prefs.getInt("fec_recovered_to_4", 30),
+ prefs.getInt("fec_recovered_to_3", 24),
+ prefs.getInt("fec_recovered_to_2", 14),
+ prefs.getInt("fec_recovered_to_1", 8));
+ }
+}
diff --git a/app/src/main/java/com/openipc/pixelpilot/XrVideoActivity.java b/app/src/main/java/com/openipc/pixelpilot/XrVideoActivity.java
new file mode 100644
index 00000000..fa927a4e
--- /dev/null
+++ b/app/src/main/java/com/openipc/pixelpilot/XrVideoActivity.java
@@ -0,0 +1,625 @@
+package com.openipc.pixelpilot;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.hardware.usb.UsbManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.Surface;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+
+import com.openipc.wfbngrtl8812.WfbNGStats;
+import com.openipc.xr.XrGoggleSession;
+
+import java.io.IOException;
+
+/**
+ * Immersive ground station.
+ *
+ *
Instead of a 2D panel that the headset compositor resamples onto a quad, the decoder
+ * writes straight into a compositor swapchain
+ * ({@code xrCreateSwapchainAndroidSurfaceKHR}) which is presented as a head locked quad
+ * layer. That removes the view hierarchy, SurfaceFlinger and the panel's fixed resolution
+ * from the video path.
+ *
+ *
The adapter, the wfb-ng link and the decoder belong to {@link LinkService}, not to this
+ * activity. Entering and leaving immersive mode therefore only swaps the decoder's output
+ * surface - the adapter is never reopened, so there is no reconnect delay and no second USB
+ * permission prompt.
+ *
+ *
There is no in-headset menu yet: channel, bandwidth and the DVR folder are still
+ * configured in {@link VideoActivity} before putting the headset on. Controllers cover
+ * what matters in flight.
+ */
+public class XrVideoActivity extends LinkClientActivity implements XrGoggleSession.Listener {
+
+ private static final String TAG = "pixelpilot-xr";
+
+ /**
+ * Enables or disables this activity's launcher entry to match the device.
+ *
+ *
The entry is what lets Horizon OS start the app straight into VR, but the manifest
+ * cannot express "only on a device with an XR runtime", so it ships disabled and is
+ * switched on here. On a phone it stays off and nothing about the flat mode changes -
+ * no second icon, no way to land on an activity that cannot create a session.
+ *
+ *
Enabling the component is also what makes {@code startActivity} on it work at all,
+ * so this has to run before VideoActivity's menu can offer it - hence {@code onCreate}
+ * rather than when the menu is opened. Both are gated on the same predicate, so a device
+ * {@link XrGoggleSession#isSupportedDevice} does not recognise sees neither the entry nor
+ * the menu; if that ever turns out to be a false negative, widening that check is the fix.
+ */
+ static void syncLauncherEntry(Context context) {
+ boolean wantEnabled = XrGoggleSession.isSupportedDevice(context);
+ ComponentName component = new ComponentName(context, XrVideoActivity.class);
+ PackageManager pm = context.getPackageManager();
+ try {
+ // COMPONENT_ENABLED_STATE_DEFAULT means "whatever the manifest said", which here
+ // is disabled - so anything other than an explicit ENABLED counts as off.
+ boolean isEnabled = pm.getComponentEnabledSetting(component)
+ == PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
+ if (isEnabled == wantEnabled) {
+ return;
+ }
+ pm.setComponentEnabledSetting(
+ component,
+ wantEnabled
+ ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+ // Back to the manifest value rather than an explicit disable, so
+ // an unrecognised device is left exactly as it shipped.
+ : PackageManager.COMPONENT_ENABLED_STATE_DEFAULT,
+ PackageManager.DONT_KILL_APP);
+ Log.i(TAG, "immersive launcher entry " + (wantEnabled ? "enabled" : "disabled"));
+ } catch (Exception e) {
+ // A launcher entry is not worth taking the flat mode down over.
+ Log.w(TAG, "could not update the immersive launcher entry", e);
+ }
+ }
+
+ // Preference keys, all in the shared "general" store so the flat activity's settings
+ // menu can write them.
+ static final String PREF_HEAD_LOCKED = "xr_head_locked";
+ static final String PREF_PASSTHROUGH = "xr_passthrough";
+ static final String PREF_SHARPENING = "xr_sharpening";
+ static final String PREF_REFRESH_HZ = "xr_refresh_hz";
+ static final String PREF_QUAD_WIDTH = "xr_quad_width";
+ static final String PREF_QUAD_DISTANCE = "xr_quad_distance";
+ static final String PREF_QUAD_HEIGHT = "xr_quad_height";
+ static final String PREF_LAST_VIDEO_W = "xr_last_video_width";
+ static final String PREF_LAST_VIDEO_H = "xr_last_video_height";
+
+ /**
+ * Whether an immersive session owns the link right now. The USB_DEVICE_ATTACHED filter
+ * lives on VideoActivity, so replugging the adapter would otherwise start the flat
+ * activity alongside a running immersive session.
+ */
+ private static volatile boolean active;
+
+ static boolean isActive() {
+ return active;
+ }
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+
+ private XrGoggleSession xr;
+ private TextView statusView;
+
+ // The flat activity may still be releasing the USB interface when we get here, so the
+ // adapter is retried for a while rather than given up on after one attempt.
+ private static final int LINK_RETRY_INTERVAL_MS = 700;
+ private static final int LINK_RETRY_LIMIT = 20;
+
+ private int linkRetries;
+ private boolean xrStarting;
+ private boolean linkStarted;
+ private Surface xrSurface;
+ private volatile String lastStatus = "";
+ private boolean videoSeen;
+ private boolean statusScreenUsable = true;
+ private ParcelFileDescriptor dvrFd;
+
+ // Link quality haptics
+ private long lastHapticAt;
+ private boolean linkWasBad;
+ private volatile WfbNGStats lastStats;
+ private long lastStatsLogAt;
+
+ private final BroadcastReceiver usbReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // Permission granted / adapter (re)appeared - re-evaluate whether we can go
+ // immersive now.
+ handler.post(XrVideoActivity.this::tryBringUpLink);
+ }
+ };
+
+ // ------------------------------------------------------------------------------
+ // lifecycle
+ // ------------------------------------------------------------------------------
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ active = true;
+ // Evict the flat activity: it owns a WfbNgLink too, and the adapter can only belong
+ // to one of them.
+ ModeOwner.claim(this);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ // Only shown while still flat: USB permission, errors, "waiting for adapter".
+ statusView = new TextView(this);
+ statusView.setTextColor(Color.WHITE);
+ statusView.setBackgroundColor(Color.BLACK);
+ statusView.setTextSize(22f);
+ statusView.setGravity(Gravity.CENTER);
+ statusView.setText(R.string.xr_starting);
+ setContentView(statusView);
+
+ if (!XrGoggleSession.isLibraryLoaded()) {
+ failToFlat("This build has no OpenXR support");
+ return;
+ }
+
+ xr = new XrGoggleSession(this, this);
+ SharedPreferences prefs = prefs();
+ // Match the swapchain to the stream so the compositor never resamples twice. The
+ // resolution from the last session is a good guess; 1080p until we have seen one.
+ xr.setSwapchainSize(prefs.getInt(PREF_LAST_VIDEO_W, 1920), prefs.getInt(PREF_LAST_VIDEO_H, 1080));
+ xr.setHeadLocked(prefs.getBoolean(PREF_HEAD_LOCKED, true));
+ xr.setPassthrough(prefs.getBoolean(PREF_PASSTHROUGH, false));
+ xr.setSharpening(prefs.getBoolean(PREF_SHARPENING, true));
+ xr.setQuadWidth(prefs.getFloat(PREF_QUAD_WIDTH, 2.2f));
+ xr.setQuadDistance(prefs.getFloat(PREF_QUAD_DISTANCE, 1.6f));
+ xr.setQuadHeightOffset(prefs.getFloat(PREF_QUAD_HEIGHT, 0f));
+ }
+
+ @Override
+ protected void onLinkServiceConnected(LinkService service) {
+ if (xr == null) {
+ return;
+ }
+ registerUsbReceiver();
+ tryBringUpLink();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (xr != null && link != null) {
+ tryBringUpLink();
+ }
+ }
+
+ // Deliberately no teardown in onPause/onStop. In an immersive session the runtime
+ // pauses and resumes us for its own reasons (system menu, proximity sensor), and
+ // dropping the USB link there costs seconds of black screen on the way back.
+
+ @Override
+ protected void onDestroy() {
+ active = false;
+ ModeOwner.release(this);
+ persistGeometry();
+ try {
+ unregisterReceiver(usbReceiver);
+ } catch (IllegalArgumentException ignored) {
+ // never registered
+ }
+ stopDvr();
+ // Hand the decoder's surface back but leave the link running: the flat activity is
+ // about to take over, and the whole point of the service is that the adapter stays
+ // open across the switch.
+ if (link != null) {
+ link.detachSurface(0);
+ }
+ if (xr != null) {
+ xr.release();
+ xr = null;
+ }
+ super.onDestroy();
+ }
+
+ private SharedPreferences prefs() {
+ return getSharedPreferences("general", Context.MODE_PRIVATE);
+ }
+
+ private void registerUsbReceiver() {
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(WfbLinkManager.ACTION_USB_PERMISSION);
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(usbReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
+ } else {
+ registerReceiver(usbReceiver, filter);
+ }
+ }
+
+ // ------------------------------------------------------------------------------
+ // bring-up
+ // ------------------------------------------------------------------------------
+
+ /**
+ * Resolves USB permission while still on a flat panel - a permission dialog is far
+ * easier to deal with before the compositor takes over - and only then starts the
+ * immersive session.
+ */
+ private void tryBringUpLink() {
+ if (xr == null || link == null) {
+ return;
+ }
+ link.ensureAdapters();
+
+ // Going immersive does not depend on the link, and the pilot is better off seeing the
+ // status screen with a reason than a flat activity that says nothing.
+ if (!xrStarting) {
+ xrStarting = true;
+ statusView.setText(R.string.xr_entering);
+ xr.start();
+ }
+
+ // The adapter may still be waiting for a USB permission the service asked for, so
+ // keep checking back rather than giving up on the first look.
+ if (!link.hasActiveAdapter() && linkRetries < LINK_RETRY_LIMIT) {
+ linkRetries++;
+ Log.i(TAG, "adapter not up yet, retry " + linkRetries + "/" + LINK_RETRY_LIMIT);
+ handler.postDelayed(this::tryBringUpLink, LINK_RETRY_INTERVAL_MS);
+ } else if (link.hasActiveAdapter()) {
+ linkRetries = 0;
+ }
+ }
+
+ @Override
+ public void onXrReady(Surface videoSurface) {
+ Log.i(TAG, "immersive session up, handing the swapchain surface to the decoder");
+ xrSurface = videoSurface;
+ // Put something on the layer before the decoder owns it. Without this the panel is
+ // an opaque black rectangle until the first frame arrives, which is
+ // indistinguishable from "nothing is being composited at all".
+ drawStatusScreen();
+ // The decoder writes into the compositor swapchain from here on. Only the surface
+ // changes - the receivers and the adapter keep running in the service.
+ if (link != null) {
+ link.attachSurface(videoSurface, 0);
+ if (link.videoWidth() > 0) {
+ xr.setVideoResolution(link.videoWidth(), link.videoHeight());
+ }
+ }
+ applyRefreshRatePreference();
+ statusView.setVisibility(View.GONE);
+ linkStarted = true;
+ }
+
+ private void applyRefreshRatePreference() {
+ float wanted = prefs().getFloat(PREF_REFRESH_HZ, 0f);
+ if (wanted <= 0f) {
+ // "Auto" means the highest the headset offers: at 60 fps video a higher
+ // display rate only makes the cadence more even.
+ for (float hz : xr.refreshRates()) {
+ wanted = Math.max(wanted, hz);
+ }
+ }
+ if (wanted > 0f) {
+ xr.requestRefreshRate(wanted);
+ }
+ }
+
+ @Override
+ public void onXrButton(int button) {
+ switch (button) {
+ case XrGoggleSession.BUTTON_RECENTER:
+ // Applied natively already, nothing to persist.
+ break;
+ case XrGoggleSession.BUTTON_PASSTHROUGH:
+ prefs().edit().putBoolean(PREF_PASSTHROUGH, xr.isPassthroughEnabled()).apply();
+ break;
+ case XrGoggleSession.BUTTON_LOCK_MODE:
+ prefs().edit().putBoolean(PREF_HEAD_LOCKED, xr.isHeadLocked()).apply();
+ break;
+ case XrGoggleSession.BUTTON_RECORD:
+ toggleDvr();
+ break;
+ case XrGoggleSession.BUTTON_EXIT:
+ leaveVr();
+ break;
+ case XrGoggleSession.BUTTON_RAISE:
+ case XrGoggleSession.BUTTON_LOWER:
+ prefs().edit().putFloat(PREF_QUAD_HEIGHT, xr.quadHeightOffset()).apply();
+ break;
+ default:
+ break;
+ }
+ }
+
+ @Override
+ public void onXrStopped(@Nullable String error) {
+ if (error != null) {
+ Log.e(TAG, "immersive session failed: " + error);
+ failToFlat(error);
+ } else {
+ finish();
+ }
+ }
+
+ /**
+ * Back to the flat activity. Cheap now that the link lives in the service: the adapter
+ * stays open and video keeps running, only the presentation changes.
+ */
+ private void leaveVr() {
+ Log.i(TAG, "leaving immersive mode on request");
+ startActivity(new Intent(this, VideoActivity.class));
+ finish();
+ }
+
+ /** Immersive mode is not usable here - say why and go back to the flat activity. */
+ private void failToFlat(String reason) {
+ Toast.makeText(this, "VR mode unavailable: " + reason, Toast.LENGTH_LONG).show();
+ if (statusView != null) {
+ statusView.setVisibility(View.VISIBLE);
+ statusView.setText(getString(R.string.xr_failed, reason));
+ }
+ handler.postDelayed(() -> {
+ startActivity(new Intent(this, VideoActivity.class));
+ finish();
+ }, 2500);
+ }
+
+ private void persistGeometry() {
+ if (xr == null) {
+ return;
+ }
+ prefs().edit()
+ .putFloat(PREF_QUAD_WIDTH, xr.quadWidth())
+ .putFloat(PREF_QUAD_DISTANCE, xr.quadDistance())
+ .putFloat(PREF_QUAD_HEIGHT, xr.quadHeightOffset())
+ .putBoolean(PREF_HEAD_LOCKED, xr.isHeadLocked())
+ .putBoolean(PREF_PASSTHROUGH, xr.isPassthroughEnabled())
+ .apply();
+ }
+
+ // ------------------------------------------------------------------------------
+ // "no signal" screen
+ // ------------------------------------------------------------------------------
+
+ /**
+ * Paints status straight into the compositor swapchain's Surface.
+ *
+ *
Only valid until MediaCodec becomes the producer for that Surface - a buffer queue
+ * has one producer - so this stops as soon as a frame has been decoded, and gives up
+ * quietly if the canvas is refused.
+ */
+ private void drawStatusScreen() {
+ if (videoSeen || !statusScreenUsable || xrSurface == null || !xrSurface.isValid()) {
+ return;
+ }
+ Canvas canvas = null;
+ try {
+ canvas = xrSurface.lockHardwareCanvas();
+ if (canvas == null) {
+ statusScreenUsable = false;
+ return;
+ }
+ paintStatus(canvas);
+ xrSurface.unlockCanvasAndPost(canvas);
+ } catch (Throwable t) {
+ // Most likely the decoder already connected as the producer.
+ Log.w(TAG, "status screen unavailable: " + t);
+ statusScreenUsable = false;
+ return;
+ }
+ handler.postDelayed(this::drawStatusScreen, 1000);
+ }
+
+ private void paintStatus(Canvas canvas) {
+ final int w = canvas.getWidth();
+ final int h = canvas.getHeight();
+
+ canvas.drawColor(Color.rgb(12, 12, 16));
+
+ Paint line = new Paint();
+ line.setColor(Color.rgb(70, 80, 100));
+ line.setStrokeWidth(Math.max(2f, h / 300f));
+ line.setStyle(Paint.Style.STROKE);
+ // A border and a centre cross: makes the panel's real extent and centre visible, so
+ // size and distance can be judged with nothing else on screen.
+ float inset = h / 40f;
+ canvas.drawRect(inset, inset, w - inset, h - inset, line);
+ float cx = w / 2f, cy = h / 2f, arm = h / 20f;
+ canvas.drawLine(cx - arm, cy, cx + arm, cy, line);
+ canvas.drawLine(cx, cy - arm, cx, cy + arm, line);
+
+ Paint title = new Paint();
+ title.setAntiAlias(true);
+ title.setColor(Color.rgb(235, 235, 240));
+ title.setTextSize(h / 14f);
+ title.setTextAlign(Paint.Align.CENTER);
+ canvas.drawText("No video", cx, cy - h / 8f, title);
+
+ Paint body = new Paint();
+ body.setAntiAlias(true);
+ body.setColor(Color.rgb(170, 180, 195));
+ body.setTextSize(h / 28f);
+ body.setTextAlign(Paint.Align.CENTER);
+
+ String status = lastStatus;
+ float y = cy + h / 7f;
+ if (!status.isEmpty()) {
+ canvas.drawText(status, cx, y, body);
+ y += h / 20f;
+ }
+ // The link counters answer the first question a pilot has when the screen stays
+ // empty: is anything arriving at all?
+ WfbNGStats stats = lastStats;
+ if (stats != null) {
+ canvas.drawText("wfb packets " + stats.count_p_all
+ + " ok " + stats.count_p_dec_ok
+ + " lost " + stats.count_p_lost
+ + " err " + stats.count_p_dec_err,
+ cx, y, body);
+ y += h / 20f;
+ } else {
+ canvas.drawText("no wfb statistics yet", cx, y, body);
+ y += h / 20f;
+ }
+ canvas.drawText("channel " + VideoActivity.getChannel(this)
+ + " @ " + VideoActivity.getBandwidth(this) + " MHz",
+ cx, y, body);
+ y += h / 20f;
+ String wifi = VideoActivity.wirelessInfo(this);
+ if (wifi != null) {
+ canvas.drawText("or push a stream to udp://" + wifi + ":5600", cx, y, body);
+ y += h / 20f;
+ }
+ canvas.drawText(xr != null && xr.isHeadLocked() ? "head locked" : "world locked", cx, y, body);
+ y += h / 20f;
+
+ Paint hint = new Paint(body);
+ hint.setTextSize(h / 36f);
+ hint.setColor(Color.rgb(130, 140, 155));
+ canvas.drawText("A recenter B passthrough stick click record", cx, y, hint);
+ y += h / 26f;
+ canvas.drawText("menu button or thumb swipe left: back to flat mode", cx, y, hint);
+ }
+
+ // ------------------------------------------------------------------------------
+ // DVR
+ // ------------------------------------------------------------------------------
+
+ private void toggleDvr() {
+ if (dvrFd != null) {
+ stopDvr();
+ xr.haptic(0.7f, 60);
+ Toast.makeText(this, "Recording stopped", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (!DvrFiles.hasRecordingFolder(this)) {
+ // Picking a folder needs the flat activity; refuse rather than drop out of VR.
+ xr.haptic(1.0f, 250);
+ Toast.makeText(this, "Pick a DVR folder in flat mode first", Toast.LENGTH_LONG).show();
+ return;
+ }
+ Uri target = DvrFiles.createRecording(this);
+ if (target == null) {
+ xr.haptic(1.0f, 250);
+ Toast.makeText(this, "Could not create the recording file", Toast.LENGTH_LONG).show();
+ return;
+ }
+ if (link == null) {
+ return;
+ }
+ try {
+ dvrFd = getContentResolver().openFileDescriptor(target, "rw");
+ link.startDvr(dvrFd.getFd(), DvrFiles.fragmentedMp4(this));
+ xr.haptic(0.7f, 120);
+ Toast.makeText(this, "Recording", Toast.LENGTH_SHORT).show();
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to open dvr file", e);
+ dvrFd = null;
+ xr.haptic(1.0f, 250);
+ }
+ }
+
+ private void stopDvr() {
+ if (dvrFd == null) {
+ return;
+ }
+ if (link != null) {
+ link.stopDvr();
+ }
+ try {
+ dvrFd.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to close dvr file", e);
+ }
+ dvrFd = null;
+ }
+
+ // ------------------------------------------------------------------------------
+ // stream + link callbacks
+ // ------------------------------------------------------------------------------
+
+ @Override
+ public void onVideoResolution(int videoW, int videoH) {
+ if (videoW <= 0 || videoH <= 0) {
+ return;
+ }
+ Log.i(TAG, "stream resolution " + videoW + "x" + videoH);
+ videoSeen = true;
+ if (xr != null) {
+ xr.setVideoResolution(videoW, videoH);
+ }
+ // So the next session can size its swapchain exactly.
+ prefs().edit().putInt(PREF_LAST_VIDEO_W, videoW).putInt(PREF_LAST_VIDEO_H, videoH).apply();
+ }
+
+ @Override
+ public void onWfbStats(WfbNGStats data) {
+ lastStats = data;
+ // Without this there is no way to tell "the adapter is dead" from "the link is fine
+ // but nothing decodes" while wearing the headset.
+ final long nowMs = System.currentTimeMillis();
+ if (nowMs - lastStatsLogAt > 2000) {
+ lastStatsLogAt = nowMs;
+ Log.i(TAG, "wfb: all=" + data.count_p_all
+ + " decOk=" + data.count_p_dec_ok
+ + " decErr=" + data.count_p_dec_err
+ + " fecRec=" + data.count_p_fec_recovered
+ + " lost=" + data.count_p_lost
+ + " bad=" + data.count_p_bad
+ + " rssi=" + data.avg_rssi
+ + " videoSeen=" + videoSeen);
+ }
+ if (xr == null || !linkStarted || data.count_p_all <= 0) {
+ return;
+ }
+ // A headset can do something goggles cannot: warn you through the controllers, so
+ // a degrading link does not depend on you reading an OSD number mid-manoeuvre.
+ final int lost = data.count_p_lost;
+ final int recovered = data.count_p_fec_recovered;
+ final boolean bad = lost > 0 || recovered > data.count_p_all / 8;
+
+ final long now = System.currentTimeMillis();
+ if (bad && (!linkWasBad || now - lastHapticAt > 2000)) {
+ lastHapticAt = now;
+ xr.haptic(lost > 0 ? 1.0f : 0.5f, lost > 0 ? 180 : 70);
+ }
+ linkWasBad = bad;
+ }
+
+ // ------------------------------------------------------------------------------
+ // link service callbacks
+ // ------------------------------------------------------------------------------
+
+ @Override
+ public void onLinkStatus(String message) {
+ lastStatus = message;
+ runOnUiThread(() -> {
+ if (statusView != null && !linkStarted) {
+ statusView.setText(message);
+ }
+ });
+ }
+
+ @Override
+ public void onLocalStreamHint(String url) {
+ Log.i(TAG, "listening on " + url);
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9ebd0632..d3c6705e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,9 @@
PixelPilot
+ PixelPilot VR
+ Waiting for the wifi adapter…
+ Entering VR…
+ VR mode unavailable: %1$s — falling back to flat mode…
- 1
- 2
diff --git a/app/videonative/src/main/cpp/BufferedPacketQueue.h b/app/videonative/src/main/cpp/BufferedPacketQueue.h
index 88d6eab0..891540a8 100644
--- a/app/videonative/src/main/cpp/BufferedPacketQueue.h
+++ b/app/videonative/src/main/cpp/BufferedPacketQueue.h
@@ -5,9 +5,11 @@
#include
#endif
#include
+#include
#include
#include
#include
+#include
#include
#include
@@ -15,10 +17,27 @@
// Define logging tag and maximum buffer size
#define BUFFERED_QUEUE_LOG_TAG "BufferedPacketQueue"
-// Considering the packet rate about 100 packets per second, 10 packets should be enough
+// Last-resort cap on the buffer. How long this is in wall clock time depends entirely on the
+// packet rate, which is why it cannot be the only bound - see MAX_BUFFER_AGE.
constexpr size_t MAX_BUFFER_SIZE = 15;
// Number of monotonically increasing packets
constexpr size_t MONOTONIC_THRESHOLD = 5;
+// A monotonic run is only tracked while the gap is plausibly a reorder. Kept separate from
+// MONOTONIC_THRESHOLD: the gap grows by one with every packet held back, so gating the counter
+// on the same value it is compared against means it can never reach it.
+constexpr size_t MONOTONIC_MAX_DISTANCE = 64;
+
+// Clock used to bound how long a packet may be held back.
+using QueueClock = std::chrono::steady_clock;
+using QueueTimePoint = QueueClock::time_point;
+
+// A gap in the sequence numbers on this path is almost always a packet that FEC could not
+// recover, not a reorder: by the time packets get here they have come through wfb-ng and a
+// loopback socket, where reordering takes microseconds. Waiting for a packet that will never
+// arrive is pure added latency, so the wait is bounded in time rather than in packets - the
+// packet bound alone is worth ~20ms on a 1080p video stream but ~300ms on the audio stream,
+// which runs at a fraction of the packet rate.
+constexpr auto MAX_BUFFER_AGE = std::chrono::milliseconds(20);
// Type definition for sequence numbers
using SeqType = uint16_t;
@@ -45,7 +64,12 @@ class BufferedPacketQueue
* @param callback Callable to handle processed packets.
*/
template
- void processPacket(SeqType currPacketIdx, const uint8_t* data, std::size_t data_length, Callback& callback)
+ void processPacket(
+ SeqType currPacketIdx,
+ const uint8_t* data,
+ std::size_t data_length,
+ Callback& callback,
+ QueueTimePoint now = QueueClock::now())
{
logDebug(
"Processing packet with Sequence=%u, lastPacketIdx=%u, firstPacket=%s",
@@ -53,6 +77,19 @@ class BufferedPacketQueue
mLastPacketIdx,
mFirstPacket ? "true" : "false");
+ // Before anything else: give up on a gap we have been waiting on for too long. Done
+ // here rather than in handleOutOfOrderPacket so that an in-order packet arriving after
+ // a stall does not get delivered ahead of what is already buffered.
+ if (!mPackets.empty() && (now - mOldestBufferedAt) >= MAX_BUFFER_AGE)
+ {
+ logWarning(
+ "Held %zu packet(s) for more than %lldms waiting on Sequence=%u. Flushing.",
+ mPackets.size(),
+ (long long) MAX_BUFFER_AGE.count(),
+ static_cast(static_cast(mLastPacketIdx + 1)));
+ mLastPacketIdx = drainBufferInOrder(callback);
+ }
+
if (isFirstPacket(currPacketIdx))
{
handleFirstPacket(currPacketIdx);
@@ -73,7 +110,7 @@ class BufferedPacketQueue
else
{
// Out-of-order packet
- handleOutOfOrderPacket(currPacketIdx, data, data_length, callback);
+ handleOutOfOrderPacket(currPacketIdx, data, data_length, callback, now);
}
}
@@ -83,6 +120,9 @@ class BufferedPacketQueue
std::unordered_map> mPackets;
+ // When the current stall started, i.e. when mPackets last went from empty to non-empty.
+ QueueTimePoint mOldestBufferedAt{};
+
// This variable is used to track a situation where the sequence number is increasing monotonically while packets
// are out of order. if this counter reaches MONOTONIC_THRESHOLD, we will restart buffering and update lastPacketIdx
// to the highest sequence index received.
@@ -174,7 +214,12 @@ class BufferedPacketQueue
* @param callback Callable to handle processed packets.
*/
template
- void handleOutOfOrderPacket(SeqType currPacketIdx, const uint8_t* data, std::size_t data_length, Callback& callback)
+ void handleOutOfOrderPacket(
+ SeqType currPacketIdx,
+ const uint8_t* data,
+ std::size_t data_length,
+ Callback& callback,
+ QueueTimePoint now)
{
logDebug("Out-of-order packet detected. Sequence=%u", currPacketIdx);
@@ -184,10 +229,15 @@ class BufferedPacketQueue
// return;
}
- bufferPacket(currPacketIdx, data, data_length);
+ bufferPacket(currPacketIdx, data, data_length, now);
- auto dist = calculateDistance(currPacketIdx, mLastPacketIdx);
- if (std::abs(dist) < MONOTONIC_THRESHOLD)
+ // calculateDistance(a, b) is how far b is ahead of a - see seqLessThan below - so the
+ // question "is this packet ahead of the last one we delivered" has to be asked in that
+ // order. Reversed, dist is negative for exactly the case this heuristic exists for (a
+ // gap ahead of us), the else branch below clears the counter every time, and the
+ // buffer only ever drains on the MAX_BUFFER_SIZE cap.
+ auto dist = calculateDistance(mLastPacketIdx, currPacketIdx);
+ if (static_cast(std::abs(dist)) < MONOTONIC_MAX_DISTANCE)
{
// Check for monotonic increases
if (dist > 0)
@@ -196,10 +246,8 @@ class BufferedPacketQueue
logDebug("Monotonic increase count: %zu", mMonotonicOutOfOrderIncreaseCount);
if (mMonotonicOutOfOrderIncreaseCount >= MONOTONIC_THRESHOLD)
{
- restartBuffering(callback, currPacketIdx);
- // Update lastPacketIdx to the highest sequence index received
- SeqType newLastIdx = currPacketIdx;
- logWarning("Monotonic threshold reached. Updating lastPacketIdx to %u", newLastIdx);
+ mLastPacketIdx = drainBufferInOrder(callback);
+ logWarning("Monotonic threshold reached. Updating lastPacketIdx to %u", mLastPacketIdx);
}
}
else
@@ -214,7 +262,7 @@ class BufferedPacketQueue
{
logWarning(
"Buffer size exceeded MAX_BUFFER_SIZE (%zu). Processing in-order buffered packets.", MAX_BUFFER_SIZE);
- restartBuffering(callback, currPacketIdx);
+ mLastPacketIdx = drainBufferInOrder(callback);
}
}
@@ -231,19 +279,27 @@ class BufferedPacketQueue
* @param data Pointer to the packet data.
* @param data_length Size of the packet data.
*/
- void bufferPacket(SeqType currPacketIdx, const uint8_t* data, std::size_t data_length)
+ void bufferPacket(SeqType currPacketIdx, const uint8_t* data, std::size_t data_length, QueueTimePoint now)
{
+ // Only the start of a stall is recorded, because the buffer is always drained as a
+ // whole. A partial drain leaves the mark where it was, which errs towards flushing
+ // early - the safe direction on a live link.
+ if (mPackets.empty())
+ {
+ mOldestBufferedAt = now;
+ }
mPackets[currPacketIdx] = std::vector(data, data + data_length);
logDebug("Buffered out-of-order packet. Buffer size: %zu", mPackets.size());
}
/**
- * @brief Handles buffer overflow by processing in-order packets and discarding others.
+ * @brief Delivers everything currently held back, in sequence order, and empties the buffer.
* @tparam Callback A callable type that processes the packet data.
* @param callback Callable to handle processed packets.
+ * @return The highest sequence number delivered, or mLastPacketIdx if nothing was held.
*/
template
- void restartBuffering(Callback& callback, SeqType currPacketIdx)
+ SeqType drainBufferInOrder(Callback& callback)
{
// Process as many in-order buffered packets as possible
processBufferedPackets(callback);
@@ -262,25 +318,40 @@ class BufferedPacketQueue
sortedPackets.push_back(it);
}
- // Sort the vector based on the keys
+ // Sorted by distance from the last delivered packet, not by raw value: a block
+ // that straddles the wrap point (65534, 65535, 0, 1) sorts to 0, 1, 65534, 65535
+ // by value, and would be handed to the parser in that order.
+ const SeqType from = mLastPacketIdx;
std::sort(
sortedPackets.begin(),
sortedPackets.end(),
- [](const auto& a, const auto& b) { return a->first < b->first; });
-
- // Iterate over the sorted packets and invoke the callback
+ [from](const auto& a, const auto& b) {
+ return static_cast(a->first - from) < static_cast(b->first - from);
+ });
+
+ // Seeded from a packet that is actually in the buffer rather than from
+ // mLastPacketIdx. RTP starts at a random sequence number, so a VTX that reboots
+ // mid-session can land more than half the sequence space away, where
+ // calculateDistance() reads as negative - seeded from mLastPacketIdx nothing
+ // would ever move and the queue would never resync.
+ SeqType highest = sortedPackets.front()->first;
for (const auto& it : sortedPackets)
{
const auto& packet = it->second;
logDebug("Processing possibly out-of-order buffered packet with Sequence=%u.", it->first);
callback(packet.data(), packet.size());
+ if (calculateDistance(highest, it->first) > 0)
+ {
+ highest = it->first;
+ }
}
mPackets.clear();
// Reset the monotonic increase counter
mMonotonicOutOfOrderIncreaseCount = 0;
+ return highest;
}
- mLastPacketIdx = currPacketIdx;
+ return mLastPacketIdx;
}
/**
diff --git a/app/videonative/src/main/cpp/tests/BufferedPacketQueue_test.cpp b/app/videonative/src/main/cpp/tests/BufferedPacketQueue_test.cpp
index c6f79a0b..6e803b62 100644
--- a/app/videonative/src/main/cpp/tests/BufferedPacketQueue_test.cpp
+++ b/app/videonative/src/main/cpp/tests/BufferedPacketQueue_test.cpp
@@ -1,6 +1,7 @@
#include "BufferedPacketQueue.h" // the class under test
#include
#include
+#include
#include
// ---------- Test fixture ----------------------------------------------------
@@ -10,7 +11,17 @@ class BufferedPacketQueueTest : public ::testing::Test
BufferedPacketQueue q;
std::vector delivered;
- void SetUp() override { delivered.clear(); }
+ // The queue bounds how long it will hold a packet back, so the tests drive the clock
+ // themselves instead of letting a loaded machine decide whether the bound was hit.
+ QueueTimePoint now{};
+
+ void SetUp() override
+ {
+ delivered.clear();
+ now = QueueTimePoint{};
+ }
+
+ void advance(int ms) { now += std::chrono::milliseconds(ms); }
/* Helper: feed one packet and record what the queue actually delivers. */
void feed(uint16_t seq)
@@ -23,7 +34,7 @@ class BufferedPacketQueueTest : public ::testing::Test
delivered.push_back(*(uint16_t*) seq);
};
- q.processPacket(seq, (uint8_t*) &dummy, 2, cb);
+ q.processPacket(seq, (uint8_t*) &dummy, 2, cb, now);
}
};
@@ -57,6 +68,124 @@ TEST_F(BufferedPacketQueueTest, ReorderedDeliversInOrder)
ASSERT_EQ(delivered, expected) << "Overflow flush should deliver the entire block in one shot";
}
+// ---------- A gap that will never be filled --------------------------------
+// The common case on a lossy link: FEC could not recover one packet, and every packet after it
+// is held back waiting for it. Nothing is lost by waiting, but everything behind the gap gets
+// later and later, so the queue has to give up at some point.
+TEST_F(BufferedPacketQueueTest, PermanentGapDoesNotHoldTheStreamForFifteenPackets)
+{
+ for (uint16_t s = 1; s <= 5; ++s) feed(s);
+ ASSERT_EQ(delivered, (std::vector{1, 2, 3, 4, 5}));
+
+ // 6 never arrives.
+ for (uint16_t s = 7; s <= 11; ++s) feed(s);
+ feed(12);
+
+ ASSERT_EQ(delivered, (std::vector{1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12}))
+ << "A monotonic run past the gap should release the buffer, not wait for MAX_BUFFER_SIZE";
+}
+
+// The monotonic run is counted in packets, so how much latency it costs depends on the packet
+// rate - which on the audio stream is a fraction of the video one. The age bound is what makes
+// the wait the same on both.
+TEST_F(BufferedPacketQueueTest, StaleBufferIsFlushedOnTimeout)
+{
+ feed(1);
+ // 2 never arrives.
+ feed(3);
+ ASSERT_EQ(delivered, (std::vector{1})) << "3 is held back waiting for 2";
+
+ advance(25);
+ feed(4);
+
+ ASSERT_EQ(delivered, (std::vector{1, 3, 4}))
+ << "Once the buffer is older than MAX_BUFFER_AGE it has to be released";
+}
+
+// The other side of that bound: a reorder that resolves quickly must still be reordered, not
+// flushed out of sequence.
+TEST_F(BufferedPacketQueueTest, ReorderWithinTimeoutIsStillPutBackInOrder)
+{
+ feed(1);
+ feed(3);
+ advance(5);
+ feed(2);
+
+ ASSERT_EQ(delivered, (std::vector{1, 2, 3}))
+ << "A packet that arrives late but within MAX_BUFFER_AGE must not be flushed early";
+}
+
+// A large jump is not a reorder - it is a stream that restarted somewhere else. It must not be
+// mistaken for a monotonic run, and the buffer cap has to catch it.
+TEST_F(BufferedPacketQueueTest, LargeJumpFallsBackToTheBufferCap)
+{
+ feed(1);
+ for (uint16_t s = 30000; s < 30000 + MAX_BUFFER_SIZE; ++s) feed(s);
+
+ ASSERT_EQ(delivered.size(), 1u + MAX_BUFFER_SIZE)
+ << "The buffer cap should have released the jumped-to block";
+ ASSERT_EQ(delivered.front(), 1);
+ ASSERT_EQ(delivered.back(), static_cast(30000 + MAX_BUFFER_SIZE - 1));
+}
+
+// A flush delivers everything it is holding, so the pointer has to end up past all of it.
+// Rewinding it to whichever packet happened to trigger the flush re-creates the stall that
+// was just cleared.
+TEST_F(BufferedPacketQueueTest, FlushAdvancesPastEverythingItDelivered)
+{
+ for (uint16_t s = 1; s <= 5; ++s) feed(s);
+
+ // 6 never arrives, and 11 is missing from the run so the flush is triggered by 10 while
+ // 12 is already buffered behind it.
+ feed(7);
+ feed(8);
+ feed(9);
+ feed(12);
+ feed(10);
+ ASSERT_EQ(delivered, (std::vector{1, 2, 3, 4, 5, 7, 8, 9, 10, 12}));
+
+ feed(13);
+ ASSERT_EQ(delivered, (std::vector{1, 2, 3, 4, 5, 7, 8, 9, 10, 12, 13}))
+ << "13 follows the highest packet already delivered and must not be held back";
+}
+
+// RTP starts at a random sequence number, so a VTX rebooting mid-session shows up as a jump
+// that can be more than half the sequence space - where a signed 16 bit distance reads as
+// "behind us" and the queue would never move again.
+TEST_F(BufferedPacketQueueTest, StreamRestartFarAheadResyncs)
+{
+ feed(1);
+ feed(2);
+
+ feed(40000);
+ ASSERT_EQ(delivered, (std::vector{1, 2})) << "40000 is held back at first";
+
+ advance(25);
+ feed(40001);
+ feed(40002);
+
+ ASSERT_EQ(delivered, (std::vector{1, 2, 40000, 40001, 40002}))
+ << "after the age bound releases 40000 the queue has to follow the new base";
+}
+
+// The buffer is a hash map, so the flush has to sort it - and sorting by raw value puts a
+// block that straddles the wrap point in the wrong order.
+TEST_F(BufferedPacketQueueTest, FlushAcrossTheWrapDeliversInOrder)
+{
+ feed(65532);
+
+ // 65533 never arrives; the rest of the run crosses zero.
+ feed(65534);
+ feed(65535);
+ feed(0);
+ feed(1);
+ feed(2);
+ feed(3);
+
+ ASSERT_EQ(delivered, (std::vector{65532, 65534, 65535, 0, 1, 2, 3}))
+ << "sorted by value this comes out as 0, 1, 2, 65534, 65535";
+}
+
// ---------- gtest boilerplate main -----------------------------------------
int main(int argc, char** argv)
{
diff --git a/app/videonative/src/main/cpp/tests/CMakeLists.txt b/app/videonative/src/main/cpp/tests/CMakeLists.txt
index 1e70da39..6ce59ad5 100644
--- a/app/videonative/src/main/cpp/tests/CMakeLists.txt
+++ b/app/videonative/src/main/cpp/tests/CMakeLists.txt
@@ -25,7 +25,7 @@ enable_testing()
# ---------- Test executable --------------------------------------------------
add_executable(queue_test
- BufferedPacketqueue_test.cpp
+ BufferedPacketQueue_test.cpp
)
target_include_directories(queue_test PUBLIC
diff --git a/app/videonative/src/main/java/com/openipc/videonative/VideoPlayer.java b/app/videonative/src/main/java/com/openipc/videonative/VideoPlayer.java
index e1c49d0f..25a9c284 100644
--- a/app/videonative/src/main/java/com/openipc/videonative/VideoPlayer.java
+++ b/app/videonative/src/main/java/com/openipc/videonative/VideoPlayer.java
@@ -9,7 +9,6 @@
import android.view.SurfaceHolder;
import androidx.annotation.Nullable;
-import androidx.appcompat.app.AppCompatActivity;
import java.util.Timer;
import java.util.TimerTask;
@@ -35,7 +34,7 @@ public class VideoPlayer implements IVideoParamsChanged {
private Timer timer;
// Setup as much as possible without creating the decoder
- public VideoPlayer(final AppCompatActivity parent) {
+ public VideoPlayer(final Context parent) {
this.context = parent;
nativeVideoPlayer = nativeInitialize(context);
}
@@ -161,6 +160,21 @@ public void addAndStartDecoderReceiver(Surface surface, int index) {
setVideoSurface(surface, index);
}
+ /**
+ * Point the decoder at a new output surface without touching the receivers.
+ *
+ * This is what a surface change should do: the decoder is rebuilt for the new surface
+ * while UDP reception, and with it the wifi link, keeps running.
+ */
+ public void attachSurface(Surface surface, int index) {
+ setVideoSurface(surface, index);
+ }
+
+ /** Releases the decoder's output surface, leaving the receivers alone. */
+ public void detachSurface(int index) {
+ setVideoSurface(null, index);
+ }
+
/**
* Stop the Receiver
* Stop the Decoder
diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp
index a3046b07..ae365310 100644
--- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp
+++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp
@@ -56,6 +56,7 @@ WfbngLink::WfbngLink(JNIEnv *env, jobject context)
}
void WfbngLink::initAgg() {
+ try {
std::string client_addr = "127.0.0.1";
uint64_t epoch = 0;
@@ -81,6 +82,19 @@ void WfbngLink::initAgg() {
udp_aggregator =
std::make_unique(client_addr, udp_client_port, keyPath, epoch, udp_channel_id_f, 0);
+ } catch (const std::exception &e) {
+ // The aggregators open the key in their constructor. Letting that escape a JNI call
+ // aborts the whole process, which is what a missing gs.key used to do.
+ __android_log_print(ANDROID_LOG_ERROR,
+ TAG,
+ "initAgg failed (%s) - check gs.key at %s. Link stays down until "
+ "the key is refreshed.",
+ e.what(),
+ keyPath);
+ video_aggregator.reset();
+ mavlink_aggregator.reset();
+ udp_aggregator.reset();
+ }
}
int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint fd) {
diff --git a/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java b/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java
index 39dca17d..897f0b08 100644
--- a/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java
+++ b/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java
@@ -7,7 +7,6 @@
import android.util.Log;
import androidx.annotation.Keep;
-import androidx.appcompat.app.AppCompatActivity;
import java.util.HashMap;
import java.util.Map;
@@ -30,6 +29,31 @@ public void setFecThresholds(int lostTo5, int recTo4, int recTo3, int recTo2, in
System.loadLibrary("WfbngRtl8812");
}
+ /**
+ * Devices with a live RX loop anywhere in the process, keyed by device name.
+ *
+ * Per-instance bookkeeping is not enough: each Activity owns its own WfbNgLink, so
+ * two of them both see an empty map and both claim the same dongle. Keyed on the thread
+ * so a claim left behind by a thread that died is simply ignored rather than locking the
+ * adapter out for good.
+ */
+ private static final Map claimedDevices = new HashMap<>();
+
+ private static synchronized boolean claim(String deviceName, Thread t) {
+ Thread holder = claimedDevices.get(deviceName);
+ if (holder != null && holder.isAlive()) {
+ return false;
+ }
+ claimedDevices.put(deviceName, t);
+ return true;
+ }
+
+ private static synchronized void release(String deviceName, Thread t) {
+ if (claimedDevices.get(deviceName) == t) {
+ claimedDevices.remove(deviceName);
+ }
+ }
+
private final long nativeWfbngLink;
private final Timer timer;
private final Context context;
@@ -50,7 +74,7 @@ public void setFecThresholds(int lostTo5, int recTo4, int recTo3, int recTo2, in
public static native void nativeSetUseLdpc(long nativeInstance, int use);
public static native void nativeSetUseStbc(long nativeInstance, int use);
- public WfbNgLink(final AppCompatActivity parent) {
+ public WfbNgLink(final Context parent) {
this.context = parent;
nativeWfbngLink = nativeInitialize(context);
timer = new Timer();
@@ -93,6 +117,19 @@ public void nativeSetUseStbc(int use) {
}
public synchronized boolean start(int wifiChannel, int bandWidth, UsbDevice usbDevice) {
+ // A second RX loop on the same adapter means two libusb handles on one interface,
+ // and the old thread is orphaned because linkThreads.put() overwrites its entry, so
+ // stopAll() then joins only the last one.
+ //
+ // (Note: three threads named "wfb-001/002" is *normal* for one adapter - the TX and
+ // adaptive-link threads inherit the RX thread's name because nothing renames them.
+ // An earlier reading of that as three RX loops was wrong; the guard is still right,
+ // because two activities each with their own WfbNgLink really can double-start.)
+ Thread existing = linkThreads.get(usbDevice);
+ if (existing != null && existing.isAlive()) {
+ Log.w(TAG, "wfb-ng already running on " + usbDevice.getDeviceName() + ", not starting a second");
+ return true;
+ }
Log.d(TAG, "wfb-ng monitoring on " + usbDevice.getDeviceName() + " using wifi channel " + wifiChannel);
UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE);
// Returns null when the permission was revoked or the device disappeared between
@@ -108,12 +145,26 @@ public synchronized boolean start(int wifiChannel, int bandWidth, UsbDevice usbD
usbDeviceConnection.close();
return false;
}
- Thread t = new Thread(() -> nativeRun(nativeWfbngLink, context, wifiChannel, bandWidth, fd));
+ final String deviceName = usbDevice.getDeviceName();
+ final Thread[] self = new Thread[1];
+ Thread t = new Thread(() -> {
+ try {
+ nativeRun(nativeWfbngLink, context, wifiChannel, bandWidth, fd);
+ } finally {
+ release(deviceName, self[0]);
+ }
+ });
+ self[0] = t;
t.setName(threadNameFor(usbDevice));
+ if (!claim(deviceName, t)) {
+ Log.w(TAG, "another wfb-ng RX loop already owns " + deviceName + ", not starting a second");
+ usbDeviceConnection.close();
+ return false;
+ }
linkThreads.put(usbDevice, t);
linkConns.put(usbDevice, usbDeviceConnection);
t.start();
- Log.d(TAG, "wfb-ng thread on " + usbDevice.getDeviceName() + " started.");
+ Log.d(TAG, "wfb-ng thread on " + deviceName + " started.");
return true;
}
@@ -123,19 +174,44 @@ private static String threadNameFor(UsbDevice usbDevice) {
return "wfb-" + (parts.length > 1 ? parts[1] : name);
}
+ /**
+ * The RX loop is joined so the USB interface is released before anything reopens it, but
+ * these calls come from Activity lifecycle callbacks on the main thread. An unbounded
+ * join there is a five second ANR waiting to happen, and it did: a driver thread that
+ * does not come back froze the UI. Wait, but give up and say so.
+ */
+ // StopRxLoop() only breaks the receive loop; the thread then still has to stop the TX
+ // frame, the adaptive link, power the chip down and release the USB interface. Too
+ // short a wait here and the next owner cannot claim the interface yet.
+ private static final long JOIN_TIMEOUT_MS = 3000;
+
+ private static void joinBounded(Thread t, String what) throws InterruptedException {
+ if (t == null) {
+ return;
+ }
+ t.join(JOIN_TIMEOUT_MS);
+ if (t.isAlive()) {
+ Log.e(TAG, "wfb-ng thread on " + what + " did not stop within " + JOIN_TIMEOUT_MS
+ + "ms, giving up the claim anyway");
+ } else {
+ Log.d(TAG, "wfb-ng thread on " + what + " done.");
+ }
+ // Drop the claim even if the thread outstayed its welcome. 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 this guards against,
+ // which the per-instance check and the single-owner handoff already prevent.
+ release(what, t);
+ }
+
public synchronized void stopAll() throws InterruptedException {
for (Map.Entry entry : linkConns.entrySet()) {
nativeStop(nativeWfbngLink, context, entry.getValue().getFileDescriptor());
}
for (Map.Entry entry : linkConns.entrySet()) {
- Thread t = linkThreads.get(entry.getKey());
- if (t != null) {
- t.join();
- }
+ joinBounded(linkThreads.get(entry.getKey()), entry.getKey().getDeviceName());
// The connection holds a dup of the usbfs fd. Without close() every
// attach/detach cycle leaks one, until the process runs out.
entry.getValue().close();
- Log.d(TAG, "wfb-ng thread on " + entry.getKey().getDeviceName() + " done.");
}
linkThreads.clear();
linkConns.clear();
@@ -148,10 +224,7 @@ public synchronized void stop(UsbDevice dev) throws InterruptedException {
}
int fd = conn.getFileDescriptor();
nativeStop(nativeWfbngLink, context, fd);
- Thread t = linkThreads.get(dev);
- if (t != null) {
- t.join();
- }
+ joinBounded(linkThreads.get(dev), dev.getDeviceName());
linkThreads.remove(dev);
linkConns.remove(dev);
conn.close();
diff --git a/app/xr/build.gradle b/app/xr/build.gradle
new file mode 100644
index 00000000..20e81cc1
--- /dev/null
+++ b/app/xr/build.gradle
@@ -0,0 +1,53 @@
+plugins {
+ alias(libs.plugins.androidLibrary)
+}
+
+android {
+ namespace = "com.openipc.xr"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ buildTypes {
+ release {
+ ndk {
+ abiFilters.add("arm64-v8a")
+ abiFilters.add("armeabi-v7a")
+ }
+ }
+ debug {
+ ndk {
+ abiFilters.add("arm64-v8a")
+ abiFilters.add("armeabi-v7a")
+ }
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path("src/main/cpp/CMakeLists.txt")
+ version = "3.22.1"
+ }
+ }
+
+ // The OpenXR loader is consumed as a prefab package (OpenXR::openxr_loader,
+ // OpenXR::headers) out of the Khronos AAR.
+ buildFeatures {
+ prefab = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(libs.annotation)
+
+ // api, not implementation: the loader's .so and its manifest additions
+ // (OpenXR broker queries/permissions) have to reach the application module.
+ api(libs.openxr.loader)
+}
diff --git a/app/xr/src/main/cpp/CMakeLists.txt b/app/xr/src/main/cpp/CMakeLists.txt
new file mode 100644
index 00000000..15cc16e2
--- /dev/null
+++ b/app/xr/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,21 @@
+
+cmake_minimum_required(VERSION 3.22.1)
+
+project("XrGoggle")
+
+find_package(OpenXR REQUIRED CONFIG)
+
+add_library(${CMAKE_PROJECT_NAME} SHARED
+ XrGoggleSession.cpp
+ jni_bindings.cpp)
+
+target_link_libraries(${CMAKE_PROJECT_NAME}
+ OpenXR::openxr_loader
+ OpenXR::headers
+ android
+ EGL
+ GLESv3
+ log)
+
+set_property(TARGET ${CMAKE_PROJECT_NAME} PROPERTY CXX_STANDARD 20)
+target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -fno-omit-frame-pointer)
diff --git a/app/xr/src/main/cpp/XrGoggleSession.cpp b/app/xr/src/main/cpp/XrGoggleSession.cpp
new file mode 100644
index 00000000..d484c928
--- /dev/null
+++ b/app/xr/src/main/cpp/XrGoggleSession.cpp
@@ -0,0 +1,1543 @@
+#include "XrGoggleSession.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#define TAG "pixelpilot-xr"
+#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
+#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
+
+namespace
+{
+constexpr float kStickDeadzone = 0.30f;
+constexpr float kTriggerDeadzone = 0.15f;
+// A hand gesture has to be held this long before it counts. Grabbing a drone or a radio
+// squeezes the hand for a moment; deliberately holding a pinch does not happen by accident.
+constexpr int64_t kHandHoldNs = 700LL * 1000 * 1000;
+constexpr float kDistancePerSec = 0.9f; // meters per second of full stick deflection
+constexpr float kWidthPerSec = 1.4f;
+constexpr float kMinDistance = 0.4f;
+constexpr float kMaxDistance = 8.0f;
+constexpr float kMinWidth = 0.4f;
+constexpr float kMaxWidth = 12.0f;
+constexpr float kMaxHeightOffset = 1.5f;
+// One nudge, about 1.8 degrees at the default distance - small enough to fine-tune.
+constexpr float kHeightStep = 0.05f;
+constexpr float kHeightPerSec = 0.7f;
+
+float clampf(float v, float lo, float hi)
+{
+ return v < lo ? lo : (v > hi ? hi : v);
+}
+
+// Yaw-only quaternion, so the panel never ends up tilted when it is anchored.
+XrQuaternionf quatFromYaw(float yaw)
+{
+ return XrQuaternionf{0.0f, std::sin(yaw * 0.5f), 0.0f, std::cos(yaw * 0.5f)};
+}
+
+float yawOf(const XrQuaternionf& q)
+{
+ return std::atan2(2.0f * (q.w * q.y + q.x * q.z), 1.0f - 2.0f * (q.y * q.y + q.z * q.z));
+}
+} // namespace
+
+// ---------------------------------------------------------------------------------
+// error handling
+// ---------------------------------------------------------------------------------
+
+void XrGoggleSession::setError(const std::string& what)
+{
+ std::lock_guard lock(mErrorMutex);
+ if (mLastError.empty())
+ {
+ mLastError = what;
+ }
+ LOGE("%s", what.c_str());
+}
+
+std::string XrGoggleSession::lastError()
+{
+ std::lock_guard lock(mErrorMutex);
+ return mLastError;
+}
+
+bool XrGoggleSession::check(XrResult result, const char* what)
+{
+ if (XR_SUCCEEDED(result))
+ {
+ return true;
+ }
+ char name[XR_MAX_RESULT_STRING_SIZE] = {0};
+ if (mInstance != XR_NULL_HANDLE)
+ {
+ xrResultToString(mInstance, result, name);
+ }
+ else
+ {
+ snprintf(name, sizeof(name), "%d", (int) result);
+ }
+ setError(std::string(what) + " failed: " + name);
+ return false;
+}
+
+XrPath XrGoggleSession::path(const char* str)
+{
+ XrPath p = XR_NULL_PATH;
+ XrResult r = xrStringToPath(mInstance, str, &p);
+ if (XR_FAILED(r))
+ {
+ LOGW("xrStringToPath(%s) failed", str);
+ return XR_NULL_PATH;
+ }
+ return p;
+}
+
+bool XrGoggleSession::extensionSupported(const char* name) const
+{
+ for (const auto& e : mAvailableExtensions)
+ {
+ if (e == name) return true;
+ }
+ return false;
+}
+
+// ---------------------------------------------------------------------------------
+// bring-up
+// ---------------------------------------------------------------------------------
+
+bool XrGoggleSession::create(JNIEnv* env, jobject activity)
+{
+ if (!initLoader(env, activity)) return false;
+ if (!resolveRuntime()) return false;
+ if (!createInstance(env, activity)) return false;
+ if (!createEgl()) return false;
+ if (!createSession()) return false;
+ if (!createSpaces()) return false;
+ if (!createSwapchain(env)) return false;
+ if (!createActions()) return false;
+ // Passthrough is optional: a runtime without XR_FB_passthrough still flies.
+ if (mHasPassthrough && !createPassthrough())
+ {
+ LOGW("passthrough unavailable, continuing without it");
+ mHasPassthrough = false;
+ }
+ if (mHasRefreshRate)
+ {
+ uint32_t count = 0;
+ if (check(mXrEnumerateDisplayRefreshRatesFB(mSession, 0, &count, nullptr),
+ "xrEnumerateDisplayRefreshRatesFB") &&
+ count > 0)
+ {
+ mRefreshRates.resize(count);
+ mXrEnumerateDisplayRefreshRatesFB(mSession, count, &count, mRefreshRates.data());
+ for (float hz : mRefreshRates)
+ {
+ LOGI("display refresh rate available: %.1f Hz", hz);
+ }
+ }
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------------
+// runtime discovery
+// ---------------------------------------------------------------------------------
+
+namespace
+{
+#if defined(__aarch64__)
+constexpr const char* kAbi = "arm64-v8a";
+#elif defined(__arm__)
+constexpr const char* kAbi = "armeabi-v7a";
+#elif defined(__x86_64__)
+constexpr const char* kAbi = "x86_64";
+#else
+constexpr const char* kAbi = "x86";
+#endif
+} // namespace
+
+// True when a real runtime answered - a runtime-provided extension has to be in the list,
+// not just "the call did not fail".
+bool XrGoggleSession::runtimeAnswers()
+{
+ uint32_t count = 0;
+ if (XR_FAILED(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr)) || count == 0)
+ {
+ return false;
+ }
+ std::vector props(count, {XR_TYPE_EXTENSION_PROPERTIES});
+ if (XR_FAILED(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, props.data())))
+ {
+ return false;
+ }
+ for (const auto& p : props)
+ {
+ if (std::strcmp(p.extensionName, XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME) == 0)
+ {
+ return true;
+ }
+ }
+ return false;
+}
+
+// Drops a runtime manifest next to our files and points the loader at it.
+bool XrGoggleSession::pointLoaderAt(const char* libraryPath)
+{
+ if (mManifestDir.empty()) return false;
+ const std::string manifest = mManifestDir + "/openxr_runtime.json";
+ {
+ std::ofstream out(manifest, std::ios::trunc);
+ if (!out) return false;
+ out << "{\n"
+ << " \"file_format_version\": \"1.0.0\",\n"
+ << " \"runtime\": {\n"
+ << " \"name\": \"Android system OpenXR runtime\",\n"
+ << " \"library_path\": \"" << libraryPath << "\"\n"
+ << " }\n"
+ << "}\n";
+ }
+ setenv("XR_RUNTIME_JSON", manifest.c_str(), 1);
+ return true;
+}
+
+/*
+ * Fallback for a headset where the loader cannot find a runtime by itself.
+ *
+ * On a Quest 3 this is not needed: the Khronos loader resolves the Horizon runtime through
+ * the org.khronos.openxr.runtime_broker content provider and the first candidate ("change
+ * nothing") wins. Querying that provider from `adb shell` returns no rows, but only because
+ * the shell does not hold org.khronos.openxr.permission.OPENXR - the app does.
+ *
+ * It is kept because the loader has exactly three discovery paths - the broker,
+ * active_runtime.json on disk, and XR_RUNTIME_JSON - and a device that offers neither of the
+ * first two would otherwise be dead in the water. Where that happens the runtime is still
+ * reachable as libopenxr_forwardloader.so from the VrDriver APEX: that library sits on every
+ * app's linker search path and exports xrNegotiateLoaderRuntimeInterface, so as far as a
+ * loader is concerned it *is* the runtime.
+ */
+bool XrGoggleSession::resolveRuntime()
+{
+ if (runtimeAnswers())
+ {
+ LOGI("runtime found without an override");
+ mRuntimeLibrary = "";
+ return true;
+ }
+
+ const std::string apkPath = std::string("/apex/com.meta.xr/priv-app/VrDriver/VrDriver.apk!/lib/") +
+ kAbi + "/libopenxr_forwardloader.so";
+ const char* candidates[] = {
+ // A bare file name means "use the system library search path", which is where the
+ // APEX library actually lives for us.
+ "libopenxr_forwardloader.so",
+ // Some loaders insist on an absolute path; Android's linker understands apk!/entry.
+ apkPath.c_str(),
+ };
+
+ for (const char* candidate : candidates)
+ {
+ if (!pointLoaderAt(candidate))
+ {
+ LOGW("could not write a runtime manifest into %s", mManifestDir.c_str());
+ break;
+ }
+ LOGI("trying runtime library_path=%s", candidate);
+ if (runtimeAnswers())
+ {
+ LOGI("runtime resolved via %s", candidate);
+ mRuntimeLibrary = candidate;
+ return true;
+ }
+ }
+
+ unsetenv("XR_RUNTIME_JSON");
+ setError(
+ "No OpenXR runtime answered. Tried the system default, then "
+ "libopenxr_forwardloader.so by name and by absolute APEX path.");
+ return false;
+}
+
+bool XrGoggleSession::initLoader(JNIEnv* env, jobject activity)
+{
+ PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
+ XrResult r = xrGetInstanceProcAddr(
+ XR_NULL_HANDLE, "xrInitializeLoaderKHR", (PFN_xrVoidFunction*) &initializeLoader);
+ if (XR_FAILED(r) || initializeLoader == nullptr)
+ {
+ // Not fatal on every runtime, but on Android it usually is.
+ LOGW("xrInitializeLoaderKHR not exposed by the loader");
+ return true;
+ }
+
+ JavaVM* vm = nullptr;
+ env->GetJavaVM(&vm);
+
+ XrLoaderInitInfoAndroidKHR init{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
+ init.applicationVM = vm;
+ init.applicationContext = env->NewGlobalRef(activity);
+ r = initializeLoader((const XrLoaderInitInfoBaseHeaderKHR*) &init);
+ if (XR_FAILED(r))
+ {
+ setError("xrInitializeLoaderKHR failed");
+ return false;
+ }
+ return true;
+}
+
+bool XrGoggleSession::createInstance(JNIEnv* env, jobject activity)
+{
+ uint32_t extCount = 0;
+ if (!check(xrEnumerateInstanceExtensionProperties(nullptr, 0, &extCount, nullptr),
+ "xrEnumerateInstanceExtensionProperties"))
+ {
+ return false;
+ }
+ std::vector props(extCount, {XR_TYPE_EXTENSION_PROPERTIES});
+ if (extCount > 0)
+ {
+ if (!check(
+ xrEnumerateInstanceExtensionProperties(nullptr, extCount, &extCount, props.data()),
+ "xrEnumerateInstanceExtensionProperties"))
+ {
+ return false;
+ }
+ }
+ mAvailableExtensions.clear();
+ for (const auto& p : props)
+ {
+ mAvailableExtensions.emplace_back(p.extensionName);
+ }
+ // Worth the log lines: it is the only way to see from a bug report what the runtime on
+ // a given headset actually offers.
+ LOGI("runtime exposes %zu instance extensions:", mAvailableExtensions.size());
+ for (const auto& name : mAvailableExtensions)
+ {
+ LOGI(" %s", name.c_str());
+ }
+
+ // Without these three there is no immersive video path at all.
+ const char* required[] = {
+ XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
+ XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
+ XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME,
+ };
+ std::vector enabled;
+ for (const char* name : required)
+ {
+ if (!extensionSupported(name))
+ {
+ setError(std::string("OpenXR runtime does not support ") + name);
+ return false;
+ }
+ enabled.push_back(name);
+ }
+
+ mHasPassthrough = extensionSupported(XR_FB_PASSTHROUGH_EXTENSION_NAME);
+ mHasLayerSettings = extensionSupported(XR_FB_COMPOSITION_LAYER_SETTINGS_EXTENSION_NAME);
+ mHasRefreshRate = extensionSupported(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
+ // Lets the same actions be driven by a pinch when flying without controllers.
+ mHasHandInteraction = extensionSupported(XR_EXT_HAND_INTERACTION_EXTENSION_NAME);
+ // Needed to correct the origin of an Android-produced image, see renderFrame().
+ mHasImageLayout = extensionSupported(XR_FB_COMPOSITION_LAYER_IMAGE_LAYOUT_EXTENSION_NAME);
+ // Thumb swipes along the index finger: a discrete control that does not occupy the
+ // hand, so pinch and grasp stay free for the flight actions.
+ mHasMicrogestures = mHasHandInteraction &&
+ extensionSupported(XR_META_HAND_TRACKING_MICROGESTURES_EXTENSION_NAME);
+ // Without this only one interaction profile is live at a time: with hands tracked the
+ // controller bindings never fire, which looks exactly like "the stick is broken".
+ mHasSimultaneous =
+ extensionSupported(XR_META_SIMULTANEOUS_HANDS_AND_CONTROLLERS_EXTENSION_NAME);
+ if (mHasPassthrough) enabled.push_back(XR_FB_PASSTHROUGH_EXTENSION_NAME);
+ if (mHasLayerSettings) enabled.push_back(XR_FB_COMPOSITION_LAYER_SETTINGS_EXTENSION_NAME);
+ if (mHasRefreshRate) enabled.push_back(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
+ if (mHasHandInteraction) enabled.push_back(XR_EXT_HAND_INTERACTION_EXTENSION_NAME);
+ if (mHasImageLayout) enabled.push_back(XR_FB_COMPOSITION_LAYER_IMAGE_LAYOUT_EXTENSION_NAME);
+ if (mHasMicrogestures) enabled.push_back(XR_META_HAND_TRACKING_MICROGESTURES_EXTENSION_NAME);
+ if (mHasSimultaneous)
+ enabled.push_back(XR_META_SIMULTANEOUS_HANDS_AND_CONTROLLERS_EXTENSION_NAME);
+ LOGI(
+ "optional extensions: passthrough=%d layerSettings=%d refreshRate=%d handInteraction=%d "
+ "imageLayout=%d microgestures=%d simultaneousHandsControllers=%d",
+ (int) mHasPassthrough,
+ (int) mHasLayerSettings,
+ (int) mHasRefreshRate,
+ (int) mHasHandInteraction,
+ (int) mHasImageLayout,
+ (int) mHasMicrogestures,
+ (int) mHasSimultaneous);
+
+ JavaVM* vm = nullptr;
+ env->GetJavaVM(&vm);
+
+ XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
+ androidInfo.applicationVM = vm;
+ androidInfo.applicationActivity = env->NewGlobalRef(activity);
+
+ XrInstanceCreateInfo info{XR_TYPE_INSTANCE_CREATE_INFO};
+ info.next = &androidInfo;
+ std::strncpy(info.applicationInfo.applicationName, "PixelPilot", XR_MAX_APPLICATION_NAME_SIZE - 1);
+ std::strncpy(info.applicationInfo.engineName, "PixelPilot", XR_MAX_ENGINE_NAME_SIZE - 1);
+ info.applicationInfo.applicationVersion = 1;
+ info.applicationInfo.engineVersion = 1;
+ // Ask for 1.0: every shipping Android runtime implements it, and nothing here needs 1.1.
+ info.applicationInfo.apiVersion = XR_API_VERSION_1_0;
+ info.enabledExtensionCount = (uint32_t) enabled.size();
+ info.enabledExtensionNames = enabled.data();
+
+ if (!check(xrCreateInstance(&info, &mInstance), "xrCreateInstance")) return false;
+
+ XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
+ systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
+ if (!check(xrGetSystem(mInstance, &systemInfo, &mSystemId), "xrGetSystem")) return false;
+
+ xrGetInstanceProcAddr(mInstance,
+ "xrCreateSwapchainAndroidSurfaceKHR",
+ (PFN_xrVoidFunction*) &mXrCreateSwapchainAndroidSurfaceKHR);
+ if (mXrCreateSwapchainAndroidSurfaceKHR == nullptr)
+ {
+ setError("xrCreateSwapchainAndroidSurfaceKHR not available");
+ return false;
+ }
+ if (mHasPassthrough)
+ {
+ xrGetInstanceProcAddr(
+ mInstance, "xrCreatePassthroughFB", (PFN_xrVoidFunction*) &mXrCreatePassthroughFB);
+ xrGetInstanceProcAddr(
+ mInstance, "xrDestroyPassthroughFB", (PFN_xrVoidFunction*) &mXrDestroyPassthroughFB);
+ xrGetInstanceProcAddr(
+ mInstance, "xrPassthroughStartFB", (PFN_xrVoidFunction*) &mXrPassthroughStartFB);
+ xrGetInstanceProcAddr(
+ mInstance, "xrPassthroughPauseFB", (PFN_xrVoidFunction*) &mXrPassthroughPauseFB);
+ xrGetInstanceProcAddr(mInstance,
+ "xrCreatePassthroughLayerFB",
+ (PFN_xrVoidFunction*) &mXrCreatePassthroughLayerFB);
+ xrGetInstanceProcAddr(mInstance,
+ "xrDestroyPassthroughLayerFB",
+ (PFN_xrVoidFunction*) &mXrDestroyPassthroughLayerFB);
+ xrGetInstanceProcAddr(mInstance,
+ "xrPassthroughLayerResumeFB",
+ (PFN_xrVoidFunction*) &mXrPassthroughLayerResumeFB);
+ xrGetInstanceProcAddr(mInstance,
+ "xrPassthroughLayerPauseFB",
+ (PFN_xrVoidFunction*) &mXrPassthroughLayerPauseFB);
+ xrGetInstanceProcAddr(mInstance,
+ "xrPassthroughLayerSetStyleFB",
+ (PFN_xrVoidFunction*) &mXrPassthroughLayerSetStyleFB);
+ mHasPassthrough = mXrCreatePassthroughFB != nullptr && mXrCreatePassthroughLayerFB != nullptr &&
+ mXrPassthroughStartFB != nullptr;
+ }
+ if (mHasSimultaneous)
+ {
+ xrGetInstanceProcAddr(mInstance,
+ "xrResumeSimultaneousHandsAndControllersTrackingMETA",
+ (PFN_xrVoidFunction*) &mXrResumeSimultaneous);
+ mHasSimultaneous = mXrResumeSimultaneous != nullptr;
+ }
+ if (mHasRefreshRate)
+ {
+ xrGetInstanceProcAddr(mInstance,
+ "xrEnumerateDisplayRefreshRatesFB",
+ (PFN_xrVoidFunction*) &mXrEnumerateDisplayRefreshRatesFB);
+ xrGetInstanceProcAddr(mInstance,
+ "xrRequestDisplayRefreshRateFB",
+ (PFN_xrVoidFunction*) &mXrRequestDisplayRefreshRateFB);
+ mHasRefreshRate =
+ mXrEnumerateDisplayRefreshRatesFB != nullptr && mXrRequestDisplayRefreshRateFB != nullptr;
+ }
+ return true;
+}
+
+bool XrGoggleSession::createEgl()
+{
+ // Nothing is rendered through this context - OpenXR just requires a graphics binding,
+ // and the runtime needs a current context on the session thread.
+ mEglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
+ if (mEglDisplay == EGL_NO_DISPLAY)
+ {
+ setError("eglGetDisplay failed");
+ return false;
+ }
+ if (eglInitialize(mEglDisplay, nullptr, nullptr) == EGL_FALSE)
+ {
+ setError("eglInitialize failed");
+ return false;
+ }
+
+ const EGLint configAttribs[] = {EGL_RENDERABLE_TYPE,
+ EGL_OPENGL_ES3_BIT_KHR,
+ EGL_SURFACE_TYPE,
+ EGL_PBUFFER_BIT,
+ EGL_RED_SIZE,
+ 8,
+ EGL_GREEN_SIZE,
+ 8,
+ EGL_BLUE_SIZE,
+ 8,
+ EGL_ALPHA_SIZE,
+ 8,
+ EGL_DEPTH_SIZE,
+ 0,
+ EGL_STENCIL_SIZE,
+ 0,
+ EGL_SAMPLES,
+ 0,
+ EGL_NONE};
+ EGLint numConfigs = 0;
+ if (eglChooseConfig(mEglDisplay, configAttribs, &mEglConfig, 1, &numConfigs) == EGL_FALSE ||
+ numConfigs < 1)
+ {
+ setError("eglChooseConfig found no usable config");
+ return false;
+ }
+
+ const EGLint contextAttribs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
+ mEglContext = eglCreateContext(mEglDisplay, mEglConfig, EGL_NO_CONTEXT, contextAttribs);
+ if (mEglContext == EGL_NO_CONTEXT)
+ {
+ setError("eglCreateContext failed");
+ return false;
+ }
+
+ const EGLint surfaceAttribs[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
+ mEglSurface = eglCreatePbufferSurface(mEglDisplay, mEglConfig, surfaceAttribs);
+ if (mEglSurface == EGL_NO_SURFACE)
+ {
+ setError("eglCreatePbufferSurface failed");
+ return false;
+ }
+ if (eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext) == EGL_FALSE)
+ {
+ setError("eglMakeCurrent failed");
+ return false;
+ }
+ return true;
+}
+
+bool XrGoggleSession::createSession()
+{
+ // Required by the spec before xrCreateSession, even though we never render.
+ PFN_xrGetOpenGLESGraphicsRequirementsKHR getRequirements = nullptr;
+ xrGetInstanceProcAddr(mInstance,
+ "xrGetOpenGLESGraphicsRequirementsKHR",
+ (PFN_xrVoidFunction*) &getRequirements);
+ if (getRequirements != nullptr)
+ {
+ XrGraphicsRequirementsOpenGLESKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
+ getRequirements(mInstance, mSystemId, &requirements);
+ }
+
+ XrGraphicsBindingOpenGLESAndroidKHR binding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
+ binding.display = mEglDisplay;
+ binding.config = mEglConfig;
+ binding.context = mEglContext;
+
+ XrSessionCreateInfo info{XR_TYPE_SESSION_CREATE_INFO};
+ info.next = &binding;
+ info.systemId = mSystemId;
+ return check(xrCreateSession(mInstance, &info, &mSession), "xrCreateSession");
+}
+
+bool XrGoggleSession::createSpaces()
+{
+ XrReferenceSpaceCreateInfo viewInfo{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
+ viewInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
+ viewInfo.poseInReferenceSpace = XrPosef{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
+ if (!check(xrCreateReferenceSpace(mSession, &viewInfo, &mViewSpace),
+ "xrCreateReferenceSpace(VIEW)"))
+ {
+ return false;
+ }
+
+ XrReferenceSpaceCreateInfo localInfo{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
+ localInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
+ localInfo.poseInReferenceSpace = XrPosef{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
+ return check(xrCreateReferenceSpace(mSession, &localInfo, &mLocalSpace),
+ "xrCreateReferenceSpace(LOCAL)");
+}
+
+/*
+ * MediaCodec is the producer for an Android surface swapchain, so the image description
+ * fields are not really ours to choose. The extension documents them as ignored, but Meta's
+ * runtime validates them and wants them zeroed: passing the "obvious" 1s gets
+ * XR_ERROR_VALIDATION_FAILURE on a Quest 3 (Horizon OS, Android 14), while all-zero is
+ * accepted. The zeroed shape is therefore first; the rest stay as a fallback for a runtime
+ * that wants something else, and the accepted one is logged.
+ */
+bool XrGoggleSession::createSwapchain(JNIEnv* env)
+{
+ // GL_RGBA8, for the variant where the runtime wants a real format.
+ constexpr int64_t kGlRgba8 = 0x8058;
+
+ struct Variant
+ {
+ const char* what;
+ XrSwapchainUsageFlags usage;
+ int64_t format;
+ uint32_t counts; // sampleCount / faceCount / arraySize / mipCount
+ };
+ const Variant variants[] = {
+ {"ignored fields zeroed", XR_SWAPCHAIN_USAGE_SAMPLED_BIT, 0, 0},
+ {"ignored fields set to 1", XR_SWAPCHAIN_USAGE_SAMPLED_BIT, 0, 1},
+ {"zeroed + color attachment",
+ XR_SWAPCHAIN_USAGE_SAMPLED_BIT | XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT,
+ 0,
+ 0},
+ {"zeroed + explicit RGBA8", XR_SWAPCHAIN_USAGE_SAMPLED_BIT, kGlRgba8, 0},
+ {"1s + explicit RGBA8", XR_SWAPCHAIN_USAGE_SAMPLED_BIT, kGlRgba8, 1},
+ };
+
+ jobject surface = nullptr;
+ XrResult last = XR_ERROR_VALIDATION_FAILURE;
+ for (const auto& variant : variants)
+ {
+ XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO};
+ info.usageFlags = variant.usage;
+ info.format = variant.format;
+ info.sampleCount = variant.counts;
+ info.width = (uint32_t) mSwapchainWidth;
+ info.height = (uint32_t) mSwapchainHeight;
+ info.faceCount = variant.counts;
+ info.arraySize = variant.counts;
+ info.mipCount = variant.counts;
+
+ surface = nullptr;
+ last = mXrCreateSwapchainAndroidSurfaceKHR(mSession, &info, &mSwapchain, &surface);
+ if (XR_SUCCEEDED(last) && surface != nullptr)
+ {
+ LOGI("android surface swapchain %dx%d created (%s)",
+ mSwapchainWidth,
+ mSwapchainHeight,
+ variant.what);
+ mVideoSurface = env->NewGlobalRef(surface);
+ return true;
+ }
+ char name[XR_MAX_RESULT_STRING_SIZE] = {0};
+ xrResultToString(mInstance, last, name);
+ LOGW("swapchain variant \'%s\' rejected: %s", variant.what, name);
+ if (XR_SUCCEEDED(last) && mSwapchain != XR_NULL_HANDLE)
+ {
+ // Succeeded but handed back no Surface - not usable, do not leak it.
+ xrDestroySwapchain(mSwapchain);
+ mSwapchain = XR_NULL_HANDLE;
+ }
+ }
+
+ // Nothing worked. Dump what the runtime does offer so the next attempt is informed.
+ uint32_t formatCount = 0;
+ if (XR_SUCCEEDED(xrEnumerateSwapchainFormats(mSession, 0, &formatCount, nullptr)) &&
+ formatCount > 0)
+ {
+ std::vector formats(formatCount);
+ if (XR_SUCCEEDED(
+ xrEnumerateSwapchainFormats(mSession, formatCount, &formatCount, formats.data())))
+ {
+ for (int64_t f : formats)
+ {
+ LOGI("runtime swapchain format 0x%llx", (unsigned long long) f);
+ }
+ }
+ }
+ return check(last, "xrCreateSwapchainAndroidSurfaceKHR");
+}
+
+XrAction XrGoggleSession::boolAction(const char* name, const char* localized)
+{
+ XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
+ info.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT;
+ std::strncpy(info.actionName, name, XR_MAX_ACTION_NAME_SIZE - 1);
+ std::strncpy(info.localizedActionName, localized, XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
+ XrAction action = XR_NULL_HANDLE;
+ if (!check(xrCreateAction(mActionSet, &info, &action), "xrCreateAction"))
+ {
+ return XR_NULL_HANDLE;
+ }
+ return action;
+}
+
+bool XrGoggleSession::createActions()
+{
+ XrActionSetCreateInfo setInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
+ std::strncpy(setInfo.actionSetName, "goggle", XR_MAX_ACTION_SET_NAME_SIZE - 1);
+ std::strncpy(setInfo.localizedActionSetName, "Goggle", XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE - 1);
+ setInfo.priority = 0;
+ if (!check(xrCreateActionSet(mInstance, &setInfo, &mActionSet), "xrCreateActionSet"))
+ {
+ return false;
+ }
+
+ mActionRecenter = boolAction("recenter", "Recenter view");
+ mActionPassthrough = boolAction("passthrough", "Toggle passthrough");
+ mActionRecord = boolAction("record", "Toggle recording");
+ mActionLockMode = boolAction("lock_mode", "Toggle head lock");
+ mActionRaise = boolAction("raise", "Move the screen up");
+ mActionLower = boolAction("lower", "Move the screen down");
+ // Separate actions so the hold requirement applies to the hand only - a controller
+ // button should still act the moment it is pressed.
+ mActionHandRecenter = boolAction("hand_recenter", "Recenter view (hold)");
+ mActionHandPassthrough = boolAction("hand_passthrough", "Toggle passthrough (hold)");
+ mActionExit = boolAction("exit", "Leave VR");
+
+ XrActionCreateInfo stick{XR_TYPE_ACTION_CREATE_INFO};
+ stick.actionType = XR_ACTION_TYPE_VECTOR2F_INPUT;
+ std::strncpy(stick.actionName, "place", XR_MAX_ACTION_NAME_SIZE - 1);
+ std::strncpy(stick.localizedActionName, "Move the screen", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
+ if (!check(xrCreateAction(mActionSet, &stick, &mActionStick), "xrCreateAction(place)"))
+ {
+ return false;
+ }
+
+ // Analog, so the trigger and grip pull the panel in and push it out at a rate.
+ XrActionCreateInfo axis{XR_TYPE_ACTION_CREATE_INFO};
+ axis.actionType = XR_ACTION_TYPE_FLOAT_INPUT;
+ std::strncpy(axis.actionName, "nearer", XR_MAX_ACTION_NAME_SIZE - 1);
+ std::strncpy(axis.localizedActionName, "Pull the screen closer", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
+ if (!check(xrCreateAction(mActionSet, &axis, &mActionNearer), "xrCreateAction(nearer)"))
+ {
+ return false;
+ }
+ std::strncpy(axis.actionName, "farther", XR_MAX_ACTION_NAME_SIZE - 1);
+ std::strncpy(axis.localizedActionName, "Push the screen away", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
+ if (!check(xrCreateAction(mActionSet, &axis, &mActionFarther), "xrCreateAction(farther)"))
+ {
+ return false;
+ }
+
+ XrActionCreateInfo haptic{XR_TYPE_ACTION_CREATE_INFO};
+ haptic.actionType = XR_ACTION_TYPE_VIBRATION_OUTPUT;
+ std::strncpy(haptic.actionName, "haptic", XR_MAX_ACTION_NAME_SIZE - 1);
+ std::strncpy(haptic.localizedActionName, "Haptic feedback", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
+ if (!check(xrCreateAction(mActionSet, &haptic, &mActionHaptic), "xrCreateAction(haptic)"))
+ {
+ return false;
+ }
+
+ // Mirrored left and right, so a single controller in either hand is enough. A/B and
+ // X/Y sit in the same places on their respective controllers, so binding both to the
+ // same action gives the same thumb positions whichever one you picked up.
+ //
+ // thumbstick up/down screen up/down
+ // thumbstick left/right screen smaller/bigger
+ // trigger pull the screen closer
+ // grip push the screen away
+ // A / X (lower button) recenter
+ // B / Y (upper button) toggle passthrough
+ // thumbstick click toggle recording
+ //
+ // Head lock stays on a hand gesture and in the flat settings menu - it is set once,
+ // not adjusted in flight, and there is no button left that exists on both controllers.
+ const XrActionSuggestedBinding touchBindings[] = {
+ {mActionRecenter, path("/user/hand/right/input/a/click")},
+ {mActionRecenter, path("/user/hand/left/input/x/click")},
+ {mActionPassthrough, path("/user/hand/right/input/b/click")},
+ {mActionPassthrough, path("/user/hand/left/input/y/click")},
+ {mActionRecord, path("/user/hand/right/input/thumbstick/click")},
+ {mActionRecord, path("/user/hand/left/input/thumbstick/click")},
+ {mActionStick, path("/user/hand/right/input/thumbstick")},
+ {mActionStick, path("/user/hand/left/input/thumbstick")},
+ {mActionNearer, path("/user/hand/right/input/trigger/value")},
+ {mActionNearer, path("/user/hand/left/input/trigger/value")},
+ {mActionFarther, path("/user/hand/right/input/squeeze/value")},
+ {mActionFarther, path("/user/hand/left/input/squeeze/value")},
+ {mActionExit, path("/user/hand/left/input/menu/click")},
+ {mActionHaptic, path("/user/hand/left/output/haptic")},
+ {mActionHaptic, path("/user/hand/right/output/haptic")},
+ };
+ XrInteractionProfileSuggestedBinding touch{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
+ touch.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
+ touch.suggestedBindings = touchBindings;
+ touch.countSuggestedBindings = (uint32_t) (sizeof(touchBindings) / sizeof(touchBindings[0]));
+ if (XR_FAILED(xrSuggestInteractionProfileBindings(mInstance, &touch)))
+ {
+ LOGW("touch controller bindings rejected, controls may be unavailable");
+ }
+
+ // Bare minimum so something still works on a runtime without Touch controllers.
+ const XrActionSuggestedBinding simpleBindings[] = {
+ {mActionRecenter, path("/user/hand/right/input/select/click")},
+ {mActionPassthrough, path("/user/hand/left/input/select/click")},
+ {mActionHaptic, path("/user/hand/left/output/haptic")},
+ {mActionHaptic, path("/user/hand/right/output/haptic")},
+ };
+ XrInteractionProfileSuggestedBinding simple{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
+ simple.interactionProfile = path("/interaction_profiles/khr/simple_controller");
+ simple.suggestedBindings = simpleBindings;
+ simple.countSuggestedBindings = (uint32_t) (sizeof(simpleBindings) / sizeof(simpleBindings[0]));
+ if (XR_FAILED(xrSuggestInteractionProfileBindings(mInstance, &simple)))
+ {
+ LOGW("simple controller bindings rejected");
+ }
+
+ // Hand tracking, for flying without controllers. A boolean action may be bound to a
+ // float source - the runtime does the thresholding - so the same four actions work.
+ // Pinch is index-to-thumb, grasp is a whole-hand squeeze, which are far enough apart
+ // not to trigger each other.
+ if (mHasHandInteraction)
+ {
+ // No grasp bindings: a whole-hand squeeze is what you do to pick up a drone, and
+ // having that flip passthrough mid-flight is worse than not having the shortcut.
+ // Pinch survives, but only when held - see heldActionFired().
+ std::vector handBindings = {
+ {mActionHandRecenter, path("/user/hand/right/input/pinch_ext/value")},
+ {mActionHandPassthrough, path("/user/hand/left/input/pinch_ext/value")},
+ };
+ // Thumb swipes along the index finger, for nudging the panel up and down without
+ // spending pinch or grasp on it.
+ const size_t withoutMicrogestures = handBindings.size();
+ if (mHasMicrogestures)
+ {
+ handBindings.push_back(
+ {mActionRaise, path("/user/hand/right/input/swipe_forward_meta")});
+ handBindings.push_back(
+ {mActionLower, path("/user/hand/right/input/swipe_backward_meta")});
+ handBindings.push_back({mActionExit, path("/user/hand/right/input/swipe_left_meta")});
+ }
+
+ XrInteractionProfileSuggestedBinding hands{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
+ hands.interactionProfile = path("/interaction_profiles/ext/hand_interaction_ext");
+ hands.suggestedBindings = handBindings.data();
+ hands.countSuggestedBindings = (uint32_t) handBindings.size();
+ XrResult r = xrSuggestInteractionProfileBindings(mInstance, &hands);
+ if (XR_FAILED(r) && handBindings.size() != withoutMicrogestures)
+ {
+ // Suggestions are atomic per profile, so one bad path would cost the working
+ // pinch/grasp bindings too. Drop the microgestures and keep those.
+ char name[XR_MAX_RESULT_STRING_SIZE] = {0};
+ xrResultToString(mInstance, r, name);
+ LOGW("microgesture bindings rejected (%s), retrying without them", name);
+ mHasMicrogestures = false;
+ hands.countSuggestedBindings = (uint32_t) withoutMicrogestures;
+ r = xrSuggestInteractionProfileBindings(mInstance, &hands);
+ }
+ if (XR_FAILED(r))
+ {
+ char name[XR_MAX_RESULT_STRING_SIZE] = {0};
+ xrResultToString(mInstance, r, name);
+ LOGW("hand interaction bindings rejected: %s", name);
+ }
+ else
+ {
+ LOGI("hand interaction bindings suggested (held pinch%s)",
+ mHasMicrogestures ? " + microgesture height" : "");
+ }
+ }
+
+ XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
+ attach.countActionSets = 1;
+ attach.actionSets = &mActionSet;
+ return check(xrAttachSessionActionSets(mSession, &attach), "xrAttachSessionActionSets");
+}
+
+bool XrGoggleSession::createPassthrough()
+{
+ XrPassthroughCreateInfoFB info{XR_TYPE_PASSTHROUGH_CREATE_INFO_FB};
+ info.flags = 0;
+ if (!check(mXrCreatePassthroughFB(mSession, &info, &mPassthrough), "xrCreatePassthroughFB"))
+ {
+ return false;
+ }
+
+ XrPassthroughLayerCreateInfoFB layerInfo{XR_TYPE_PASSTHROUGH_LAYER_CREATE_INFO_FB};
+ layerInfo.passthrough = mPassthrough;
+ layerInfo.purpose = XR_PASSTHROUGH_LAYER_PURPOSE_RECONSTRUCTION_FB;
+ layerInfo.flags = 0;
+ if (!check(mXrCreatePassthroughLayerFB(mSession, &layerInfo, &mPassthroughLayer),
+ "xrCreatePassthroughLayerFB"))
+ {
+ return false;
+ }
+
+ if (mXrPassthroughLayerSetStyleFB != nullptr)
+ {
+ XrPassthroughStyleFB style{XR_TYPE_PASSTHROUGH_STYLE_FB};
+ style.textureOpacityFactor = 1.0f;
+ style.edgeColor = XrColor4f{0.0f, 0.0f, 0.0f, 0.0f};
+ mXrPassthroughLayerSetStyleFB(mPassthroughLayer, &style);
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------------
+// settings
+// ---------------------------------------------------------------------------------
+
+void XrGoggleSession::setSwapchainSize(int width, int height)
+{
+ if (width >= 320 && height >= 240 && width <= 4096 && height <= 4096)
+ {
+ mSwapchainWidth = width;
+ mSwapchainHeight = height;
+ mVideoWidth = width;
+ mVideoHeight = height;
+ }
+}
+
+void XrGoggleSession::setVideoResolution(int width, int height)
+{
+ if (width > 0 && height > 0)
+ {
+ mVideoWidth = width;
+ mVideoHeight = height;
+ }
+}
+
+void XrGoggleSession::setQuadDistance(float meters)
+{
+ mQuadDistance = clampf(meters, kMinDistance, kMaxDistance);
+}
+
+void XrGoggleSession::setQuadWidth(float meters)
+{
+ mQuadWidth = clampf(meters, kMinWidth, kMaxWidth);
+}
+
+void XrGoggleSession::setQuadHeightOffset(float meters)
+{
+ mQuadHeightOffset = clampf(meters, -kMaxHeightOffset, kMaxHeightOffset);
+}
+
+void XrGoggleSession::setPassthrough(bool enabled)
+{
+ mPassthroughWanted = enabled;
+}
+
+void XrGoggleSession::setSharpening(bool enabled)
+{
+ mSharpening = enabled;
+}
+
+void XrGoggleSession::setHeadLocked(bool enabled)
+{
+ mHeadLocked = enabled;
+ if (!enabled)
+ {
+ // Anchor where the pilot is looking right now instead of at the origin.
+ mRecenterRequested = true;
+ }
+}
+
+void XrGoggleSession::requestRefreshRate(float hz)
+{
+ mWantedRefreshRate = hz;
+}
+
+std::vector XrGoggleSession::refreshRates()
+{
+ return mRefreshRates;
+}
+
+void XrGoggleSession::requestHaptic(float amplitude, int durationMs)
+{
+ mPendingHapticAmplitude = clampf(amplitude, 0.0f, 1.0f);
+ mPendingHapticMs = durationMs < 1 ? 1 : durationMs;
+}
+
+// ---------------------------------------------------------------------------------
+// frame loop
+// ---------------------------------------------------------------------------------
+
+void XrGoggleSession::runLoop(JNIEnv* env, jobject listener)
+{
+ bool exitLoop = false;
+ while (!exitLoop && !mStopRequested)
+ {
+ pollEvents(env, listener, &exitLoop);
+ if (exitLoop) break;
+
+ if (!mSessionRunning)
+ {
+ // Idle without burning a core while the runtime has us backgrounded.
+ struct timespec ts = {0, 20 * 1000 * 1000};
+ nanosleep(&ts, nullptr);
+ continue;
+ }
+
+ applyPendingRefreshRate();
+ syncActions(env, listener);
+ applyPassthroughState();
+ renderFrame(env, listener);
+ }
+
+ if (mSessionRunning && mSession != XR_NULL_HANDLE)
+ {
+ xrRequestExitSession(mSession);
+ // Drain until the runtime actually stops the session, so teardown is clean.
+ for (int i = 0; i < 200 && mSessionRunning; ++i)
+ {
+ bool ignored = false;
+ pollEvents(env, listener, &ignored);
+ struct timespec ts = {0, 10 * 1000 * 1000};
+ nanosleep(&ts, nullptr);
+ }
+ }
+}
+
+void XrGoggleSession::pollEvents(JNIEnv* env, jobject listener, bool* exitLoop)
+{
+ (void) env;
+ (void) listener;
+ XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER};
+ while (true)
+ {
+ event.type = XR_TYPE_EVENT_DATA_BUFFER;
+ XrResult r = xrPollEvent(mInstance, &event);
+ if (r != XR_SUCCESS) break;
+
+ switch (event.type)
+ {
+ case XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED:
+ {
+ const auto* changed = reinterpret_cast(&event);
+ mSessionState = changed->state;
+ LOGI("session state -> %d", (int) mSessionState);
+ if (mSessionState == XR_SESSION_STATE_READY)
+ {
+ XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};
+ begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
+ if (check(xrBeginSession(mSession, &begin), "xrBeginSession"))
+ {
+ mSessionRunning = true;
+ if (mHasSimultaneous)
+ {
+ XrSimultaneousHandsAndControllersTrackingResumeInfoMETA resume{
+ XR_TYPE_SIMULTANEOUS_HANDS_AND_CONTROLLERS_TRACKING_RESUME_INFO_META};
+ if (XR_SUCCEEDED(mXrResumeSimultaneous(mSession, &resume)))
+ {
+ LOGI("hands and controllers tracked simultaneously");
+ }
+ else
+ {
+ LOGW("xrResumeSimultaneousHandsAndControllersTrackingMETA failed");
+ }
+ }
+ logInteractionProfiles();
+ }
+ }
+ else if (mSessionState == XR_SESSION_STATE_STOPPING)
+ {
+ mSessionRunning = false;
+ xrEndSession(mSession);
+ }
+ else if (mSessionState == XR_SESSION_STATE_EXITING ||
+ mSessionState == XR_SESSION_STATE_LOSS_PENDING)
+ {
+ mSessionRunning = false;
+ *exitLoop = true;
+ }
+ break;
+ }
+ case XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING:
+ {
+ LOGW("instance loss pending");
+ mSessionRunning = false;
+ *exitLoop = true;
+ break;
+ }
+ case XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED:
+ {
+ // The only way to see which of the suggested profiles is actually live.
+ logInteractionProfiles();
+ break;
+ }
+ case XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING:
+ {
+ // The runtime recentered underneath us; re-anchor on the next frame.
+ if (!mHeadLocked) mRecenterRequested = true;
+ break;
+ }
+ default:
+ break;
+ }
+ }
+}
+
+// A hand action fires once, after being held for kHandHoldNs. Anything shorter is treated
+// as an accident.
+bool XrGoggleSession::heldActionFired(XrAction action, int slot, XrTime now)
+{
+ if (action == XR_NULL_HANDLE) return false;
+ HeldButton& held = mHeld[slot];
+
+ XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO};
+ get.action = action;
+ XrActionStateBoolean state{XR_TYPE_ACTION_STATE_BOOLEAN};
+ if (XR_FAILED(xrGetActionStateBoolean(mSession, &get, &state)) || !state.isActive)
+ {
+ held = HeldButton{};
+ return false;
+ }
+
+ if (!state.currentState)
+ {
+ held = HeldButton{};
+ return false;
+ }
+ if (!held.down)
+ {
+ held.down = true;
+ held.fired = false;
+ held.downTime = now;
+ return false;
+ }
+ if (!held.fired && (now - held.downTime) >= kHandHoldNs)
+ {
+ held.fired = true;
+ return true;
+ }
+ return false;
+}
+
+void XrGoggleSession::logInteractionProfiles()
+{
+ for (const char* hand : {"/user/hand/left", "/user/hand/right"})
+ {
+ XrInteractionProfileState state{XR_TYPE_INTERACTION_PROFILE_STATE};
+ if (XR_FAILED(xrGetCurrentInteractionProfile(mSession, path(hand), &state)))
+ {
+ continue;
+ }
+ if (state.interactionProfile == XR_NULL_PATH)
+ {
+ LOGI("%s: no interaction profile active", hand);
+ continue;
+ }
+ char name[XR_MAX_PATH_LENGTH] = {0};
+ uint32_t written = 0;
+ if (XR_SUCCEEDED(
+ xrPathToString(mInstance, state.interactionProfile, sizeof(name), &written, name)))
+ {
+ LOGI("%s: %s", hand, name);
+ }
+ }
+}
+
+void XrGoggleSession::applyPendingRefreshRate()
+{
+ if (!mHasRefreshRate) return;
+ const float wanted = mWantedRefreshRate.load();
+ if (wanted <= 0.0f || std::fabs(wanted - mAppliedRefreshRate.load()) < 0.01f) return;
+
+ // Only ask for a rate the runtime actually offers, otherwise it returns an error.
+ bool supported = mRefreshRates.empty();
+ for (float hz : mRefreshRates)
+ {
+ if (std::fabs(hz - wanted) < 0.51f)
+ {
+ supported = true;
+ break;
+ }
+ }
+ if (!supported)
+ {
+ LOGW("refresh rate %.1f Hz not offered by the runtime", wanted);
+ mAppliedRefreshRate = wanted;
+ return;
+ }
+ if (XR_SUCCEEDED(mXrRequestDisplayRefreshRateFB(mSession, wanted)))
+ {
+ LOGI("display refresh rate set to %.1f Hz", wanted);
+ }
+ else
+ {
+ LOGW("xrRequestDisplayRefreshRateFB(%.1f) failed", wanted);
+ }
+ mAppliedRefreshRate = wanted;
+}
+
+// Passthrough keeps the cameras and reconstruction running, so only pay for it while it
+// is on screen.
+void XrGoggleSession::applyPassthroughState()
+{
+ if (!mHasPassthrough || mPassthrough == XR_NULL_HANDLE) return;
+ const bool wanted = mPassthroughWanted.load();
+ if (wanted == mPassthroughRunning) return;
+
+ if (wanted)
+ {
+ if (mXrPassthroughStartFB == nullptr) return;
+ if (!XR_SUCCEEDED(mXrPassthroughStartFB(mPassthrough)))
+ {
+ LOGW("xrPassthroughStartFB failed");
+ mPassthroughWanted = false;
+ return;
+ }
+ if (mXrPassthroughLayerResumeFB != nullptr)
+ {
+ mXrPassthroughLayerResumeFB(mPassthroughLayer);
+ }
+ mPassthroughRunning = true;
+ LOGI("passthrough started");
+ }
+ else
+ {
+ if (mXrPassthroughLayerPauseFB != nullptr)
+ {
+ mXrPassthroughLayerPauseFB(mPassthroughLayer);
+ }
+ if (mXrPassthroughPauseFB != nullptr)
+ {
+ mXrPassthroughPauseFB(mPassthrough);
+ }
+ mPassthroughRunning = false;
+ LOGI("passthrough paused");
+ }
+}
+
+void XrGoggleSession::applyPendingHaptic()
+{
+ const float amplitude = mPendingHapticAmplitude.exchange(0.0f);
+ const int ms = mPendingHapticMs.exchange(0);
+ if (amplitude <= 0.0f || ms <= 0 || mActionHaptic == XR_NULL_HANDLE) return;
+
+ XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION};
+ vibration.amplitude = amplitude;
+ vibration.frequency = XR_FREQUENCY_UNSPECIFIED;
+ vibration.duration = (XrDuration) ms * 1000000LL;
+
+ XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO};
+ info.action = mActionHaptic;
+ info.subactionPath = XR_NULL_PATH;
+ xrApplyHapticFeedback(mSession, &info, (const XrHapticBaseHeader*) &vibration);
+}
+
+void XrGoggleSession::syncActions(JNIEnv* env, jobject listener)
+{
+ if (mActionSet == XR_NULL_HANDLE) return;
+
+ XrActiveActionSet active{mActionSet, XR_NULL_PATH};
+ XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO};
+ sync.countActiveActionSets = 1;
+ sync.activeActionSets = &active;
+ if (XR_FAILED(xrSyncActions(mSession, &sync))) return;
+
+ jclass cls = env->GetObjectClass(listener);
+ jmethodID onButton = env->GetMethodID(cls, "onXrButton", "(I)V");
+
+ struct
+ {
+ XrAction action;
+ int button;
+ } buttons[] = {
+ {mActionRecenter, BUTTON_RECENTER},
+ {mActionPassthrough, BUTTON_PASSTHROUGH},
+ {mActionRecord, BUTTON_RECORD},
+ {mActionLockMode, BUTTON_LOCK_MODE},
+ {mActionRaise, BUTTON_RAISE},
+ {mActionLower, BUTTON_LOWER},
+ {mActionExit, BUTTON_EXIT},
+ };
+
+ for (const auto& b : buttons)
+ {
+ if (b.action == XR_NULL_HANDLE) continue;
+ XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO};
+ get.action = b.action;
+ XrActionStateBoolean state{XR_TYPE_ACTION_STATE_BOOLEAN};
+ if (XR_FAILED(xrGetActionStateBoolean(mSession, &get, &state))) continue;
+ // Rising edge only.
+ if (state.isActive && state.changedSinceLastSync && state.currentState)
+ {
+ switch (b.button)
+ {
+ case BUTTON_RECENTER:
+ mRecenterRequested = true;
+ requestHaptic(0.4f, 30);
+ break;
+ case BUTTON_PASSTHROUGH:
+ mPassthroughWanted = !mPassthroughWanted.load();
+ requestHaptic(0.4f, 30);
+ break;
+ case BUTTON_LOCK_MODE:
+ setHeadLocked(!mHeadLocked.load());
+ requestHaptic(0.4f, 30);
+ break;
+ // Recenter and passthrough are dispatched from the held-gesture path too, so
+ // they are handled where the press is detected rather than here.
+ case BUTTON_RAISE:
+ setQuadHeightOffset(mQuadHeightOffset.load() + kHeightStep);
+ requestHaptic(0.25f, 20);
+ break;
+ case BUTTON_LOWER:
+ setQuadHeightOffset(mQuadHeightOffset.load() - kHeightStep);
+ requestHaptic(0.25f, 20);
+ break;
+ case BUTTON_EXIT:
+ // Handled on the Java side; just confirm it was registered.
+ requestHaptic(0.6f, 80);
+ break;
+ default:
+ break;
+ }
+ if (onButton != nullptr)
+ {
+ env->CallVoidMethod(listener, onButton, (jint) b.button);
+ if (env->ExceptionCheck()) env->ExceptionClear();
+ }
+ }
+ }
+
+ // Hand gestures, which need a deliberate hold before they count.
+ const XrTime nowNs = mLastPredictedDisplayTime;
+ if (heldActionFired(mActionHandRecenter, 0, nowNs))
+ {
+ mRecenterRequested = true;
+ requestHaptic(0.5f, 60);
+ if (onButton != nullptr)
+ {
+ env->CallVoidMethod(listener, onButton, (jint) BUTTON_RECENTER);
+ if (env->ExceptionCheck()) env->ExceptionClear();
+ }
+ }
+ if (heldActionFired(mActionHandPassthrough, 1, nowNs))
+ {
+ mPassthroughWanted = !mPassthroughWanted.load();
+ requestHaptic(0.5f, 60);
+ if (onButton != nullptr)
+ {
+ env->CallVoidMethod(listener, onButton, (jint) BUTTON_PASSTHROUGH);
+ if (env->ExceptionCheck()) env->ExceptionClear();
+ }
+ }
+
+ const float dt = 1.0f / 72.0f; // good enough for a manual adjustment ramp
+
+ XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO};
+ if (mActionStick != XR_NULL_HANDLE)
+ {
+ get.action = mActionStick;
+ XrActionStateVector2f stickState{XR_TYPE_ACTION_STATE_VECTOR2F};
+ if (XR_SUCCEEDED(xrGetActionStateVector2f(mSession, &get, &stickState)) && stickState.isActive)
+ {
+ // Either thumbstick: up/down raises the panel, left/right resizes it.
+ const float y = stickState.currentState.y;
+ if (std::fabs(y) > kStickDeadzone)
+ {
+ setQuadHeightOffset(mQuadHeightOffset.load() + y * kHeightPerSec * dt);
+ }
+ const float x = stickState.currentState.x;
+ if (std::fabs(x) > kStickDeadzone)
+ {
+ setQuadWidth(mQuadWidth.load() + x * kWidthPerSec * dt);
+ }
+ }
+ }
+
+ // Trigger pulls the panel in, grip pushes it out, both analog.
+ float distanceRate = 0.0f;
+ for (int i = 0; i < 2; ++i)
+ {
+ const XrAction action = (i == 0) ? mActionNearer : mActionFarther;
+ if (action == XR_NULL_HANDLE) continue;
+ get.action = action;
+ XrActionStateFloat axisState{XR_TYPE_ACTION_STATE_FLOAT};
+ if (XR_SUCCEEDED(xrGetActionStateFloat(mSession, &get, &axisState)) && axisState.isActive &&
+ axisState.currentState > kTriggerDeadzone)
+ {
+ distanceRate += (i == 0 ? -1.0f : 1.0f) * axisState.currentState;
+ }
+ }
+ if (std::fabs(distanceRate) > 0.0f)
+ {
+ setQuadDistance(mQuadDistance.load() + distanceRate * kDistancePerSec * dt);
+ }
+}
+
+void XrGoggleSession::recenterAt(XrTime time)
+{
+ XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
+ if (XR_FAILED(xrLocateSpace(mViewSpace, mLocalSpace, time, &location)))
+ {
+ return;
+ }
+ if ((location.locationFlags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT) == 0 ||
+ (location.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) == 0)
+ {
+ return;
+ }
+
+ const float yaw = yawOf(location.pose.orientation);
+ const float distance = mQuadDistance.load();
+ mAnchorPose.orientation = quatFromYaw(yaw);
+ mAnchorPose.position = XrVector3f{location.pose.position.x - std::sin(yaw) * distance,
+ location.pose.position.y,
+ location.pose.position.z - std::cos(yaw) * distance};
+ mAnchorValid = true;
+}
+
+void XrGoggleSession::renderFrame(JNIEnv* env, jobject listener)
+{
+ (void) env;
+ (void) listener;
+
+ XrFrameWaitInfo waitInfo{XR_TYPE_FRAME_WAIT_INFO};
+ XrFrameState frameState{XR_TYPE_FRAME_STATE};
+ if (XR_FAILED(xrWaitFrame(mSession, &waitInfo, &frameState))) return;
+
+ XrFrameBeginInfo beginInfo{XR_TYPE_FRAME_BEGIN_INFO};
+ if (XR_FAILED(xrBeginFrame(mSession, &beginInfo))) return;
+
+ mLastPredictedDisplayTime = frameState.predictedDisplayTime;
+
+ applyPendingHaptic();
+
+ if (mRecenterRequested.exchange(false) || (!mHeadLocked.load() && !mAnchorValid))
+ {
+ recenterAt(frameState.predictedDisplayTime);
+ }
+
+ const bool headLocked = mHeadLocked.load();
+ const bool wantPassthrough =
+ mHasPassthrough && mPassthroughRunning && mPassthroughWanted.load();
+
+ // Video quad. Width is in meters at the configured distance, height follows the
+ // stream's aspect ratio so nothing is stretched.
+ const int vw = mVideoWidth.load();
+ const int vh = mVideoHeight.load();
+ const float aspect = (vw > 0 && vh > 0) ? (float) vh / (float) vw : 9.0f / 16.0f;
+ const float widthM = mQuadWidth.load();
+
+ XrCompositionLayerSettingsFB layerSettings{XR_TYPE_COMPOSITION_LAYER_SETTINGS_FB};
+ layerSettings.layerFlags = XR_COMPOSITION_LAYER_SETTINGS_QUALITY_SHARPENING_BIT_FB;
+
+ // Everything that produces into an Android Surface - MediaCodec, and Canvas for the
+ // status screen - has its origin at the top left, while the compositor samples a
+ // swapchain image from the bottom left. Without this the picture is upside down.
+ XrCompositionLayerImageLayoutFB imageLayout{XR_TYPE_COMPOSITION_LAYER_IMAGE_LAYOUT_FB};
+ imageLayout.flags = XR_COMPOSITION_LAYER_IMAGE_LAYOUT_VERTICAL_FLIP_BIT_FB;
+
+ // Chain whatever applies onto the quad.
+ const void* layerChain = nullptr;
+ if (mHasLayerSettings && mSharpening.load())
+ {
+ layerSettings.next = layerChain;
+ layerChain = &layerSettings;
+ }
+ if (mHasImageLayout)
+ {
+ imageLayout.next = const_cast(layerChain);
+ layerChain = &imageLayout;
+ }
+
+ XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD};
+ quad.next = layerChain;
+ quad.layerFlags = 0; // opaque: the video has to cover passthrough behind it
+ quad.space = headLocked ? mViewSpace : mLocalSpace;
+ quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
+ quad.subImage.swapchain = mSwapchain;
+ quad.subImage.imageArrayIndex = 0;
+ // Clamp to the swapchain the runtime handed us; MediaCodec resizes the buffers to the
+ // stream resolution, which is normally the size this was created with.
+ quad.subImage.imageRect.offset = XrOffset2Di{0, 0};
+ quad.subImage.imageRect.extent =
+ XrExtent2Di{vw > 0 && vw <= mSwapchainWidth ? vw : mSwapchainWidth,
+ vh > 0 && vh <= mSwapchainHeight ? vh : mSwapchainHeight};
+ const float heightOffset = mQuadHeightOffset.load();
+ if (headLocked)
+ {
+ quad.pose =
+ XrPosef{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, heightOffset, -mQuadDistance.load()}};
+ }
+ else
+ {
+ quad.pose = mAnchorPose;
+ quad.pose.position.y += heightOffset;
+ }
+ quad.size = XrExtent2Df{widthM, widthM * aspect};
+
+ XrCompositionLayerPassthroughFB passthroughLayer{XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_FB};
+ passthroughLayer.flags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
+ passthroughLayer.space = XR_NULL_HANDLE;
+ passthroughLayer.layerHandle = mPassthroughLayer;
+
+ const XrCompositionLayerBaseHeader* layers[2];
+ uint32_t layerCount = 0;
+ if (wantPassthrough)
+ {
+ layers[layerCount++] = (const XrCompositionLayerBaseHeader*) &passthroughLayer;
+ }
+ layers[layerCount++] = (const XrCompositionLayerBaseHeader*) &quad;
+
+ XrFrameEndInfo endInfo{XR_TYPE_FRAME_END_INFO};
+ endInfo.displayTime = frameState.predictedDisplayTime;
+ endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
+ // shouldRender false means the runtime wants nothing on screen (e.g. system overlay
+ // in front of us); submitting zero layers keeps the frame loop pacing intact.
+ endInfo.layerCount = frameState.shouldRender ? layerCount : 0;
+ endInfo.layers = frameState.shouldRender ? layers : nullptr;
+ xrEndFrame(mSession, &endInfo);
+}
+
+// ---------------------------------------------------------------------------------
+// teardown
+// ---------------------------------------------------------------------------------
+
+void XrGoggleSession::destroy(JNIEnv* env)
+{
+ if (mPassthroughLayer != XR_NULL_HANDLE && mXrDestroyPassthroughLayerFB != nullptr)
+ {
+ mXrDestroyPassthroughLayerFB(mPassthroughLayer);
+ mPassthroughLayer = XR_NULL_HANDLE;
+ }
+ if (mPassthrough != XR_NULL_HANDLE && mXrDestroyPassthroughFB != nullptr)
+ {
+ if (mPassthroughRunning && mXrPassthroughPauseFB != nullptr)
+ {
+ mXrPassthroughPauseFB(mPassthrough);
+ }
+ mXrDestroyPassthroughFB(mPassthrough);
+ mPassthrough = XR_NULL_HANDLE;
+ mPassthroughRunning = false;
+ }
+ if (mActionSet != XR_NULL_HANDLE)
+ {
+ xrDestroyActionSet(mActionSet);
+ mActionSet = XR_NULL_HANDLE;
+ }
+ if (mSwapchain != XR_NULL_HANDLE)
+ {
+ xrDestroySwapchain(mSwapchain);
+ mSwapchain = XR_NULL_HANDLE;
+ }
+ if (mVideoSurface != nullptr)
+ {
+ env->DeleteGlobalRef(mVideoSurface);
+ mVideoSurface = nullptr;
+ }
+ if (mViewSpace != XR_NULL_HANDLE)
+ {
+ xrDestroySpace(mViewSpace);
+ mViewSpace = XR_NULL_HANDLE;
+ }
+ if (mLocalSpace != XR_NULL_HANDLE)
+ {
+ xrDestroySpace(mLocalSpace);
+ mLocalSpace = XR_NULL_HANDLE;
+ }
+ if (mSession != XR_NULL_HANDLE)
+ {
+ xrDestroySession(mSession);
+ mSession = XR_NULL_HANDLE;
+ }
+ if (mEglDisplay != EGL_NO_DISPLAY)
+ {
+ eglMakeCurrent(mEglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ if (mEglSurface != EGL_NO_SURFACE) eglDestroySurface(mEglDisplay, mEglSurface);
+ if (mEglContext != EGL_NO_CONTEXT) eglDestroyContext(mEglDisplay, mEglContext);
+ eglTerminate(mEglDisplay);
+ mEglSurface = EGL_NO_SURFACE;
+ mEglContext = EGL_NO_CONTEXT;
+ mEglDisplay = EGL_NO_DISPLAY;
+ }
+ if (mInstance != XR_NULL_HANDLE)
+ {
+ xrDestroyInstance(mInstance);
+ mInstance = XR_NULL_HANDLE;
+ }
+}
diff --git a/app/xr/src/main/cpp/XrGoggleSession.h b/app/xr/src/main/cpp/XrGoggleSession.h
new file mode 100644
index 00000000..8c18a24a
--- /dev/null
+++ b/app/xr/src/main/cpp/XrGoggleSession.h
@@ -0,0 +1,223 @@
+// Immersive OpenXR presentation of the decoded video stream.
+//
+// The video never touches this code: 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. There is no
+// GL blit and no readback in the video path - the only reason an EGL context exists at
+// all is that OpenXR requires a graphics binding for the session.
+
+#ifndef PIXELPILOT_XRGOGGLESESSION_H
+#define PIXELPILOT_XRGOGGLESESSION_H
+
+#include
+#include
+#include
+#include
+
+#define XR_USE_PLATFORM_ANDROID
+#define XR_USE_GRAPHICS_API_OPENGL_ES
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+class XrGoggleSession
+{
+ public:
+ // Keep in sync with XrGoggleSession.java
+ enum Button
+ {
+ BUTTON_RECENTER = 0,
+ BUTTON_PASSTHROUGH = 1,
+ BUTTON_RECORD = 2,
+ BUTTON_LOCK_MODE = 3,
+ BUTTON_RAISE = 4,
+ BUTTON_LOWER = 5,
+ BUTTON_EXIT = 6,
+ };
+
+ XrGoggleSession() = default;
+ ~XrGoggleSession() = default;
+
+ // Brings up loader, instance, system, EGL, session, swapchain and actions.
+ // Returns false and leaves lastError() set on any failure; the caller is expected to
+ // fall back to the flat activity.
+ bool create(JNIEnv* env, jobject activity);
+
+ // Blocks until requestStop() or an unrecoverable error. Must be called from the same
+ // thread that called create(), and that thread must be attached to the JVM: button
+ // presses are dispatched to listener.onXrButton(int) from here.
+ void runLoop(JNIEnv* env, jobject listener);
+
+ void destroy(JNIEnv* env);
+
+ void requestStop() { mStopRequested = true; }
+
+ // The producer Surface for MediaCodec. Global ref, valid between create() and
+ // destroy().
+ jobject videoSurface() const { return mVideoSurface; }
+
+ std::string lastError();
+
+ std::vector refreshRates();
+
+ // --- live settings, read by the frame loop -------------------------------------
+ void setVideoResolution(int width, int height);
+ void setQuadDistance(float meters);
+ void setQuadWidth(float meters);
+ void setQuadHeightOffset(float meters);
+ void setPassthrough(bool enabled);
+ void setSharpening(bool enabled);
+ void setHeadLocked(bool enabled);
+ void requestRefreshRate(float hz);
+ void requestRecenter() { mRecenterRequested = true; }
+ // amplitude 0..1, duration in milliseconds
+ void requestHaptic(float amplitude, int durationMs);
+
+ float quadDistance() const { return mQuadDistance; }
+ float quadWidth() const { return mQuadWidth; }
+ float quadHeightOffset() const { return mQuadHeightOffset; }
+ bool headLocked() const { return mHeadLocked; }
+ bool passthroughEnabled() const { return mPassthroughWanted; }
+
+ // Swapchain geometry is fixed at create() time; the caller seeds it from the last
+ // known stream resolution.
+ void setSwapchainSize(int width, int height);
+
+ // Writable directory used to drop a runtime manifest, see resolveRuntime().
+ void setManifestDir(std::string dir) { mManifestDir = std::move(dir); }
+
+ private:
+ bool initLoader(JNIEnv* env, jobject activity);
+ bool resolveRuntime();
+ bool runtimeAnswers();
+ bool pointLoaderAt(const char* libraryPath);
+ bool createInstance(JNIEnv* env, jobject activity);
+ bool createEgl();
+ bool createSession();
+ bool createSpaces();
+ bool createSwapchain(JNIEnv* env);
+ bool createActions();
+ bool createPassthrough();
+
+ void pollEvents(JNIEnv* env, jobject listener, bool* exitLoop);
+ void renderFrame(JNIEnv* env, jobject listener);
+ void syncActions(JNIEnv* env, jobject listener);
+ void applyPendingHaptic();
+ void applyPassthroughState();
+ // Returns true on the frame a held action should fire, once per press.
+ bool heldActionFired(XrAction action, int slot, XrTime now);
+ void logInteractionProfiles();
+ void applyPendingRefreshRate();
+ void recenterAt(XrTime time);
+
+ bool extensionSupported(const char* name) const;
+ bool check(XrResult result, const char* what);
+ void setError(const std::string& what);
+ XrPath path(const char* str);
+ XrAction boolAction(const char* name, const char* localized);
+
+ // --- OpenXR handles -----------------------------------------------------------
+ XrInstance mInstance = XR_NULL_HANDLE;
+ XrSystemId mSystemId = XR_NULL_SYSTEM_ID;
+ XrSession mSession = XR_NULL_HANDLE;
+ XrSpace mViewSpace = XR_NULL_HANDLE;
+ XrSpace mLocalSpace = XR_NULL_HANDLE;
+ XrSwapchain mSwapchain = XR_NULL_HANDLE;
+ jobject mVideoSurface = nullptr;
+
+ XrPassthroughFB mPassthrough = XR_NULL_HANDLE;
+ XrPassthroughLayerFB mPassthroughLayer = XR_NULL_HANDLE;
+ bool mPassthroughRunning = false;
+
+ XrActionSet mActionSet = XR_NULL_HANDLE;
+ XrAction mActionRecenter = XR_NULL_HANDLE;
+ XrAction mActionPassthrough = XR_NULL_HANDLE;
+ XrAction mActionRecord = XR_NULL_HANDLE;
+ XrAction mActionLockMode = XR_NULL_HANDLE;
+ XrAction mActionStick = XR_NULL_HANDLE;
+ XrAction mActionNearer = XR_NULL_HANDLE;
+ XrAction mActionFarther = XR_NULL_HANDLE;
+ XrAction mActionHandRecenter = XR_NULL_HANDLE;
+ XrAction mActionHandPassthrough = XR_NULL_HANDLE;
+ XrAction mActionRaise = XR_NULL_HANDLE;
+ XrAction mActionLower = XR_NULL_HANDLE;
+ XrAction mActionExit = XR_NULL_HANDLE;
+ XrAction mActionHaptic = XR_NULL_HANDLE;
+
+ // --- EGL ----------------------------------------------------------------------
+ EGLDisplay mEglDisplay = EGL_NO_DISPLAY;
+ EGLConfig mEglConfig = nullptr;
+ EGLSurface mEglSurface = EGL_NO_SURFACE;
+ EGLContext mEglContext = EGL_NO_CONTEXT;
+
+ // --- extension entry points ---------------------------------------------------
+ PFN_xrCreateSwapchainAndroidSurfaceKHR mXrCreateSwapchainAndroidSurfaceKHR = nullptr;
+ PFN_xrCreatePassthroughFB mXrCreatePassthroughFB = nullptr;
+ PFN_xrDestroyPassthroughFB mXrDestroyPassthroughFB = nullptr;
+ PFN_xrPassthroughStartFB mXrPassthroughStartFB = nullptr;
+ PFN_xrPassthroughPauseFB mXrPassthroughPauseFB = nullptr;
+ PFN_xrCreatePassthroughLayerFB mXrCreatePassthroughLayerFB = nullptr;
+ PFN_xrDestroyPassthroughLayerFB mXrDestroyPassthroughLayerFB = nullptr;
+ PFN_xrPassthroughLayerResumeFB mXrPassthroughLayerResumeFB = nullptr;
+ PFN_xrPassthroughLayerPauseFB mXrPassthroughLayerPauseFB = nullptr;
+ PFN_xrPassthroughLayerSetStyleFB mXrPassthroughLayerSetStyleFB = nullptr;
+ PFN_xrEnumerateDisplayRefreshRatesFB mXrEnumerateDisplayRefreshRatesFB = nullptr;
+ PFN_xrRequestDisplayRefreshRateFB mXrRequestDisplayRefreshRateFB = nullptr;
+ PFN_xrResumeSimultaneousHandsAndControllersTrackingMETA mXrResumeSimultaneous = nullptr;
+
+ std::vector mAvailableExtensions;
+ bool mHasPassthrough = false;
+ bool mHasHandInteraction = false;
+ bool mHasImageLayout = false;
+ bool mHasMicrogestures = false;
+ bool mHasSimultaneous = false;
+ bool mHasLayerSettings = false;
+ bool mHasRefreshRate = false;
+
+ // --- state --------------------------------------------------------------------
+ XrTime mLastPredictedDisplayTime = 0;
+ XrSessionState mSessionState = XR_SESSION_STATE_UNKNOWN;
+ bool mSessionRunning = false;
+ std::atomic mStopRequested{false};
+ std::atomic mRecenterRequested{false};
+
+ std::atomic mVideoWidth{1920};
+ std::atomic mVideoHeight{1080};
+ int mSwapchainWidth = 1920;
+ int mSwapchainHeight = 1080;
+ std::atomic mQuadDistance{1.6f};
+ std::atomic mQuadWidth{2.2f};
+ std::atomic mQuadHeightOffset{0.0f};
+ std::atomic mHeadLocked{true};
+ std::atomic mPassthroughWanted{false};
+ std::atomic mSharpening{true};
+ std::atomic mWantedRefreshRate{0.0f};
+ std::atomic mAppliedRefreshRate{0.0f};
+
+ std::atomic mPendingHapticAmplitude{0.0f};
+ std::atomic mPendingHapticMs{0};
+
+ // Anchor for world-locked mode, in the LOCAL reference space.
+ XrPosef mAnchorPose = {{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
+ bool mAnchorValid = false;
+
+ std::string mManifestDir;
+ std::string mRuntimeLibrary;
+ // Press bookkeeping for the hand actions, which need a deliberate hold.
+ struct HeldButton
+ {
+ bool down = false;
+ bool fired = false;
+ XrTime downTime = 0;
+ };
+ HeldButton mHeld[2];
+ std::vector mRefreshRates;
+ std::mutex mErrorMutex;
+ std::string mLastError;
+};
+
+#endif // PIXELPILOT_XRGOGGLESESSION_H
diff --git a/app/xr/src/main/cpp/jni_bindings.cpp b/app/xr/src/main/cpp/jni_bindings.cpp
new file mode 100644
index 00000000..12e68b76
--- /dev/null
+++ b/app/xr/src/main/cpp/jni_bindings.cpp
@@ -0,0 +1,205 @@
+#include
+
+#include "XrGoggleSession.h"
+
+#define JNI_METHOD(return_type, method_name) \
+ extern "C" JNIEXPORT return_type JNICALL Java_com_openipc_xr_XrGoggleSession_##method_name
+
+static inline XrGoggleSession* native(jlong handle)
+{
+ return reinterpret_cast(handle);
+}
+
+JNI_METHOD(jlong, nativeAlloc)(JNIEnv* env, jclass clazz)
+{
+ (void) env;
+ (void) clazz;
+ return reinterpret_cast(new XrGoggleSession());
+}
+
+JNI_METHOD(void, nativeFree)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ delete native(handle);
+}
+
+JNI_METHOD(void, nativeSetSwapchainSize)
+(JNIEnv* env, jclass clazz, jlong handle, jint width, jint height)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setSwapchainSize(width, height);
+}
+
+JNI_METHOD(void, nativeSetManifestDir)(JNIEnv* env, jclass clazz, jlong handle, jstring dir)
+{
+ (void) clazz;
+ if (dir == nullptr) return;
+ const char* chars = env->GetStringUTFChars(dir, nullptr);
+ if (chars != nullptr)
+ {
+ native(handle)->setManifestDir(std::string(chars));
+ env->ReleaseStringUTFChars(dir, chars);
+ }
+}
+
+JNI_METHOD(jboolean, nativeCreate)(JNIEnv* env, jclass clazz, jlong handle, jobject activity)
+{
+ (void) clazz;
+ return native(handle)->create(env, activity) ? JNI_TRUE : JNI_FALSE;
+}
+
+JNI_METHOD(jobject, nativeGetVideoSurface)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ return native(handle)->videoSurface();
+}
+
+JNI_METHOD(void, nativeRunLoop)(JNIEnv* env, jclass clazz, jlong handle, jobject listener)
+{
+ (void) clazz;
+ native(handle)->runLoop(env, listener);
+}
+
+JNI_METHOD(void, nativeRequestStop)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->requestStop();
+}
+
+JNI_METHOD(void, nativeDestroy)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) clazz;
+ native(handle)->destroy(env);
+}
+
+JNI_METHOD(jstring, nativeLastError)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) clazz;
+ const std::string error = native(handle)->lastError();
+ if (error.empty()) return nullptr;
+ return env->NewStringUTF(error.c_str());
+}
+
+JNI_METHOD(jfloatArray, nativeGetRefreshRates)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) clazz;
+ const std::vector rates = native(handle)->refreshRates();
+ jfloatArray out = env->NewFloatArray((jsize) rates.size());
+ if (out == nullptr) return nullptr;
+ if (!rates.empty())
+ {
+ env->SetFloatArrayRegion(out, 0, (jsize) rates.size(), rates.data());
+ }
+ return out;
+}
+
+JNI_METHOD(void, nativeSetVideoResolution)
+(JNIEnv* env, jclass clazz, jlong handle, jint width, jint height)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setVideoResolution(width, height);
+}
+
+JNI_METHOD(void, nativeSetQuadDistance)(JNIEnv* env, jclass clazz, jlong handle, jfloat meters)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setQuadDistance(meters);
+}
+
+JNI_METHOD(void, nativeSetQuadWidth)(JNIEnv* env, jclass clazz, jlong handle, jfloat meters)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setQuadWidth(meters);
+}
+
+JNI_METHOD(void, nativeSetQuadHeightOffset)(JNIEnv* env, jclass clazz, jlong handle, jfloat meters)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setQuadHeightOffset(meters);
+}
+
+JNI_METHOD(jfloat, nativeGetQuadHeightOffset)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ return native(handle)->quadHeightOffset();
+}
+
+JNI_METHOD(void, nativeSetPassthrough)(JNIEnv* env, jclass clazz, jlong handle, jboolean enabled)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setPassthrough(enabled == JNI_TRUE);
+}
+
+JNI_METHOD(void, nativeSetSharpening)(JNIEnv* env, jclass clazz, jlong handle, jboolean enabled)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setSharpening(enabled == JNI_TRUE);
+}
+
+JNI_METHOD(void, nativeSetHeadLocked)(JNIEnv* env, jclass clazz, jlong handle, jboolean enabled)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->setHeadLocked(enabled == JNI_TRUE);
+}
+
+JNI_METHOD(void, nativeRequestRefreshRate)(JNIEnv* env, jclass clazz, jlong handle, jfloat hz)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->requestRefreshRate(hz);
+}
+
+JNI_METHOD(void, nativeRequestRecenter)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->requestRecenter();
+}
+
+JNI_METHOD(void, nativeHaptic)
+(JNIEnv* env, jclass clazz, jlong handle, jfloat amplitude, jint durationMs)
+{
+ (void) env;
+ (void) clazz;
+ native(handle)->requestHaptic(amplitude, durationMs);
+}
+
+JNI_METHOD(jfloat, nativeGetQuadDistance)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ return native(handle)->quadDistance();
+}
+
+JNI_METHOD(jfloat, nativeGetQuadWidth)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ return native(handle)->quadWidth();
+}
+
+JNI_METHOD(jboolean, nativeIsHeadLocked)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ return native(handle)->headLocked() ? JNI_TRUE : JNI_FALSE;
+}
+
+JNI_METHOD(jboolean, nativeIsPassthroughEnabled)(JNIEnv* env, jclass clazz, jlong handle)
+{
+ (void) env;
+ (void) clazz;
+ return native(handle)->passthroughEnabled() ? JNI_TRUE : JNI_FALSE;
+}
diff --git a/app/xr/src/main/java/com/openipc/xr/XrGoggleSession.java b/app/xr/src/main/java/com/openipc/xr/XrGoggleSession.java
new file mode 100644
index 00000000..d78267ea
--- /dev/null
+++ b/app/xr/src/main/java/com/openipc/xr/XrGoggleSession.java
@@ -0,0 +1,320 @@
+package com.openipc.xr;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.Surface;
+
+import androidx.annotation.Keep;
+import androidx.annotation.Nullable;
+
+/**
+ * Immersive OpenXR presentation of the decoded video stream.
+ *
+ * The video is not rendered by this class. {@code xrCreateSwapchainAndroidSurfaceKHR}
+ * hands out a {@link Surface} that is the producer side of a compositor swapchain;
+ * MediaCodec decodes straight into it and the runtime composites it as a head locked
+ * quad layer. No GL blit, no readback, no view hierarchy.
+ *
+ *
All native calls happen on a dedicated thread owned by this class. Listener
+ * callbacks are posted to the main looper.
+ */
+@Keep
+public final class XrGoggleSession {
+ // Keep in sync with XrGoggleSession.h
+ public static final int BUTTON_RECENTER = 0;
+ public static final int BUTTON_PASSTHROUGH = 1;
+ public static final int BUTTON_RECORD = 2;
+ public static final int BUTTON_LOCK_MODE = 3;
+ public static final int BUTTON_RAISE = 4;
+ public static final int BUTTON_LOWER = 5;
+ public static final int BUTTON_EXIT = 6;
+
+ private static final String TAG = "pixelpilot-xr";
+
+ private static boolean libraryLoaded;
+
+ static {
+ try {
+ System.loadLibrary("XrGoggle");
+ libraryLoaded = true;
+ } catch (Throwable t) {
+ // A device without the OpenXR loader still has to be able to run the flat app.
+ Log.w(TAG, "libXrGoggle not available: " + t.getMessage());
+ libraryLoaded = false;
+ }
+ }
+
+ public interface Listener {
+ /** The compositor swapchain is ready; hand this Surface to the decoder. */
+ void onXrReady(Surface videoSurface);
+
+ /** One of the BUTTON_* constants was pressed on a controller. */
+ void onXrButton(int button);
+
+ /** Session ended. {@code error} is null for a normal stop. */
+ void onXrStopped(@Nullable String error);
+ }
+
+ private final Activity activity;
+ private final Listener listener;
+ private final Handler main = new Handler(Looper.getMainLooper());
+
+ private long handle;
+ private Thread thread;
+ private volatile boolean stopping;
+
+ public XrGoggleSession(Activity activity, Listener listener) {
+ this.activity = activity;
+ this.listener = listener;
+ }
+
+ /**
+ * Cheap pre-flight check used to decide whether to offer the mode at all. It says
+ * "this looks like a headset", not "a session will succeed" — only {@link #start()}
+ * can tell you that.
+ */
+ public static boolean isSupportedDevice(Context context) {
+ if (!libraryLoaded) {
+ return false;
+ }
+ PackageManager pm = context.getPackageManager();
+ if (pm.hasSystemFeature("android.hardware.vr.headtracking")
+ || pm.hasSystemFeature("android.software.xr.immersive")
+ || pm.hasSystemFeature("oculus.software.handtracking")) {
+ return true;
+ }
+ // Horizon OS shell, present on every Quest.
+ try {
+ pm.getPackageInfo("com.oculus.vrshell", 0);
+ return true;
+ } catch (PackageManager.NameNotFoundException ignored) {
+ return false;
+ }
+ }
+
+ public static boolean isLibraryLoaded() {
+ return libraryLoaded;
+ }
+
+ /** Seeds the swapchain geometry. Must be called before {@link #start()}. */
+ public void setSwapchainSize(int width, int height) {
+ ensureHandle();
+ if (handle != 0) {
+ nativeSetSwapchainSize(handle, width, height);
+ }
+ }
+
+ /**
+ * Creates the session on a background thread and starts the frame loop. The listener
+ * gets either {@link Listener#onXrReady} or {@link Listener#onXrStopped} with an
+ * error.
+ */
+ public void start() {
+ if (thread != null) {
+ return;
+ }
+ ensureHandle();
+ if (handle == 0) {
+ main.post(() -> listener.onXrStopped("OpenXR native library is not available"));
+ return;
+ }
+ thread = new Thread(this::threadBody, "xr-goggle");
+ // The frame loop drives the compositor; it must not lose to background work.
+ thread.setPriority(Thread.MAX_PRIORITY);
+ thread.start();
+ }
+
+ /** Asks the frame loop to exit. Returns immediately. */
+ public void stop() {
+ stopping = true;
+ if (handle != 0) {
+ nativeRequestStop(handle);
+ }
+ }
+
+ /** Stops and joins, then releases the native object. Safe to call twice. */
+ public void release() {
+ stop();
+ Thread t = thread;
+ if (t != null) {
+ try {
+ t.join(2000);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ thread = null;
+ }
+ if (handle != 0) {
+ nativeFree(handle);
+ handle = 0;
+ }
+ }
+
+ private void ensureHandle() {
+ if (libraryLoaded && handle == 0) {
+ handle = nativeAlloc();
+ // Somewhere writable for the runtime manifest the loader may need.
+ nativeSetManifestDir(handle, activity.getFilesDir().getAbsolutePath());
+ }
+ }
+
+ private void threadBody() {
+ final long h = handle;
+ if (!nativeCreate(h, activity)) {
+ final String error = errorOr("OpenXR session could not be created");
+ main.post(() -> listener.onXrStopped(error));
+ return;
+ }
+ final Surface surface = nativeGetVideoSurface(h);
+ if (surface == null) {
+ main.post(() -> listener.onXrStopped("OpenXR gave us no video surface"));
+ nativeDestroy(h);
+ return;
+ }
+ main.post(() -> listener.onXrReady(surface));
+
+ // Blocks until stop() or an unrecoverable runtime error. Button presses arrive
+ // through onXrButton() below, called from this thread.
+ nativeRunLoop(h, this);
+
+ final String error = stopping ? null : errorOr("OpenXR session ended unexpectedly");
+ nativeDestroy(h);
+ main.post(() -> listener.onXrStopped(error));
+ }
+
+ private String errorOr(String fallback) {
+ String error = handle != 0 ? nativeLastError(handle) : null;
+ return error != null ? error : fallback;
+ }
+
+ /** Called from the XR frame loop thread. */
+ @Keep
+ void onXrButton(int button) {
+ main.post(() -> listener.onXrButton(button));
+ }
+
+ // --- live settings ---------------------------------------------------------------
+
+ public void setVideoResolution(int width, int height) {
+ if (handle != 0) nativeSetVideoResolution(handle, width, height);
+ }
+
+ public void setQuadDistance(float meters) {
+ if (handle != 0) nativeSetQuadDistance(handle, meters);
+ }
+
+ public void setQuadWidth(float meters) {
+ if (handle != 0) nativeSetQuadWidth(handle, meters);
+ }
+
+ public void setQuadHeightOffset(float meters) {
+ if (handle != 0) nativeSetQuadHeightOffset(handle, meters);
+ }
+
+ public float quadHeightOffset() {
+ return handle != 0 ? nativeGetQuadHeightOffset(handle) : 0f;
+ }
+
+ public void setPassthrough(boolean enabled) {
+ if (handle != 0) nativeSetPassthrough(handle, enabled);
+ }
+
+ public void setSharpening(boolean enabled) {
+ if (handle != 0) nativeSetSharpening(handle, enabled);
+ }
+
+ public void setHeadLocked(boolean enabled) {
+ if (handle != 0) nativeSetHeadLocked(handle, enabled);
+ }
+
+ /** 0 leaves the runtime default alone. */
+ public void requestRefreshRate(float hz) {
+ if (handle != 0) nativeRequestRefreshRate(handle, hz);
+ }
+
+ public void recenter() {
+ if (handle != 0) nativeRequestRecenter(handle);
+ }
+
+ public void haptic(float amplitude, int durationMs) {
+ if (handle != 0) nativeHaptic(handle, amplitude, durationMs);
+ }
+
+ public float[] refreshRates() {
+ return handle != 0 ? nativeGetRefreshRates(handle) : new float[0];
+ }
+
+ public float quadDistance() {
+ return handle != 0 ? nativeGetQuadDistance(handle) : 0f;
+ }
+
+ public float quadWidth() {
+ return handle != 0 ? nativeGetQuadWidth(handle) : 0f;
+ }
+
+ public boolean isHeadLocked() {
+ return handle != 0 && nativeIsHeadLocked(handle);
+ }
+
+ public boolean isPassthroughEnabled() {
+ return handle != 0 && nativeIsPassthroughEnabled(handle);
+ }
+
+ // --- native ---------------------------------------------------------------------
+
+ private static native long nativeAlloc();
+
+ private static native void nativeFree(long handle);
+
+ private static native void nativeSetSwapchainSize(long handle, int width, int height);
+
+ private static native void nativeSetManifestDir(long handle, String dir);
+
+ private static native boolean nativeCreate(long handle, Activity activity);
+
+ private static native Surface nativeGetVideoSurface(long handle);
+
+ private static native void nativeRunLoop(long handle, XrGoggleSession listener);
+
+ private static native void nativeRequestStop(long handle);
+
+ private static native void nativeDestroy(long handle);
+
+ private static native String nativeLastError(long handle);
+
+ private static native float[] nativeGetRefreshRates(long handle);
+
+ private static native void nativeSetVideoResolution(long handle, int width, int height);
+
+ private static native void nativeSetQuadDistance(long handle, float meters);
+
+ private static native void nativeSetQuadWidth(long handle, float meters);
+
+ private static native void nativeSetQuadHeightOffset(long handle, float meters);
+
+ private static native float nativeGetQuadHeightOffset(long handle);
+
+ private static native void nativeSetPassthrough(long handle, boolean enabled);
+
+ private static native void nativeSetSharpening(long handle, boolean enabled);
+
+ private static native void nativeSetHeadLocked(long handle, boolean enabled);
+
+ private static native void nativeRequestRefreshRate(long handle, float hz);
+
+ private static native void nativeRequestRecenter(long handle);
+
+ private static native void nativeHaptic(long handle, float amplitude, int durationMs);
+
+ private static native float nativeGetQuadDistance(long handle);
+
+ private static native float nativeGetQuadWidth(long handle);
+
+ private static native boolean nativeIsHeadLocked(long handle);
+
+ private static native boolean nativeIsPassthroughEnabled(long handle);
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b1540ecf..d2075b43 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,18 +2,22 @@
agp = "8.5.2"
coreKtx = "1.13.1"
appcompat = "1.7.0"
+annotation = "1.8.2"
material = "1.12.0"
constraintlayout = "2.1.4"
mpandroidchartVersion = "3.1.0"
mediapipe = "0.10.29"
+openxr = "1.1.49"
[libraries]
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
philjay-mpandroidchart = { module = "com.github.Philjay:mpandroidchart", version.ref = "mpandroidchartVersion" }
mediapipe-tasks-vision = { module = "com.google.mediapipe:tasks-vision", version.ref = "mediapipe" }
+openxr-loader = { module = "org.khronos.openxr:openxr_loader_for_android", version.ref = "openxr" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
diff --git a/settings.gradle b/settings.gradle
index b383549c..12d4a68b 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -27,4 +27,5 @@ rootProject.name = "PixelPilot"
include(":app")
include(":app:videonative")
include(":app:wfbngrtl8812")
+include(":app:xr")
include(":app:mavlink")