Skip to content

android: probe rgb10a2 cross-context sampling, force 8-bit when broken (#374) - #380

Draft
abdelaziz-mahdy wants to merge 2 commits into
wang-bin:masterfrom
abdelaziz-mahdy:feature/rgb10a2-probe-pr
Draft

android: probe rgb10a2 cross-context sampling, force 8-bit when broken (#374)#380
abdelaziz-mahdy wants to merge 2 commits into
wang-bin:masterfrom
abdelaziz-mahdy:feature/rgb10a2-probe-pr

Conversation

@abdelaziz-mahdy

@abdelaziz-mahdy abdelaziz-mahdy commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Draft for your opinion — the detection approach discussed in #374. Full disclosure: the implementation was generated with Fable 5 (the same model that produced the #374 analysis) and reviewed by me.

UPDATE after on-device testing: this in-process probe does NOT detect the failure — see the findings comment below. In-process EGLImage import round-trips cleanly on this driver; the real failures live in SurfaceTexture's import path and in SurfaceFlinger's process. Kept as a draft for discussion of what detection should look like.

Idea

The driver accepts a 10-bit EGLConfig and renders RGBA_1010102 window buffers without any EGL/GL error — the corruption only appears when a second GL context samples the buffer. So a query can't detect it; the probe reproduces the handoff once per process, before the first surface attach:

  1. Create an AImageReader (512×512, RGBA_1010102, GPU_SAMPLED | GPU_COLOR_OUTPUT — same gralloc usage as the real video path, so vendor tiled/compressed layouts are exercised).
  2. Context A renders four quadrant colors into its window (scissor + clear only) and swaps.
  3. The produced AHardwareBuffer is imported via eglCreateImageKHR into a second, unshared context and sampled as GL_TEXTURE_EXTERNAL_OES — the same import path the Flutter engine uses.
  4. Read back four quadrant samples; each must match one of the painted colors (tolerance 24/255, order-insensitive so Y-flips can't false-positive).

Any corrupt sample → GLRenderAPI.depth = 8 via setRenderAPI() before updateNativeSurface() (your suggested fix from #374), so only broken drivers lose 10-bit; healthy devices keep rgb10a2/HDR untouched.

Notes

  • mediandk is resolved via dlsym (AImageReader_newWithUsage is API 26+; direct linking would break plugin load on older devices). Probe-infrastructure failures (no 10-bit config, EGL errors, pre-26) never force 8-bit.
  • Result is cached; cost is one-time ~10–40 ms on first playback. Could move to a background thread at plugin attach if you prefer zero observable cost.
  • Env overrides for testing: FVP_RGB10A2_PROBE=0 (skip), FVP_RGB10A2_PROBE=force8.
  • Independent of android: implement VideoViewType.platformView with SurfaceView output #379 (applies to the existing texture path).

I'll test on the affected device and report the probe log output here. Happy to change the approach entirely if you have a different direction in mind.

Some drivers accept a 10-bit EGLConfig and render into RGBA_1010102
window buffers without error, but corrupt them when a second GL
context samples the result (PowerVR BXE / Realtek RTD2875P, wang-bin#374).
Nothing surfaces through the EGL/GL API, so detection reproduces the
handoff once per process: render quadrant colors into an RGBA_1010102
ImageReader surface (same gralloc usage as the real video path) from
one context, import the buffer as an EGLImage in a second unshared
context, sample and read back. On mismatch the GL render target is
forced to depth=8 via setRenderAPI before updateNativeSurface, keeping
10-bit/HDR untouched on healthy drivers.

mediandk is resolved via dlsym (AImageReader_newWithUsage is API 26+);
probe-infrastructure failures never force 8-bit. Env overrides for
testing: FVP_RGB10A2_PROBE=0 / force8.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a cross-context RGBA_1010102 sampling probe to detect GPU driver corruption on 10-bit window buffers, forcing an 8-bit fallback when necessary. The review feedback highlights critical issues in the probe implementation, including potential EGL context and surface state corruption on the calling thread, a resource leak of the dynamically loaded libmediandk.so library, and a lack of thread safety when caching the probe results.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread android/rgb10a2_probe.cpp
Comment on lines +78 to +103
struct ProbeCleanup {
EGLDisplay dpy = EGL_NO_DISPLAY;
EGLSurface winSurf = EGL_NO_SURFACE;
EGLSurface pbuf = EGL_NO_SURFACE;
EGLContext ctxA = EGL_NO_CONTEXT;
EGLContext ctxB = EGL_NO_CONTEXT;
EGLImageKHR image = EGL_NO_IMAGE_KHR;
AImageReader* reader = nullptr;
AImage* img = nullptr;
AImageReader_delete_t readerDelete = nullptr;
AImage_delete_t imageDelete = nullptr;
PFNEGLDESTROYIMAGEKHRPROC destroyImage = nullptr;

~ProbeCleanup() {
if (dpy != EGL_NO_DISPLAY) {
eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (image != EGL_NO_IMAGE_KHR && destroyImage) destroyImage(dpy, image);
if (winSurf != EGL_NO_SURFACE) eglDestroySurface(dpy, winSurf);
if (pbuf != EGL_NO_SURFACE) eglDestroySurface(dpy, pbuf);
if (ctxA != EGL_NO_CONTEXT) eglDestroyContext(dpy, ctxA);
if (ctxB != EGL_NO_CONTEXT) eglDestroyContext(dpy, ctxB);
}
if (img && imageDelete) imageDelete(img);
if (reader && readerDelete) readerDelete(reader);
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

EGL Context and Surface State Leak / Corruption

The probe function probeShowsBroken() changes the current EGL context of the calling thread by calling eglMakeCurrent with its own internal contexts (ctxA and ctxB). When the function returns, the destructor of ProbeCleanup clears the current context of the thread by calling eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT).

If the calling thread (which is typically the Flutter UI/platform thread) already had a current EGL context before probeShowsBroken() was called, that context is lost. This will break subsequent OpenGL/EGL operations on that thread, leading to rendering issues or crashes.

Additionally, libmediandk.so opened via dlopen is currently leaked. We can manage its lifetime by storing the ndk handle in ProbeCleanup and calling dlclose(ndk) at the end of the destructor.

We should save the current EGL display, context, and surfaces at the start of the probe, and restore them in the destructor of ProbeCleanup.

struct ProbeCleanup {
  void* ndk = nullptr;
  EGLDisplay dpy = EGL_NO_DISPLAY;
  EGLSurface winSurf = EGL_NO_SURFACE;
  EGLSurface pbuf = EGL_NO_SURFACE;
  EGLContext ctxA = EGL_NO_CONTEXT;
  EGLContext ctxB = EGL_NO_CONTEXT;
  EGLImageKHR image = EGL_NO_IMAGE_KHR;
  AImageReader* reader = nullptr;
  AImage* img = nullptr;
  AImageReader_delete_t readerDelete = nullptr;
  AImage_delete_t imageDelete = nullptr;
  PFNEGLDESTROYIMAGEKHRPROC destroyImage = nullptr;
  EGLDisplay oldDpy = EGL_NO_DISPLAY;
  EGLContext oldCtx = EGL_NO_CONTEXT;
  EGLSurface oldDraw = EGL_NO_SURFACE;
  EGLSurface oldRead = EGL_NO_SURFACE;

  ~ProbeCleanup() {
    if (dpy != EGL_NO_DISPLAY) {
      if (oldCtx != EGL_NO_CONTEXT) {
        eglMakeCurrent(oldDpy, oldDraw, oldRead, oldCtx);
      } else {
        eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
      }
      if (image != EGL_NO_IMAGE_KHR && destroyImage) destroyImage(dpy, image);
      if (winSurf != EGL_NO_SURFACE) eglDestroySurface(dpy, winSurf);
      if (pbuf != EGL_NO_SURFACE) eglDestroySurface(dpy, pbuf);
      if (ctxA != EGL_NO_CONTEXT) eglDestroyContext(dpy, ctxA);
      if (ctxB != EGL_NO_CONTEXT) eglDestroyContext(dpy, ctxB);
    }
    if (img && imageDelete) imageDelete(img);
    if (reader && readerDelete) readerDelete(reader);
    if (ndk) dlclose(ndk);
  }
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d59add4 — the probe now saves the caller's display/context/surfaces before any EGL call and restores them on exit (clearing only when nothing was current).

Comment thread android/rgb10a2_probe.cpp
Comment on lines +107 to +133
bool probeShowsBroken() {
void* ndk = dlopen("libmediandk.so", RTLD_NOW | RTLD_LOCAL);
if (!ndk) return false;
auto newWithUsage = (AImageReader_newWithUsage_t)dlsym(ndk, "AImageReader_newWithUsage");
auto getWindow = (AImageReader_getWindow_t)dlsym(ndk, "AImageReader_getWindow");
auto acquireNext = (AImageReader_acquireNextImage_t)dlsym(ndk, "AImageReader_acquireNextImage");
auto readerDelete = (AImageReader_delete_t)dlsym(ndk, "AImageReader_delete");
auto getHwBuffer = (AImage_getHardwareBuffer_t)dlsym(ndk, "AImage_getHardwareBuffer");
auto imageDelete = (AImage_delete_t)dlsym(ndk, "AImage_delete");
if (!newWithUsage || !getWindow || !acquireNext || !readerDelete || !getHwBuffer || !imageDelete) {
return false; // pre-26 device: can't probe (and 1010102 unlikely anyway)
}

auto getNativeClientBuffer =
(PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC)eglGetProcAddress("eglGetNativeClientBufferANDROID");
auto createImage = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR");
auto destroyImage = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR");
auto imageTargetTexture =
(PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
if (!getNativeClientBuffer || !createImage || !destroyImage || !imageTargetTexture) {
return false;
}

ProbeCleanup c;
c.readerDelete = readerDelete;
c.imageDelete = imageDelete;
c.destroyImage = destroyImage;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Prevent libmediandk.so Leaks and Save EGL State Early

To prevent leaking libmediandk.so on early returns and to correctly save the current EGL context/surfaces before any EGL operations, we should instantiate ProbeCleanup immediately after dlopen and populate its fields.

bool probeShowsBroken() {
  void* ndk = dlopen("libmediandk.so", RTLD_NOW | RTLD_LOCAL);
  if (!ndk) return false;
  ProbeCleanup c;
  c.ndk = ndk;
  c.oldDpy = eglGetCurrentDisplay();
  c.oldCtx = eglGetCurrentContext();
  c.oldDraw = eglGetCurrentSurface(EGL_DRAW);
  c.oldRead = eglGetCurrentSurface(EGL_READ);

  auto newWithUsage = (AImageReader_newWithUsage_t)dlsym(ndk, "AImageReader_newWithUsage");
  auto getWindow = (AImageReader_getWindow_t)dlsym(ndk, "AImageReader_getWindow");
  auto acquireNext = (AImageReader_acquireNextImage_t)dlsym(ndk, "AImageReader_acquireNextImage");
  auto readerDelete = (AImageReader_delete_t)dlsym(ndk, "AImageReader_delete");
  auto getHwBuffer = (AImage_getHardwareBuffer_t)dlsym(ndk, "AImage_getHardwareBuffer");
  auto imageDelete = (AImage_delete_t)dlsym(ndk, "AImage_delete");
  if (!newWithUsage || !getWindow || !acquireNext || !readerDelete || !getHwBuffer || !imageDelete) {
    return false; // pre-26 device: can't probe (and 1010102 unlikely anyway)
  }

  auto getNativeClientBuffer =
      (PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC)eglGetProcAddress("eglGetNativeClientBufferANDROID");
  auto createImage = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR");
  auto destroyImage = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR");
  auto imageTargetTexture =
      (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
  if (!getNativeClientBuffer || !createImage || !destroyImage || !imageTargetTexture) {
    return false;
  }

  c.readerDelete = readerDelete;
  c.imageDelete = imageDelete;
  c.destroyImage = destroyImage;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d59add4 — ProbeCleanup owns the dlopen handle and closes it on every exit path, after the AImage/AImageReader deleters that live in that library.

Comment thread android/rgb10a2_probe.cpp
Comment on lines +263 to +273
bool fvpRgb10a2CrossContextOk() {
static int cached = -1;
if (cached >= 0) return cached != 0;
if (const char* env = getenv("FVP_RGB10A2_PROBE")) {
if (!strcmp(env, "0")) cached = 1;
else if (!strcmp(env, "force8")) cached = 0;
}
if (cached < 0) cached = probeShowsBroken() ? 0 : 1;
clog << "rgb10a2 cross-context sampling ok: " << cached << endl;
return cached != 0;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Thread-Safe Caching of Probe Result

The static variable cached is checked and modified without any synchronization. If multiple players are initialized concurrently, or if fvpRgb10a2CrossContextOk() is called from multiple threads, this results in a data race on cached and could cause probeShowsBroken() to run multiple times concurrently.

We can make this completely thread-safe and more idiomatic by using a C++11 thread-safe static local variable initialized with a lambda.

Suggested change
bool fvpRgb10a2CrossContextOk() {
static int cached = -1;
if (cached >= 0) return cached != 0;
if (const char* env = getenv("FVP_RGB10A2_PROBE")) {
if (!strcmp(env, "0")) cached = 1;
else if (!strcmp(env, "force8")) cached = 0;
}
if (cached < 0) cached = probeShowsBroken() ? 0 : 1;
clog << "rgb10a2 cross-context sampling ok: " << cached << endl;
return cached != 0;
}
bool fvpRgb10a2CrossContextOk() {
static const bool ok = []() {
if (const char* env = getenv("FVP_RGB10A2_PROBE")) {
if (!strcmp(env, "0")) return true;
if (!strcmp(env, "force8")) return false;
}
const bool broken = probeShowsBroken();
clog << "rgb10a2 cross-context sampling ok: " << !broken << endl;
return !broken;
}();
return ok;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d59add4 — cache is a magic static initialized by a lambda (env overrides included), so concurrent first calls are safe.

The probe ran eglMakeCurrent on the calling thread and cleared to
EGL_NO_CONTEXT on exit, clobbering any context the caller had current.
Save the current display/context/surfaces before touching EGL and
restore them in the cleanup. Also dlclose the mediandk handle (after
the AImage/AImageReader deleters that live in it) and switch the probe
cache to a magic static so concurrent first calls are safe.
@abdelaziz-mahdy

Copy link
Copy Markdown
Contributor Author

Tested on the affected device (TCL RTD2875P / PowerVR BXE-4-32, Android 12) — the probe as written does NOT detect the failure, and the reasons are informative, so posting them before any more engineering. Iterations tried (all on feature/rgb10a2-probe, field-tested via logcat):

1. AImageReader rejects RGBA_1010102 outright (-10004, it's not an AIMAGE_FORMAT). Switched to AHardwareBuffer_allocate (R10G10B10A2_UNORM + GPU_SAMPLED | GPU_COLOR_OUTPUT).

2. Flat content always survives. With solid color quadrants: 0/4 corrupt while real video corrupts on screen at the same moment. Consistent with the corruption geometry from #374 (detail areas corrupt, flat areas pixel-perfect) — the vendor framebuffer compression decodes trivial blocks correctly even on the broken path. Switched to a hash-colored 8 px checkerboard (32-byte blocks = the observed burst size) with a full 4096-sample comparison.

3. Even the faithful version passes. Also tried AIMAGE_FORMAT_PRIVATE reader + 10-bit EGL window surface producer, so the driver allocates its native compressed window buffers exactly like the render path:

rgb10a2 cross-context probe: 0/4096 samples corrupt
rgb10a2 cross-context sampling ok: true
select a 10bits EGLConfig        <- and playback then visibly corrupts

Conclusion: on this driver, in-process eglCreateImageKHR(EGL_NATIVE_BUFFER_ANDROID) import round-trips cleanly. The real-world failures involve different consumers:

  • the texture path fails inside SurfaceTexture's own import (that's where IMGSRV IsTextureConsistent fires, in the app process), and
  • a SurfaceView/platform-view path fails in SurfaceFlinger's process (per-frame MapperGetCPUAddresses MAP_FAILED at the compositor while composing the 10-bit layer).

So a faithful app-side probe would need a real SurfaceTexture consumer (ASurfaceTexture NDK, API 28+, needs a small Java assist to construct) — and that still wouldn't cover the compositor-side failure. Given that, detection may fit better inside libmdk (or a driver deny-list keyed on GL_RENDERER/IMG DDK version), and you'd know best which. Happy to build the SurfaceTexture variant if you think it's the right direction — this device reliably reproduces both failure modes for testing.

Marking this draft accordingly; the branch stays available as a starting point.

@wang-bin

Copy link
Copy Markdown
Owner

not elegant, I prefer GLRenderAPI.depth = 8.

@abdelaziz-mahdy

Copy link
Copy Markdown
Contributor Author

not elegant, I prefer GLRenderAPI.depth = 8.

I agree, and even don't know if the other approaches will work

But with 8 we lose the hdr right?

@wang-bin

Copy link
Copy Markdown
Owner

not elegant, I prefer GLRenderAPI.depth = 8.

I agree, and even don't know if the other approaches will work

But with 8 we lose the hdr right?

hdr works for all depths, but lose some details in 8bit surfaces. i can change EGL_SDR_DEPTH default value to 8, then hdr will try rgb10a2 but still corrupt on your device.

@abdelaziz-mahdy

Copy link
Copy Markdown
Contributor Author

not elegant, I prefer GLRenderAPI.depth = 8.

I agree, and even don't know if the other approaches will work

But with 8 we lose the hdr right?

hdr works for all depths, but lose some details in 8bit surfaces. i can change EGL_SDR_DEPTH default value to 8, then hdr will try rgb10a2 but still corrupt on your device.

Oh I misunderstood then, so yeah 8 is good as a default

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants