From a3d4002be7a9c8f67704181666784e0970324d7f Mon Sep 17 00:00:00 2001
From: iflyhere <57563846+iflyhere@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:23:59 +0200
Subject: [PATCH 1/5] Render the main video on a SurfaceView unless object
detection needs the TextureView
#108 replaced the main video SurfaceView with a TextureView so MediaPipe can
grab frames via getBitmap(). That swap is unconditional, so every user pays
for it even with object detection turned off: the video is no longer eligible
for a hardware overlay plane and instead goes through the view hierarchy's GPU
composition, which costs GPU time, power and about one frame of latency.
The layout now carries both renderers and the active one is picked from the
existing "od_enabled" preference:
- object detection off (default) -> mainVideoSurface (SurfaceView), the
pre-#108 behaviour
- object detection on -> mainVideo (TextureView), unchanged
Toggling detection in the menu swaps the renderer, which means the decoder
needs a different surface. VideoPlayer.stopAndRemoveReceiverDecoder() also
stops the UDP receiver and nothing restarts it, so a hot swap is not safe
today; the toggle restarts the app instead, the same way the VR mode toggle
already does. startObjectDetectionLoop() bails out if the TextureView is not
the active renderer.
---
.../com/openipc/pixelpilot/VideoActivity.java | 32 ++++++++++++++++++-
app/src/main/res/layout/activity_video.xml | 14 ++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
index cacfc1ba..cd427ada 100644
--- a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
+++ b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
@@ -134,6 +134,9 @@ public void run() {
private Timer recordTimer = null;
private int seconds = 0;
private boolean isVRMode = false;
+ // 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;
private ConstraintLayout constraintLayout;
private ConstraintSet constraintSet;
private WfbNgLink wfbLink;
@@ -368,6 +371,7 @@ 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));
}
@@ -378,7 +382,22 @@ private void setupVRVideoPlayers() {
private void setupStandardVideoPlayer() {
binding.surfaceViewRight.setVisibility(View.GONE);
binding.surfaceViewLeft.setVisibility(View.GONE);
- binding.mainVideo.setSurfaceTextureListener(videoPlayer.configureTextureView(0));
+
+ // Object detection reads frames back with TextureView.getBitmap(), which forces
+ // the video through the view hierarchy's GPU composition. Without it a
+ // SurfaceView is used so the video stays on a hardware overlay plane.
+ videoUsesTextureView = getSharedPreferences("general", MODE_PRIVATE)
+ .getBoolean("od_enabled", false);
+
+ if (videoUsesTextureView) {
+ binding.mainVideoSurface.setVisibility(View.GONE);
+ binding.mainVideo.setVisibility(View.VISIBLE);
+ binding.mainVideo.setSurfaceTextureListener(videoPlayer.configureTextureView(0));
+ } else {
+ binding.mainVideo.setVisibility(View.GONE);
+ binding.mainVideoSurface.setVisibility(View.VISIBLE);
+ binding.mainVideoSurface.getHolder().addCallback(videoPlayer.configure1(0));
+ }
}
// ----------------------------------------------------------------------------
@@ -1541,6 +1560,7 @@ public void onVideoRatioChanged(final int videoW, final int videoH) {
Log.d(TAG, "Set resolution: " + videoW + "x" + videoH);
updateViewRatio(R.id.mainVideo, lastVideoW, lastVideoH);
+ updateViewRatio(R.id.mainVideoSurface, lastVideoW, lastVideoH);
updateViewRatio(R.id.surfaceViewLeft, lastVideoW, lastVideoH);
updateViewRatio(R.id.surfaceViewRight, lastVideoW, lastVideoH);
}
@@ -1984,6 +2004,15 @@ private void setObjectDetectionEnabled(boolean enabled) {
isObjectDetectionEnabled = enabled;
prefs.edit().putBoolean("od_enabled", enabled).apply();
+ // Enabling / disabling detection swaps the main video renderer. Handing the
+ // decoder a different surface at runtime would need the receiver lifecycle in
+ // VideoPlayer reworked, so restart instead - same as the VR mode toggle does.
+ if (!isVRMode && enabled != videoUsesTextureView) {
+ Toast.makeText(this, "Restarting to switch video renderer...", Toast.LENGTH_SHORT).show();
+ resetApp();
+ return;
+ }
+
if (enabled) {
binding.detectionOverlay.setVisibility(View.VISIBLE);
startObjectDetectionLoop();
@@ -2003,6 +2032,7 @@ private void restartObjectDetector() {
private void startObjectDetectionLoop() {
if (isVRMode) return; // Standard mode only
+ if (!videoUsesTextureView) return; // getBitmap() needs the TextureView renderer
if (objectDetectionExecutor == null) {
objectDetectionExecutor = Executors.newSingleThreadExecutor();
}
diff --git a/app/src/main/res/layout/activity_video.xml b/app/src/main/res/layout/activity_video.xml
index cabe29e6..8aa7e519 100644
--- a/app/src/main/res/layout/activity_video.xml
+++ b/app/src/main/res/layout/activity_video.xml
@@ -5,10 +5,24 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
+
+
+
Date: Thu, 27 Aug 2026 20:22:32 +0200
Subject: [PATCH 2/5] Add a low latency decoder option and actually apply the
MediaCodec keys
writeAndroidPerformanceParams() was never called: both call sites in
AndroidMediaFormatHelper.h were commented out, and the same keys sat
commented out a second time in VideoDecoder::configureStartDecoder().
So every decoder was configured without "low-latency" and without
"priority", i.e. MediaCodec kept its default reorder/output queue, which
on a live stream with no B-frames only adds latency.
The keys are now written, but behind a switch so a device whose decoder
does not like them can be put back on the stock pipeline:
- Settings -> Video -> Low latency, persisted as "low_latency_decoder",
default on
- plumbed through VideoPlayer.setLowLatency() /
nativeSetLowLatency() to VideoDecoder, applied when the decoder is
configured
- writeAndroidPerformanceParams() also gained the vendor low-latency keys
that were commented out in VideoDecoder.cpp (Qualcomm, HiSilicon,
rtc-ext) and is now static like the two functions next to it
Unknown AMediaFormat keys are ignored by MediaCodec, so writing all
variants is safe across vendors and Android versions.
---
.../com/openipc/pixelpilot/VideoActivity.java | 35 ++++++++++++++++++
app/videonative/src/main/cpp/VideoDecoder.cpp | 14 +++-----
app/videonative/src/main/cpp/VideoDecoder.h | 6 ++++
app/videonative/src/main/cpp/VideoPlayer.cpp | 10 ++++++
app/videonative/src/main/cpp/VideoPlayer.h | 2 ++
.../cpp/helper/AndroidMediaFormatHelper.h | 36 ++++++++-----------
.../com/openipc/videonative/VideoPlayer.java | 10 ++++++
7 files changed, 83 insertions(+), 30 deletions(-)
diff --git a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
index cacfc1ba..7261f9ac 100644
--- a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
+++ b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java
@@ -165,6 +165,11 @@ public static int getChannel(Context context) {
Context.MODE_PRIVATE).getInt("wifi-channel", 161);
}
+ public static boolean getLowLatencySetting(Context context) {
+ return context.getSharedPreferences("general",
+ Context.MODE_PRIVATE).getBoolean("low_latency_decoder", true);
+ }
+
public static int getBandwidth(Context context) {
return context.getSharedPreferences("general",
Context.MODE_PRIVATE).getInt("bandwidth", 20);
@@ -353,6 +358,7 @@ private void initializeWfbNg() {
private void initializeVideoPlayers() {
videoPlayer = new VideoPlayer(this);
videoPlayer.setIVideoParamsChanged(this);
+ videoPlayer.setLowLatency(getLowLatencySetting(this));
isVRMode = getVRSetting();
@@ -587,6 +593,9 @@ private void showSettingsMenu(View anchor) {
// Bandwidth submenu
setupBandwidthSubMenu(popup);
+ // Video submenu
+ setupVideoSubMenu(popup);
+
// OSD submenu
setupOSDSubMenu(popup);
@@ -671,6 +680,32 @@ private void setupBandwidthSubMenu(PopupMenu popup) {
}
}
+ /**
+ * Submenu for video decoder options.
+ * "Low latency" sets the MediaCodec low-latency and realtime-priority keys. It is on
+ * by default; decoders that misbehave with those keys can be put back on the stock
+ * pipeline here.
+ */
+ private void setupVideoSubMenu(PopupMenu popup) {
+ SubMenu videoMenu = popup.getMenu().addSubMenu("Video");
+
+ MenuItem lowLatencyItem = videoMenu.add("Low latency");
+ lowLatencyItem.setCheckable(true);
+ lowLatencyItem.setChecked(getLowLatencySetting(this));
+ lowLatencyItem.setOnMenuItemClickListener(item -> {
+ boolean enabled = !item.isChecked();
+ item.setChecked(enabled);
+ getSharedPreferences("general", MODE_PRIVATE).edit()
+ .putBoolean("low_latency_decoder", enabled).apply();
+ videoPlayer.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);
+ item.setActionView(new View(this));
+ return false;
+ });
+ }
+
/**
* Submenu handling OSD toggles and locks.
*/
diff --git a/app/videonative/src/main/cpp/VideoDecoder.cpp b/app/videonative/src/main/cpp/VideoDecoder.cpp
index 47d1079a..0ae7f05d 100644
--- a/app/videonative/src/main/cpp/VideoDecoder.cpp
+++ b/app/videonative/src/main/cpp/VideoDecoder.cpp
@@ -135,15 +135,6 @@ void VideoDecoder::configureStartDecoder(int idx)
AMediaFormat* format = AMediaFormat_new();
AMediaFormat_setString(format, AMEDIAFORMAT_KEY_MIME, MIME.c_str());
- // AMediaFormat_setInt32(format, "low-latency", 1);
- // AMediaFormat_setInt32(format, "vendor.low-latency.enable", 1);
- // AMediaFormat_setInt32(format, "vendor.qti-ext-dec-low-latency.enable", 1);
- // AMediaFormat_setInt32(format, "vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1);
- // AMediaFormat_setInt32(format, "vendor.rtc-ext-dec-low-latency.enable", 1);
-
- // MediaCodec supports two priorities: 0 - realtime, 1 - best effort
- // AMediaFormat_setInt32(format, "priority", 0);
-
if (IS_H265)
{
h265_configureAMediaFormat(mKeyFrameFinder, format);
@@ -153,6 +144,11 @@ void VideoDecoder::configureStartDecoder(int idx)
h264_configureAMediaFormat(mKeyFrameFinder, format);
}
+ if (mLowLatency)
+ {
+ writeAndroidPerformanceParams(format);
+ }
+
MLOGD << "Configuring decoder:" << AMediaFormat_toString(format);
auto status = AMediaCodec_configure(decoder.codec[idx], format, decoder.window[idx], nullptr, 0);
diff --git a/app/videonative/src/main/cpp/VideoDecoder.h b/app/videonative/src/main/cpp/VideoDecoder.h
index bf815a2e..a20b296a 100644
--- a/app/videonative/src/main/cpp/VideoDecoder.h
+++ b/app/videonative/src/main/cpp/VideoDecoder.h
@@ -86,6 +86,10 @@ class VideoDecoder
void registerOnDecodingInfoChangedCallback(DECODING_INFO_CHANGED_CALLBACK decodingInfoChangedCallback);
+ // Enable / disable the low-latency and realtime-priority AMediaFormat keys.
+ // Applied the next time the decoder is configured, not to a running decoder.
+ void setLowLatency(bool enabled) { mLowLatency = enabled; }
+
// If the decoder has been configured, feed NALU. Else search for configuration data and
// configure as soon as possible
// If the input pipe was closed (surface has been removed or is not set yet), only buffer key frames
@@ -109,6 +113,8 @@ class VideoDecoder
std::unique_ptr mCheckOutputThread[2] = {nullptr, nullptr};
bool USE_SW_DECODER_INSTEAD = false;
+ // Some decoders misbehave with the low-latency keys, so it stays user switchable.
+ std::atomic mLowLatency = true;
// Holds the AMediaCodec instance, as well as the state (configured or not configured)
Decoder decoder{};
DecodingInfo decodingInfo;
diff --git a/app/videonative/src/main/cpp/VideoPlayer.cpp b/app/videonative/src/main/cpp/VideoPlayer.cpp
index 6175d585..5a106a3c 100644
--- a/app/videonative/src/main/cpp/VideoPlayer.cpp
+++ b/app/videonative/src/main/cpp/VideoPlayer.cpp
@@ -318,6 +318,16 @@ extern "C"
}
}
+ JNI_METHOD(void, nativeSetLowLatency)
+ (JNIEnv* env, jclass jclass1, jlong nativeInstance, jboolean enabled)
+ {
+ VideoPlayer* p = native(nativeInstance);
+ if (p)
+ {
+ p->setLowLatency(enabled);
+ }
+ }
+
JNI_METHOD(void, nativeSetVideoSurface)
(JNIEnv* env, jclass jclass1, jlong videoPlayerN, jobject surface, jint index)
{
diff --git a/app/videonative/src/main/cpp/VideoPlayer.h b/app/videonative/src/main/cpp/VideoPlayer.h
index 830d423e..f339b1fc 100644
--- a/app/videonative/src/main/cpp/VideoPlayer.h
+++ b/app/videonative/src/main/cpp/VideoPlayer.h
@@ -54,6 +54,8 @@ class VideoPlayer
void setForwarding(const std::string& ip, int port, bool enabled);
+ void setLowLatency(bool enabled) { videoDecoder.setLowLatency(enabled); }
+
private:
void onNewNALU(const NALU& nalu);
diff --git a/app/videonative/src/main/cpp/helper/AndroidMediaFormatHelper.h b/app/videonative/src/main/cpp/helper/AndroidMediaFormatHelper.h
index 28a05897..bc3ffa9d 100644
--- a/app/videonative/src/main/cpp/helper/AndroidMediaFormatHelper.h
+++ b/app/videonative/src/main/cpp/helper/AndroidMediaFormatHelper.h
@@ -5,26 +5,22 @@
#include
#include "../NALU/KeyFrameFinder.hpp"
-// Some of these params are only supported on the latest Android versions
-// However,writing them has no negative affect on devices with older Android versions
-// Note that for example the low-latency key cannot fix any issues like the 'VUI' issue
-void writeAndroidPerformanceParams(AMediaFormat* format)
+// Decoder tuning that trades pipeline depth for latency. Unknown keys are ignored by
+// MediaCodec, so writing all of them is safe on every device / Android version.
+static void writeAndroidPerformanceParams(AMediaFormat* format)
{
- // I think: KEY_LOW_LATENCY is for decoder. But it doesn't really make a difference anyways
- static const auto PARAMETER_KEY_LOW_LATENCY = "low-latency";
- AMediaFormat_setInt32(format, PARAMETER_KEY_LOW_LATENCY, 1);
- // Lower values mean higher priority
- // Works on pixel 3 (look at output format description)
- static const auto AMEDIAFORMAT_KEY_PRIORITY = "priority";
- AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_PRIORITY, 0);
- // set operating rate ? - doesn't make a difference
- // static const auto AMEDIAFORMAT_KEY_OPERATING_RATE="operating-rate";
- // AMediaFormat_setInt32(format,AMEDIAFORMAT_KEY_OPERATING_RATE,60);
- //
- // AMEDIAFORMAT_KEY_LOW_LATENCY;
- // AMEDIAFORMAT_KEY_LATENCY;
- // AMediaFormat_setInt32(format,AMEDIAFORMAT_KEY_LATENCY,0);
- // AMediaFormat_setInt32(format,AMEDIAFORMAT_KEY_OPERATING_RATE,0);
+ // AMEDIAFORMAT_KEY_LOW_LATENCY (API 30+). Tells the decoder to output a frame as soon
+ // as it is decoded instead of keeping a reorder/output queue. For a live stream that
+ // never uses B-frames the queue only adds latency.
+ AMediaFormat_setInt32(format, "low-latency", 1);
+ // Vendor equivalents for SoCs whose codec does not pick up the AOSP key. Qualcomm is
+ // the relevant one for most phones and for the Snapdragon XR2 headsets.
+ AMediaFormat_setInt32(format, "vendor.low-latency.enable", 1);
+ AMediaFormat_setInt32(format, "vendor.qti-ext-dec-low-latency.enable", 1);
+ AMediaFormat_setInt32(format, "vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1);
+ AMediaFormat_setInt32(format, "vendor.rtc-ext-dec-low-latency.enable", 1);
+ // MediaCodec knows two priorities: 0 - realtime, 1 - best effort. Lower is higher.
+ AMediaFormat_setInt32(format, "priority", 0);
}
static void h264_configureAMediaFormat(KeyFrameFinder& kff, AMediaFormat* format)
@@ -42,7 +38,6 @@ static void h264_configureAMediaFormat(KeyFrameFinder& kff, AMediaFormat* format
// AVCProfileBaseline==1
// AMediaFormat_setInt32(decoder.format,AMEDIAFORMAT_KEY_PROFILE,1);
// AMediaFormat_setInt32(decoder.format,AMEDIAFORMAT_KEY_PRIORITY,0);
- // writeAndroidPerformanceParams(format);
}
static void h265_configureAMediaFormat(KeyFrameFinder& kff, AMediaFormat* format)
@@ -60,7 +55,6 @@ static void h265_configureAMediaFormat(KeyFrameFinder& kff, AMediaFormat* format
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_HEIGHT, videoWH[1]);
AMediaFormat_setBuffer(format, "csd-0", buff.data(), buff.size());
MLOGD << "Video WH:" << videoWH[0] << " H:" << videoWH[1];
- // writeAndroidPerformanceParams(format);
}
#endif // FPVUE_ANDROIDMEDIAFORMATHELPER_H
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 7330eb8b..e1c49d0f 100644
--- a/app/videonative/src/main/java/com/openipc/videonative/VideoPlayer.java
+++ b/app/videonative/src/main/java/com/openipc/videonative/VideoPlayer.java
@@ -52,6 +52,8 @@ public VideoPlayer(final AppCompatActivity parent) {
public static native void nativeSetUdpForwarding(long nativeInstance, String ip, int port, boolean enabled);
+ public static native void nativeSetLowLatency(long nativeInstance, boolean enabled);
+
public static native void nativeStartDvr(long nativeInstance, int fd, int fmp4_enabled);
public static native void nativeStopDvr(long nativeInstance);
@@ -126,6 +128,14 @@ public boolean isRunning() {
return timer != null;
}
+ /**
+ * Enable/disable the low latency + realtime priority MediaCodec keys.
+ * Takes effect the next time the decoder is configured.
+ */
+ public void setLowLatency(boolean enabled) {
+ nativeSetLowLatency(nativeVideoPlayer, enabled);
+ }
+
public void setUdpForwarding(String ip, int port, boolean enabled) {
verifyApplicationThread();
nativeSetUdpForwarding(nativeVideoPlayer, ip, port, enabled);
From 680fce1f0d5e0c96d098cc7582305014450987aa Mon Sep 17 00:00:00 2001
From: iflyhere <57563846+iflyhere@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:26:22 +0200
Subject: [PATCH 3/5] Fix: DVR recording leaks one heap buffer per frame
onNewNALU() allocated a copy of every NALU with new uint8_t[] and pushed it
into naluQueue as a NALU. NALU is documented as a non-owning view ("it does
not do any memory management", NALU.hpp) and its destructor is defaulted, so
popping the queue never freed anything.
The buffer leaks for as long as a recording runs - roughly the video bitrate,
so ~1 MB/s at 8 Mbit/s. A ten minute recording leaks a few hundred MB and the
app eventually gets killed by the OOM killer, mid flight.
The queue now holds an owning DvrNalu { std::vector, bool } that is
moved in and out, so the bytes are freed with the queue entry and anything
still queued is released when the writer thread stops.
Note: the queued NALU is also read after the h265 flag was needed for
mp4_h26x_write_init(), so the flag is read from the front element before the
move instead.
---
app/videonative/src/main/cpp/VideoPlayer.cpp | 18 ++++++++----------
app/videonative/src/main/cpp/VideoPlayer.h | 17 ++++++++++++++---
2 files changed, 22 insertions(+), 13 deletions(-)
diff --git a/app/videonative/src/main/cpp/VideoPlayer.cpp b/app/videonative/src/main/cpp/VideoPlayer.cpp
index 6175d585..239200a5 100644
--- a/app/videonative/src/main/cpp/VideoPlayer.cpp
+++ b/app/videonative/src/main/cpp/VideoPlayer.cpp
@@ -61,16 +61,15 @@ void VideoPlayer::processQueue()
}
if (!naluQueue.empty())
{
- NALU nalu = naluQueue.front();
if (framerate == 0)
{
if (latestDecodingInfo.currentFPS <= 0)
{
continue;
}
+ const bool is_h265 = naluQueue.front().is_h265;
if (MP4E_STATUS_OK !=
- mp4_h26x_write_init(
- &mp4wr, mux, latestVideoRatio.width, latestVideoRatio.height, nalu.IS_H265_PACKET))
+ mp4_h26x_write_init(&mp4wr, mux, latestVideoRatio.width, latestVideoRatio.height, is_h265))
{
__android_log_print(ANDROID_LOG_DEBUG, TAG, "error: mp4_h26x_write_init failed");
}
@@ -82,12 +81,13 @@ void VideoPlayer::processQueue()
framerate,
latestVideoRatio.width,
latestVideoRatio.height,
- nalu.IS_H265_PACKET);
+ is_h265);
}
+ DvrNalu nalu = std::move(naluQueue.front());
naluQueue.pop();
lock.unlock();
// Process the NALU
- auto res = mp4_h26x_write_nal(&mp4wr, nalu.getData(), nalu.getSize(), 90000 / framerate);
+ auto res = mp4_h26x_write_nal(&mp4wr, nalu.data.data(), (int) nalu.data.size(), 90000 / framerate);
if (MP4E_STATUS_OK != res)
{
__android_log_print(ANDROID_LOG_DEBUG, TAG, "mp4_h26x_write_nal failed with %d", res);
@@ -144,11 +144,9 @@ void VideoPlayer::onNewNALU(const NALU& nalu)
{
return;
}
- // Copy data to write if from a different thread.
- uint8_t* m_data_copy = new uint8_t[nalu.getSize()];
- memcpy(m_data_copy, nalu.getData(), nalu.getSize());
- NALU nalu_(m_data_copy, nalu.getSize(), nalu.IS_H265_PACKET);
- enqueueNALU(nalu_);
+ // The writer thread outlives this call, so hand it an owning copy.
+ enqueueNALU(DvrNalu{std::vector(nalu.getData(), nalu.getData() + nalu.getSize()),
+ nalu.IS_H265_PACKET});
}
void VideoPlayer::setVideoSurface(JNIEnv* env, jobject surface, jint i)
diff --git a/app/videonative/src/main/cpp/VideoPlayer.h b/app/videonative/src/main/cpp/VideoPlayer.h
index 830d423e..8d712c5a 100644
--- a/app/videonative/src/main/cpp/VideoPlayer.h
+++ b/app/videonative/src/main/cpp/VideoPlayer.h
@@ -9,6 +9,8 @@
#include
#include
#include
+#include
+#include
#include "AudioDecoder.h"
#include "BufferedPacketQueue.h"
#include "UdpReceiver.h"
@@ -74,9 +76,18 @@ class VideoPlayer
H26XParser mParser;
BufferedPacketQueue mBufferedPacketQueueVideo, mBufferedPacketQueueAudio;
+ // A NALU is a non-owning view onto the parser's buffer (see NALU.hpp), which is
+ // reused for the next packet. The DVR writer runs on its own thread, so what gets
+ // handed over has to own its bytes.
+ struct DvrNalu
+ {
+ std::vector data;
+ bool is_h265 = false;
+ };
+
// DVR attributes
int dvr_fd;
- std::queue naluQueue;
+ std::queue naluQueue;
std::mutex mtx;
std::condition_variable cv;
bool stopFlag = false;
@@ -84,11 +95,11 @@ class VideoPlayer
int dvr_mp4_fragmentation = 0;
uint64_t last_dvr_write = 0;
- void enqueueNALU(const NALU& nalu)
+ void enqueueNALU(DvrNalu&& nalu)
{
{
std::lock_guard lock(mtx);
- naluQueue.push(nalu);
+ naluQueue.push(std::move(nalu));
}
cv.notify_one();
}
From 4b8c41ce622049486ca5753178ddf9d7f415ecf4 Mon Sep 17 00:00:00 2001
From: iflyhere <57563846+iflyhere@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:28:30 +0200
Subject: [PATCH 4/5] Harden the USB adapter lifecycle
Four separate ways the adapter path can take the app down or wedge it. All of
them are easy to hit on a powered hub that re-enumerates the dongle, which is
how a lot of ground stations are wired.
1. Deliberate null deref. WfbngLink::stop() ran a CRASH() macro
(`int *i = 0; *i = 42;`) when the fd was no longer in rtl_devices. That is
a recoverable state - the adapter was already gone - and it killed the
process. Removed, now a warning and return.
2. NPE on openDevice(). UsbManager.openDevice() returns null when the
permission was revoked or the device disappeared between the permission
check and the open; getFileDescriptor() was called on it unconditionally.
start() now returns false instead, WfbLinkManager reports it and leaves the
adapter out of activeWifiAdapters so the next refresh retries it. Before,
a failed adapter was recorded as active and never retried.
3. Leaked usbfs descriptors. UsbDeviceConnection was never closed and
linkConns was never cleared, so every attach/detach cycle leaked one fd
plus the map entry.
4. USB permission dialog on Android 14. requestPermission() got a
PendingIntent built from an implicit Intent. Android 14 refuses to deliver
those to a runtime registered receiver, so the result never arrived and the
app sat on "No permission for wifi adapter(s)". setPackage() added.
Also: refreshAdapters() dereferenced getAttachedAdapters() without checking
for the null it returns when the device filter fails to parse, and the wfb
thread name indexed split()[1] without checking the device name matched
/dev/bus/usb/.
---
.../openipc/pixelpilot/WfbLinkManager.java | 23 +++++++++++---
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp | 11 ++-----
.../com/openipc/wfbngrtl8812/WfbNgLink.java | 30 +++++++++++++++++--
3 files changed, 49 insertions(+), 15 deletions(-)
diff --git a/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java b/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java
index 8580be69..0c0390e0 100644
--- a/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java
+++ b/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java
@@ -120,6 +120,10 @@ public Map getAttachedAdapters() {
public synchronized void refreshAdapters() {
Map attachedAdapters = getAttachedAdapters();
+ if (attachedAdapters == null) {
+ Log.e(TAG, "Could not read the usb device filter, skipping adapter refresh.");
+ return;
+ }
boolean missingPermissions = false;
android.hardware.usb.UsbManager usbManager =
@@ -128,8 +132,13 @@ public synchronized void refreshAdapters() {
if (!usbManager.hasPermission(entry.getValue())) {
binding.tvMessage.setVisibility(View.VISIBLE);
binding.tvMessage.setText("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.
+ Intent permissionIntent = new Intent(WfbLinkManager.ACTION_USB_PERMISSION);
+ permissionIntent.setPackage(context.getPackageName());
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0,
- new Intent(WfbLinkManager.ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE);
+ permissionIntent, PendingIntent.FLAG_IMMUTABLE);
usbManager.requestPermission(entry.getValue(), pendingIntent);
missingPermissions = true;
}
@@ -155,8 +164,11 @@ public synchronized void refreshAdapters() {
if (activeWifiAdapters.containsKey(entry.getKey())) {
continue;
}
- startAdapter(entry.getValue());
- activeWifiAdapters.put(entry.getKey(), entry.getValue());
+ // Only track it as active if it actually came up, otherwise a failed adapter
+ // is never retried on the next refresh.
+ if (startAdapter(entry.getValue())) {
+ activeWifiAdapters.put(entry.getKey(), entry.getValue());
+ }
}
if (activeWifiAdapters.isEmpty()) {
@@ -204,7 +216,10 @@ public synchronized boolean startAdapter(UsbDevice dev) {
String text = "Starting wfb-ng channel " + wifiChannel + " with " + String.format(
"[%04X", dev.getVendorId()) + ":" + String.format("%04X]", dev.getProductId());
binding.tvMessage.setText(text);
- wfbLink.start(wifiChannel, bandWidth.getValue(), dev);
+ if (!wfbLink.start(wifiChannel, bandWidth.getValue(), dev)) {
+ binding.tvMessage.setText("Could not open wifi adapter " + dev.getDeviceName());
+ return false;
+ }
return true;
}
}
diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp
index e8d9c14c..a3046b07 100644
--- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp
+++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp
@@ -34,12 +34,6 @@
#undef TAG
#define TAG "pixelpilot"
-#define CRASH() \
- do { \
- int *i = 0; \
- *i = 42; \
- } while (0)
-
std::string generate_random_string(size_t length) {
const std::string characters = "abcdefghijklmnopqrstuvwxyz";
std::random_device rd;
@@ -283,8 +277,9 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint
void WfbngLink::stop(JNIEnv *env, jobject context, jint fd) {
if (rtl_devices.find(fd) == rtl_devices.end()) {
- __android_log_print(ANDROID_LOG_ERROR, TAG, "rtl_devices.find(%d) == rtl_devices.end()", fd);
- CRASH();
+ // Happens when the adapter was already gone by the time the stop arrived, e.g. it
+ // was unplugged or the hub re-enumerated it. Nothing left to stop.
+ __android_log_print(ANDROID_LOG_WARN, TAG, "stop: no rtl device for fd=%d, already gone", fd);
return;
}
auto dev = rtl_devices.at(fd).get();
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 ea0347de..39dca17d 100644
--- a/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java
+++ b/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java
@@ -92,17 +92,35 @@ public void nativeSetUseStbc(int use) {
nativeSetUseStbc(nativeWfbngLink, use);
}
- public synchronized void start(int wifiChannel, int bandWidth, UsbDevice usbDevice) {
+ public synchronized boolean start(int wifiChannel, int bandWidth, UsbDevice usbDevice) {
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
+ // the permission check and here, which is easy to hit on a re-enumerating hub.
UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
+ if (usbDeviceConnection == null) {
+ Log.e(TAG, "Could not open " + usbDevice.getDeviceName() + " (no permission or already gone)");
+ return false;
+ }
int fd = usbDeviceConnection.getFileDescriptor();
+ if (fd < 0) {
+ Log.e(TAG, "Invalid file descriptor for " + usbDevice.getDeviceName());
+ usbDeviceConnection.close();
+ return false;
+ }
Thread t = new Thread(() -> nativeRun(nativeWfbngLink, context, wifiChannel, bandWidth, fd));
- t.setName("wfb-" + usbDevice.getDeviceName().split("/dev/bus/usb/")[1]);
+ t.setName(threadNameFor(usbDevice));
linkThreads.put(usbDevice, t);
linkConns.put(usbDevice, usbDeviceConnection);
- linkThreads.get(usbDevice).start();
+ t.start();
Log.d(TAG, "wfb-ng thread on " + usbDevice.getDeviceName() + " started.");
+ return true;
+ }
+
+ private static String threadNameFor(UsbDevice usbDevice) {
+ String name = usbDevice.getDeviceName();
+ String[] parts = name.split("/dev/bus/usb/");
+ return "wfb-" + (parts.length > 1 ? parts[1] : name);
}
public synchronized void stopAll() throws InterruptedException {
@@ -114,9 +132,13 @@ public synchronized void stopAll() throws InterruptedException {
if (t != null) {
t.join();
}
+ // 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();
}
public synchronized void stop(UsbDevice dev) throws InterruptedException {
@@ -131,6 +153,8 @@ public synchronized void stop(UsbDevice dev) throws InterruptedException {
t.join();
}
linkThreads.remove(dev);
+ linkConns.remove(dev);
+ conn.close();
}
public void SetWfbNGStatsChanged(final WfbNGStatsChanged callback) {
From fbd65127bcb690ab9c7284c010ad224b82f170e7 Mon Sep 17 00:00:00 2001
From: iflyhere <57563846+iflyhere@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:25:46 +0200
Subject: [PATCH 5/5] Keep the activity alive across configuration changes
VideoActivity declares no android:configChanges, so every configuration
change destroys and recreates it. onCreate() re-runs the whole bring-up and
onPause()/onStop() tear the link down first, which means a few seconds of
black screen and a fresh USB/wfb-ng/decoder init.
That fires more often than it looks:
- window resize in multi-window / freeform / desktop mode (screenSize,
smallestScreenSize, screenLayout) - also how the app is presented on
Android XR headsets, where the panel is user resizeable
- attaching a keyboard or a dock (keyboard, keyboardHidden, navigation)
- rotation (orientation)
Handling those in-process is enough: the layout is ConstraintLayout based
and re-measures itself, the activity keeps no configuration dependent
state, and none of those qualifiers select alternative resources in this
project, so no onConfigurationChanged() override is needed.
uiMode and density are deliberately not in the list: values-night/ and the
mipmap-*dpi buckets do depend on them, so those two still need a recreate
to pick up the right resources.
Also:
- android:resizeableActivity="true" - be explicit rather than relying on
the target SDK default
- android.hardware.touchscreen android:required="false" - the implicit
default is required=true, which marks the app incompatible with any
ground station driven by a pointer or a gamepad instead of a touchscreen
---
app/src/main/AndroidManifest.xml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b13e729a..1ad6bc61 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,11 @@
+
+
@@ -46,8 +51,10 @@