Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d91c5e8
Add an immersive OpenXR goggle mode
iflyhere Aug 27, 2026
df3b9a7
Only run passthrough while it is on screen
iflyhere Aug 27, 2026
0ac7161
Fix: NPE in wirelessInfo() outside VideoActivity
iflyhere Aug 27, 2026
b96ed6f
Let the immersive activity launch without Touch controllers
iflyhere Aug 27, 2026
f5cad10
Point the OpenXR loader at the Horizon OS runtime explicitly
iflyhere Aug 27, 2026
df54e7b
Provision gs.key and the wfb options from the immersive activity too
iflyhere Aug 27, 2026
afa0499
Fix the Android surface swapchain creation, and ask for passthrough
iflyhere Aug 27, 2026
7bcdb82
Drive the in-flight actions from hand tracking as well
iflyhere Aug 27, 2026
098ca8a
Show a status screen on the layer until video arrives
iflyhere Aug 27, 2026
84b883d
Fix: the layer was upside down
iflyhere Aug 27, 2026
1f76b13
Let the panel be nudged up and down
iflyhere Aug 27, 2026
c739935
Mirror the controller mapping and put distance on trigger/grip
iflyhere Aug 27, 2026
6cf71ba
Stop hand gestures firing by accident, and keep the flat activity out…
iflyhere Sep 2, 2026
3a21341
Fix: several RX loops on one adapter, and the ANR that came with it
iflyhere Sep 2, 2026
6c435fb
Say which adapter problem it actually is, and log the ids
iflyhere Sep 2, 2026
35f87e6
Retry the adapter handoff into immersive mode instead of failing it once
iflyhere Sep 2, 2026
fa8ab41
Fix: both mode activities stayed alive and the adapter ended up owned…
iflyhere Sep 2, 2026
58e026a
Move the link into a foreground service so it has a single owner
iflyhere Sep 2, 2026
4493c30
Fix: the surface was produced before the service had bound
iflyhere Sep 2, 2026
5879741
Add a way out of immersive mode
iflyhere Sep 2, 2026
faed15f
Bound how long the packet queue holds a frame back
iflyhere Sep 2, 2026
7d693e4
Keep the immersive launcher entry off devices that cannot run it
iflyhere Sep 2, 2026
36de002
Merge the packet queue latency fix into the immersive branch
iflyhere Sep 2, 2026
db3717b
Advance the queue past everything a flush delivered
iflyhere Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<!-- Optional: the immersive activity needs it, the flat one does not, so the app
still installs and runs on a phone. -->
<uses-feature
android:name="android.hardware.vr.headtracking"
android:required="false" />
<!-- Horizon OS treats an app declared with the VR category as controller-required
unless it advertises hand tracking, and blocks the launch with
RequiresControllersLaunchInterceptor when the controllers are off. A pilot has a
radio in their hands, not Touch controllers, and the video path does not need them
at all - so declare it and let the app start either way. -->
<uses-feature
android:name="oculus.software.handtracking"
android:required="false" />
<!-- Horizon OS only exposes XR_FB_passthrough to an app that asks for it here; without
this the extension is simply absent from the instance extension list. -->
<uses-feature
android:name="com.oculus.feature.PASSTHROUGH"
android:required="false" />

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
Expand All @@ -19,6 +37,9 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.BIND_VPN_SERVICE" />
<!-- Goes with the oculus.software.handtracking feature: without it Horizon OS does
not hand the app tracked hands, so the pinch bindings never fire. -->
<uses-permission android:name="com.oculus.permission.HAND_TRACKING" />

<application
android:allowBackup="true"
Expand All @@ -29,6 +50,13 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
android:supportsRtl="true">
<!-- Owns the adapter, the wfb-ng link and the decoder for as long as either
presentation mode needs them, and across the switch between them. -->
<service
android:name=".LinkService"
android:exported="false"
android:foregroundServiceType="dataSync" />

<service
android:name=".WfbNgVpnService"
android:label="Wfb-Ng VPN Service"
Expand Down Expand Up @@ -76,5 +104,29 @@
<action android:name="android.intent.action.ACTION_POWER_DISCONNECTED" />
</intent-filter>
</activity>

<activity
android:name=".XrVideoActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:enabled="false"
android:exported="true"
android:label="@string/app_name_xr"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:theme="@style/Theme.FPVueMat">
<!-- Horizon OS and Android XR pick the immersive launch path from the VR
category. It is a separate launcher entry so the flat activity keeps
working unchanged, including its USB_DEVICE_ATTACHED filter.

Shipped disabled: on a phone this entry would be a second icon leading to
an activity that cannot create a session, and there is no manifest
condition for "device has an XR runtime". VideoActivity.onCreate turns it
on where it can actually run - see XrVideoActivity.syncLauncherEntry. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
98 changes: 98 additions & 0 deletions app/src/main/java/com/openipc/pixelpilot/DvrFiles.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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";
}
}
87 changes: 87 additions & 0 deletions app/src/main/java/com/openipc/pixelpilot/GsKey.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
98 changes: 98 additions & 0 deletions app/src/main/java/com/openipc/pixelpilot/LinkClientActivity.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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) {
}
}
Loading