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 @@