diff --git a/tests/GosCompatTests/GosCompatCheckApp/Android.bp b/tests/GosCompatTests/GosCompatCheckApp/Android.bp
index dc9c17f023342..c60b60e5e9806 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/Android.bp
+++ b/tests/GosCompatTests/GosCompatCheckApp/Android.bp
@@ -12,6 +12,25 @@ cc_library_shared {
stl: "none",
}
+cc_library_shared {
+ name: "libgoscompat_dmabuf_release_jni",
+ srcs: ["jni/dmabuf_release_jni.cpp"],
+ // The helper app is SDK-built, so the JNI dependency needs a matching SDK variant.
+ sdk_version: "current",
+ header_libs: ["jni_headers"],
+ shared_libs: [
+ "libEGL",
+ "libGLESv2",
+ "liblog",
+ ],
+ cflags: [
+ "-Wall",
+ "-Wextra",
+ "-Werror",
+ ],
+ stl: "c++_static",
+}
+
android_test_helper_app {
name: "GosCompatCheckApp",
srcs: [
@@ -19,7 +38,10 @@ android_test_helper_app {
"src/**/*.kt",
],
manifest: "AndroidManifest.xml",
- jni_libs: ["libgoscompat_maps_scan_jni"],
+ jni_libs: [
+ "libgoscompat_maps_scan_jni",
+ "libgoscompat_dmabuf_release_jni",
+ ],
// Keep the JNI library inside the standalone helper APK loaded by Tradefed and manual installs.
use_embedded_native_libs: true,
// Build both ABIs so the helper can run under either app process ABI in multi-ABI test runs.
diff --git a/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml b/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml
index 70b02b471e839..2e6e818ccc050 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml
+++ b/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml
@@ -23,6 +23,28 @@
android:process=":maps_scan"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
+
+
+
+
+
+
+
+
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define LOG_TAG "GosCompatDmaBufRelease"
+#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
+
+#ifndef EGL_PROTECTED_CONTENT_EXT
+#define EGL_PROTECTED_CONTENT_EXT 0x32C0
+#endif
+
+#ifndef GL_TEXTURE_PROTECTED_EXT
+#define GL_TEXTURE_PROTECTED_EXT 0x8BFA
+#endif
+
+namespace {
+
+constexpr const char* DIRECT_HEAP_DEVICE_PREFIX = "/dev/dma_heap/";
+constexpr const char* VFRAME_SECURE_HEAP_NAME = "vframe-secure";
+constexpr const char* VSTREAM_SECURE_HEAP_NAME = "vstream-secure";
+constexpr uint64_t DIRECT_HEAP_BYTES_PER_PIXEL = 4;
+constexpr uint64_t DIRECT_HEAP_EXPECTED_CHUNK_SIZE = 65536;
+constexpr const char* EGL_PROTECTED_CONTENT_EXTENSION = "EGL_EXT_protected_content";
+constexpr const char* GL_PROTECTED_TEXTURES_EXTENSION = "GL_EXT_protected_textures";
+
+std::mutex g_workload_mutex;
+/*
+ * Retained DMA-BUF fds intentionally stay outside ScopedFd. The tests need to
+ * choose between explicit close through release_workload() and process teardown.
+ */
+std::vector g_retained_fds;
+
+struct ProtectedEglWorkload {
+ EGLDisplay display = EGL_NO_DISPLAY;
+ EGLContext context = EGL_NO_CONTEXT;
+ EGLSurface surface = EGL_NO_SURFACE;
+ GLuint texture = 0;
+};
+
+ProtectedEglWorkload g_protected_egl_workload;
+
+struct NativeResult {
+ bool ready = false;
+ bool unsupported = false;
+ bool protected_content = true;
+ int allocated_buffers = 0;
+ int pid = 0;
+ int tid = 0;
+ std::string heap_path;
+ std::string heap_name;
+ std::string allocator;
+ std::string allocation;
+ std::string error;
+};
+
+int get_thread_id() {
+ return static_cast(syscall(SYS_gettid));
+}
+
+void close_fd(int fd) {
+ if (fd >= 0) {
+ close(fd);
+ }
+}
+
+bool has_extension(const char* extensions, const char* extension) {
+ if (extensions == nullptr || extension == nullptr) {
+ return false;
+ }
+
+ const size_t extension_length = std::strlen(extension);
+ const char* current = extensions;
+ while ((current = std::strstr(current, extension)) != nullptr) {
+ const bool starts_token = current == extensions || current[-1] == ' ';
+ const char next = current[extension_length];
+ const bool ends_token = next == '\0' || next == ' ';
+ if (starts_token && ends_token) {
+ return true;
+ }
+ current += extension_length;
+ }
+ return false;
+}
+
+std::string hex_error(const char* prefix, unsigned int error) {
+ char buffer[64];
+ std::snprintf(buffer, sizeof(buffer), "%s 0x%x", prefix, error);
+ return buffer;
+}
+
+void destroy_protected_egl_workload(ProtectedEglWorkload* workload) {
+ if (workload->display == EGL_NO_DISPLAY) {
+ return;
+ }
+
+ if (workload->context != EGL_NO_CONTEXT && workload->surface != EGL_NO_SURFACE) {
+ eglMakeCurrent(workload->display, workload->surface, workload->surface,
+ workload->context);
+ }
+ if (workload->texture != 0) {
+ glDeleteTextures(1, &workload->texture);
+ }
+ eglMakeCurrent(workload->display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ if (workload->surface != EGL_NO_SURFACE) {
+ eglDestroySurface(workload->display, workload->surface);
+ }
+ if (workload->context != EGL_NO_CONTEXT) {
+ eglDestroyContext(workload->display, workload->context);
+ }
+ eglTerminate(workload->display);
+ *workload = ProtectedEglWorkload();
+}
+
+class ScopedFd {
+public:
+ explicit ScopedFd(int fd = -1) : mFd(fd) {}
+
+ ~ScopedFd() {
+ reset();
+ }
+
+ ScopedFd(const ScopedFd&) = delete;
+ ScopedFd& operator=(const ScopedFd&) = delete;
+
+ ScopedFd(ScopedFd&& other) noexcept : mFd(other.release()) {}
+
+ ScopedFd& operator=(ScopedFd&& other) noexcept {
+ if (this != &other) {
+ reset(other.release());
+ }
+ return *this;
+ }
+
+ int get() const {
+ return mFd;
+ }
+
+ int release() {
+ int fd = mFd;
+ mFd = -1;
+ return fd;
+ }
+
+ void reset(int fd = -1) {
+ close_fd(mFd);
+ mFd = fd;
+ }
+
+private:
+ int mFd;
+};
+
+void release_workload() {
+ std::lock_guard lock(g_workload_mutex);
+ for (int fd : g_retained_fds) {
+ close_fd(fd);
+ }
+ g_retained_fds.clear();
+
+ destroy_protected_egl_workload(&g_protected_egl_workload);
+}
+
+bool is_allowed_heap_name(const std::string& heap_name) {
+ return heap_name == VFRAME_SECURE_HEAP_NAME || heap_name == VSTREAM_SECURE_HEAP_NAME;
+}
+
+void set_errno_error(NativeResult* result, const std::string& operation, int error_number) {
+ result->error = operation + ": " + std::strerror(error_number);
+}
+
+uint64_t direct_heap_allocation_size(int width, int height, NativeResult* result) {
+ const uint64_t safe_width = width > 0 ? static_cast(width) : 1;
+ const uint64_t safe_height = height > 0 ? static_cast(height) : 1;
+ const uint64_t max_size = std::numeric_limits::max();
+ if (safe_width > max_size / safe_height ||
+ safe_width * safe_height > max_size / DIRECT_HEAP_BYTES_PER_PIXEL) {
+ result->error = "Direct secure DMA-BUF allocation size overflowed";
+ return 0;
+ }
+ return safe_width * safe_height * DIRECT_HEAP_BYTES_PER_PIXEL;
+}
+
+NativeResult start_workload(const std::string& requested_heap_name, int width, int height,
+ int buffer_count) {
+ release_workload();
+
+ NativeResult result;
+ result.pid = getpid();
+ result.tid = get_thread_id();
+
+ const std::string heap_name = requested_heap_name.empty()
+ ? VFRAME_SECURE_HEAP_NAME : requested_heap_name;
+ result.heap_name = heap_name;
+ result.allocator = "dma_heap";
+
+ if (!is_allowed_heap_name(heap_name)) {
+ result.unsupported = true;
+ result.error = "Unsupported direct secure DMA-BUF heap name";
+ return result;
+ }
+
+ const uint64_t allocation_size = direct_heap_allocation_size(width, height, &result);
+ if (allocation_size == 0) {
+ return result;
+ }
+ result.allocation = "bytes=" + std::to_string(allocation_size)
+ + ", expected_chunks="
+ + std::to_string(allocation_size / DIRECT_HEAP_EXPECTED_CHUNK_SIZE)
+ + ", expected_chunk_size=" + std::to_string(DIRECT_HEAP_EXPECTED_CHUNK_SIZE);
+
+ const std::string heap_path = std::string(DIRECT_HEAP_DEVICE_PREFIX) + heap_name;
+ result.heap_path = heap_path;
+ int raw_heap_fd;
+ do {
+ raw_heap_fd = open(heap_path.c_str(), O_RDONLY | O_CLOEXEC);
+ } while (raw_heap_fd < 0 && errno == EINTR);
+ if (raw_heap_fd < 0) {
+ const int error_number = errno;
+ if (error_number == ENOENT || error_number == EACCES) {
+ result.unsupported = true;
+ }
+ set_errno_error(&result, "open " + heap_path, error_number);
+ return result;
+ }
+ ScopedFd heap_fd(raw_heap_fd);
+
+ /*
+ * This follows the same DMA-BUF allocation and fd release shape as
+ * DmaBufHeapTest.Allocate and DmaBufHeapTest.RepeatedAllocate in
+ * system/memory/libdmabufheap/tests/dmabuf_heap_test.cpp. This helper uses
+ * the raw DMA heap UAPI because it must target the Pixel secure heap device
+ * names directly and run inside the SDK-built helper APK process.
+ */
+ std::vector allocated_fds;
+ const int requested_buffers = buffer_count > 0 ? buffer_count : 1;
+ for (int i = 0; i < requested_buffers; i++) {
+ struct dma_heap_allocation_data data = {};
+ data.len = allocation_size;
+ data.fd_flags = O_RDWR | O_CLOEXEC;
+ data.heap_flags = 0;
+
+ int ret;
+ do {
+ ret = ioctl(heap_fd.get(), DMA_HEAP_IOCTL_ALLOC, &data);
+ } while (ret < 0 && errno == EINTR);
+ if (ret < 0) {
+ set_errno_error(&result, "DMA_HEAP_IOCTL_ALLOC " + heap_name, errno);
+ break;
+ }
+ ScopedFd allocation(static_cast(data.fd));
+ allocated_fds.push_back(std::move(allocation));
+ }
+
+ result.allocated_buffers = static_cast(allocated_fds.size());
+ if (!result.error.empty()) {
+ return result;
+ }
+ if (allocated_fds.empty()) {
+ if (result.error.empty()) {
+ result.error = "No DMA-BUF file descriptors were allocated";
+ }
+ return result;
+ }
+
+ result.ready = true;
+ result.unsupported = false;
+ result.error.clear();
+ {
+ std::vector fds;
+ fds.reserve(allocated_fds.size());
+ for (ScopedFd& fd : allocated_fds) {
+ fds.push_back(fd.release());
+ }
+
+ std::lock_guard lock(g_workload_mutex);
+ g_retained_fds = std::move(fds);
+ }
+ return result;
+}
+
+/*
+ * This follows the public CTS protected EGL shape rather than private GPU APIs:
+ * - cts/tests/tests/media/common/src/android/media/cts/OutputSurface.java creates a
+ * protected GLES context and protected pbuffer with EGL_PROTECTED_CONTENT_EXT.
+ * - cts/tests/vr/src/android/vr/cts/RendererProtectedTexturesTest.java marks a
+ * texture with GL_TEXTURE_PROTECTED_EXT, and VrExtensionBehaviorTest.java validates
+ * those protected attributes.
+ *
+ * Unlike CTS, this helper keeps the protected EGL resources live until release_workload()
+ * or process teardown, so the test covers the DMA-BUF final release path.
+ */
+NativeResult start_protected_egl_workload(int width, int height, int iterations) {
+ release_workload();
+
+ NativeResult result;
+ ProtectedEglWorkload workload;
+ result.pid = getpid();
+ result.tid = get_thread_id();
+ result.heap_name = "vendor-selected";
+ result.allocator = "EGL_EXT_protected_content";
+ result.protected_content = true;
+
+ auto fail = [&result, &workload](const std::string& error) -> NativeResult {
+ result.error = error;
+ destroy_protected_egl_workload(&workload);
+ return result;
+ };
+ auto unsupported = [&result, &workload](const std::string& error) -> NativeResult {
+ result.unsupported = true;
+ result.error = error;
+ destroy_protected_egl_workload(&workload);
+ return result;
+ };
+
+ EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
+ if (display == EGL_NO_DISPLAY) {
+ result.error = hex_error("eglGetDisplay failed with EGL error", eglGetError());
+ return result;
+ }
+
+ if (!eglInitialize(display, nullptr, nullptr)) {
+ result.error = hex_error("eglInitialize failed with EGL error", eglGetError());
+ return result;
+ }
+ workload.display = display;
+
+ const char* egl_extensions = eglQueryString(display, EGL_EXTENSIONS);
+ if (!has_extension(egl_extensions, EGL_PROTECTED_CONTENT_EXTENSION)) {
+ return unsupported("EGL_EXT_protected_content is not advertised");
+ }
+
+ if (!eglBindAPI(EGL_OPENGL_ES_API)) {
+ return fail(hex_error("eglBindAPI failed with EGL error", eglGetError()));
+ }
+
+ const EGLint config_attributes[] = {
+ EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
+ EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
+ EGL_RED_SIZE, 8,
+ EGL_GREEN_SIZE, 8,
+ EGL_BLUE_SIZE, 8,
+ EGL_ALPHA_SIZE, 8,
+ EGL_NONE,
+ };
+ EGLConfig config = nullptr;
+ EGLint config_count = 0;
+ if (!eglChooseConfig(display, config_attributes, &config, 1, &config_count) ||
+ config_count == 0) {
+ return fail(hex_error("eglChooseConfig failed with EGL error", eglGetError()));
+ }
+
+ EGLint max_pbuffer_width = 1;
+ EGLint max_pbuffer_height = 1;
+ eglGetConfigAttrib(display, config, EGL_MAX_PBUFFER_WIDTH, &max_pbuffer_width);
+ eglGetConfigAttrib(display, config, EGL_MAX_PBUFFER_HEIGHT, &max_pbuffer_height);
+ if (max_pbuffer_width < 1 || max_pbuffer_height < 1) {
+ return fail("EGL config reported an invalid pbuffer size limit");
+ }
+ const EGLint requested_width = width > 0 ? width : 1;
+ const EGLint requested_height = height > 0 ? height : 1;
+ const EGLint actual_width = requested_width <= max_pbuffer_width
+ ? requested_width : max_pbuffer_width;
+ const EGLint actual_height = requested_height <= max_pbuffer_height
+ ? requested_height : max_pbuffer_height;
+
+ const EGLint context_attributes[] = {
+ EGL_CONTEXT_CLIENT_VERSION, 2,
+ EGL_PROTECTED_CONTENT_EXT, EGL_TRUE,
+ EGL_NONE,
+ };
+ EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
+ if (context == EGL_NO_CONTEXT) {
+ return fail(hex_error("eglCreateContext failed with EGL error", eglGetError()));
+ }
+ workload.context = context;
+
+ const EGLint surface_attributes[] = {
+ EGL_WIDTH, actual_width,
+ EGL_HEIGHT, actual_height,
+ EGL_PROTECTED_CONTENT_EXT, EGL_TRUE,
+ EGL_NONE,
+ };
+ EGLSurface surface = eglCreatePbufferSurface(display, config, surface_attributes);
+ if (surface == EGL_NO_SURFACE) {
+ return fail(hex_error("eglCreatePbufferSurface failed with EGL error", eglGetError()));
+ }
+ workload.surface = surface;
+
+ if (!eglMakeCurrent(display, surface, surface, context)) {
+ return fail(hex_error("eglMakeCurrent failed with EGL error", eglGetError()));
+ }
+
+ const char* gl_extensions = reinterpret_cast(glGetString(GL_EXTENSIONS));
+ if (!has_extension(gl_extensions, GL_PROTECTED_TEXTURES_EXTENSION)) {
+ return unsupported("GL_EXT_protected_textures is not advertised");
+ }
+
+ GLuint texture = 0;
+ glGenTextures(1, &texture);
+ workload.texture = texture;
+ glBindTexture(GL_TEXTURE_2D, texture);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_PROTECTED_EXT, GL_TRUE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, actual_width, actual_height, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ GLenum gl_error = glGetError();
+ if (gl_error != GL_NO_ERROR) {
+ return fail(hex_error("protected glTexImage2D failed with GL error", gl_error));
+ }
+
+ const int draw_iterations = iterations > 0 ? iterations : 1;
+ glViewport(0, 0, actual_width, actual_height);
+ for (int i = 0; i < draw_iterations; i++) {
+ glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ }
+ glFinish();
+ gl_error = glGetError();
+ if (gl_error != GL_NO_ERROR) {
+ return fail(hex_error("protected GL workload failed with GL error", gl_error));
+ }
+
+ result.ready = true;
+ result.unsupported = false;
+ result.allocated_buffers = 2;
+ result.allocation = "protected_pbuffer=" + std::to_string(actual_width)
+ + "x" + std::to_string(actual_height)
+ + ", protected_texture=" + std::to_string(actual_width)
+ + "x" + std::to_string(actual_height)
+ + ", requested=" + std::to_string(requested_width)
+ + "x" + std::to_string(requested_height)
+ + ", iterations=" + std::to_string(draw_iterations);
+
+ {
+ std::lock_guard lock(g_workload_mutex);
+ g_protected_egl_workload = workload;
+ }
+ return result;
+}
+
+jobjectArray result_to_array(JNIEnv* env, const NativeResult& result) {
+ jclass string_class = env->FindClass("java/lang/String");
+ jobjectArray array = env->NewObjectArray(11, string_class, nullptr);
+ const std::string values[] = {
+ result.ready ? "true" : "false",
+ result.unsupported ? "true" : "false",
+ result.protected_content ? "true" : "false",
+ std::to_string(result.allocated_buffers),
+ std::to_string(result.pid),
+ std::to_string(result.tid),
+ result.heap_path,
+ result.heap_name,
+ result.allocator,
+ result.allocation,
+ result.error,
+ };
+ for (jsize i = 0; i < 11; i++) {
+ jstring value = env->NewStringUTF(values[i].c_str());
+ env->SetObjectArrayElement(array, i, value);
+ env->DeleteLocalRef(value);
+ }
+ return array;
+}
+
+} // namespace
+
+extern "C" JNIEXPORT jobjectArray JNICALL
+Java_app_grapheneos_goscompat_checks_dmabuf_DmaBufReleaseRunner_nativeStartWorkload(
+ JNIEnv* env, jobject /* thiz */, jstring heap_name, jint width, jint height,
+ jint buffer_count) {
+ const char* heap_name_chars = heap_name != nullptr
+ ? env->GetStringUTFChars(heap_name, nullptr) : nullptr;
+ std::string heap_name_string = heap_name_chars != nullptr ? heap_name_chars : "";
+ if (heap_name_chars != nullptr) {
+ env->ReleaseStringUTFChars(heap_name, heap_name_chars);
+ }
+
+ NativeResult result = start_workload(heap_name_string, width, height, buffer_count);
+ if (!result.ready && !result.error.empty()) {
+ ALOGE("%s", result.error.c_str());
+ }
+ return result_to_array(env, result);
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_app_grapheneos_goscompat_checks_dmabuf_DmaBufReleaseRunner_nativeReleaseWorkload(
+ JNIEnv* /* env */, jobject /* thiz */) {
+ release_workload();
+}
+
+extern "C" JNIEXPORT jobjectArray JNICALL
+Java_app_grapheneos_goscompat_checks_dmabuf_DmaBufReleaseRunner_nativeStartProtectedEglWorkload(
+ JNIEnv* env, jobject /* thiz */, jint width, jint height, jint iterations) {
+ NativeResult result = start_protected_egl_workload(width, height, iterations);
+ if (!result.ready && !result.error.empty()) {
+ ALOGE("%s", result.error.c_str());
+ }
+ return result_to_array(env, result);
+}
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/GosCompatCheckActivity.kt b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/GosCompatCheckActivity.kt
index c7c80ca754e51..d46091c75e193 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/GosCompatCheckActivity.kt
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/GosCompatCheckActivity.kt
@@ -26,6 +26,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
+import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -37,19 +38,24 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import app.grapheneos.goscompat.checks.dmabuf.DmaBufReleasePanel
class GosCompatCheckActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
enableEdgeToEdge(
- statusBarStyle = SystemBarStyle.auto(AndroidColor.TRANSPARENT, AndroidColor.TRANSPARENT),
- navigationBarStyle = SystemBarStyle.auto(AndroidColor.TRANSPARENT, AndroidColor.TRANSPARENT),
+ statusBarStyle = SystemBarStyle.auto(
+ AndroidColor.TRANSPARENT,
+ AndroidColor.TRANSPARENT,
+ ),
+ navigationBarStyle = SystemBarStyle.auto(
+ AndroidColor.TRANSPARENT,
+ AndroidColor.TRANSPARENT,
+ ),
)
setContent {
@@ -71,18 +77,21 @@ class GosCompatCheckActivity : ComponentActivity() {
reflectiveMapsScanRunning = reflectiveMapsScanRunning.value,
onRunDirectMapsScan = {
startMapsScan(
- GosCompatContract.METHOD_RUN_DIRECT_MAPS_SCAN_CHECK,
+ GosCompatContract.MapsScan.Method.RUN_DIRECT_CHECK,
directMapsScanResult,
directMapsScanRunning,
)
},
onRunReflectiveMapsScan = {
startMapsScan(
- GosCompatContract.METHOD_RUN_REFLECTIVE_MAPS_SCAN_CHECK,
+ GosCompatContract.MapsScan.Method.RUN_REFLECTIVE_CHECK,
reflectiveMapsScanResult,
reflectiveMapsScanRunning,
)
},
+ dmaBufReleaseContent = {
+ DmaBufReleasePanel(this@GosCompatCheckActivity)
+ },
)
}
}
@@ -111,7 +120,7 @@ class GosCompatCheckActivity : ComponentActivity() {
val startTimeMillis = System.currentTimeMillis()
return try {
val client = context.contentResolver.acquireUnstableContentProviderClient(
- GosCompatContract.MAPS_SCAN_CONTENT_URI,
+ GosCompatContract.MapsScan.CONTENT_URI,
) ?: return MapsScanResult.failed("Maps scan provider was unavailable")
val bundle = try {
// Keep the UI process from becoming a stable provider dependent. The scanner is
@@ -143,6 +152,7 @@ private fun GosCompatCheckScreen(
reflectiveMapsScanRunning: Boolean,
onRunDirectMapsScan: () -> Unit,
onRunReflectiveMapsScan: () -> Unit,
+ dmaBufReleaseContent: @Composable () -> Unit,
) {
Column(
modifier = Modifier
@@ -158,23 +168,42 @@ private fun GosCompatCheckScreen(
fontWeight = FontWeight.SemiBold,
)
- MapsScanControl(
- title = "Direct JNI maps scan",
- mapsScanResult = directMapsScanResult,
- mapsScanRunning = directMapsScanRunning,
- onRunMapsScan = onRunDirectMapsScan,
- )
- ResultSection("Direct JNI details", directMapsScanResult?.details.orEmpty())
- ResultSection("Direct JNI errors", directMapsScanResult?.errors.orEmpty())
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text(
+ text = "Memory maps scan",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ MapsScanControl(
+ title = "Direct JNI maps scan",
+ mapsScanResult = directMapsScanResult,
+ mapsScanRunning = directMapsScanRunning,
+ onRunMapsScan = onRunDirectMapsScan,
+ )
+ ResultSection("Direct JNI details", directMapsScanResult?.details.orEmpty())
+ ResultSection("Direct JNI errors", directMapsScanResult?.errors.orEmpty())
- MapsScanControl(
- title = "Reflective JNI maps scan",
- mapsScanResult = reflectiveMapsScanResult,
- mapsScanRunning = reflectiveMapsScanRunning,
- onRunMapsScan = onRunReflectiveMapsScan,
- )
- ResultSection("Reflective JNI details", reflectiveMapsScanResult?.details.orEmpty())
- ResultSection("Reflective JNI errors", reflectiveMapsScanResult?.errors.orEmpty())
+ MapsScanControl(
+ title = "Reflective JNI maps scan",
+ mapsScanResult = reflectiveMapsScanResult,
+ mapsScanRunning = reflectiveMapsScanRunning,
+ onRunMapsScan = onRunReflectiveMapsScan,
+ )
+ ResultSection("Reflective JNI details", reflectiveMapsScanResult?.details.orEmpty())
+ ResultSection("Reflective JNI errors", reflectiveMapsScanResult?.errors.orEmpty())
+ }
+ }
+
+ dmaBufReleaseContent()
}
}
@@ -209,10 +238,7 @@ private fun MapsScanControl(
text = status,
modifier = Modifier
.background(statusColor.copy(alpha = 0.12f), RoundedCornerShape(6.dp))
- .padding(horizontal = 12.dp, vertical = 8.dp)
- .semantics {
- contentDescription = "Memory maps scan status: $status"
- },
+ .padding(horizontal = 12.dp, vertical = 8.dp),
color = statusColor,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
@@ -231,7 +257,7 @@ private fun MapsScanControl(
}
@Composable
-private fun ResultSection(title: String, lines: List) {
+internal fun ResultSection(title: String, lines: List) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = title,
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanCrashActivity.java b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanCrashActivity.java
index f61196238bedc..c5fbf4be93af2 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanCrashActivity.java
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanCrashActivity.java
@@ -8,14 +8,14 @@ public final class MapsScanCrashActivity extends Activity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- String mode = getIntent().getStringExtra(GosCompatContract.EXTRA_MAPS_SCAN_MODE);
- String token = getIntent().getStringExtra(GosCompatContract.EXTRA_MAPS_SCAN_TOKEN);
+ String mode = getIntent().getStringExtra(GosCompatContract.MapsScan.Extra.MODE);
+ String token = getIntent().getStringExtra(GosCompatContract.MapsScan.Extra.TOKEN);
MapsScanResultStore.clear(this);
// Keep the native scan outside the launch transaction so a segfault is attributed
// to this isolated app process rather than the instrumentation shell command.
Thread launcher = new Thread(() -> {
- MapsScanResult result = GosCompatContract.MODE_REFLECTIVE.equals(mode)
+ MapsScanResult result = GosCompatContract.MapsScan.Mode.REFLECTIVE.equals(mode)
? MapsScanRunner.runReflective()
: MapsScanRunner.runDirect();
MapsScanResultStore.save(getApplicationContext(), token, result);
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanProvider.java b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanProvider.java
index 8c4111061b660..797af963c49f0 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanProvider.java
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanProvider.java
@@ -14,17 +14,17 @@ public boolean onCreate() {
@Override
public Bundle call(String method, String arg, Bundle extras) {
- if (GosCompatContract.METHOD_RUN_MAPS_SCAN_CHECK.equals(method)
- || GosCompatContract.METHOD_RUN_DIRECT_MAPS_SCAN_CHECK.equals(method)) {
+ if (GosCompatContract.MapsScan.Method.RUN_CHECK.equals(method)
+ || GosCompatContract.MapsScan.Method.RUN_DIRECT_CHECK.equals(method)) {
return MapsScanRunner.runDirect().toBundle();
}
- if (GosCompatContract.METHOD_RUN_REFLECTIVE_MAPS_SCAN_CHECK.equals(method)) {
+ if (GosCompatContract.MapsScan.Method.RUN_REFLECTIVE_CHECK.equals(method)) {
return MapsScanRunner.runReflective().toBundle();
}
- if (GosCompatContract.METHOD_GET_MAPS_SCAN_RESULT.equals(method)) {
+ if (GosCompatContract.MapsScan.Method.GET_RESULT.equals(method)) {
return MapsScanResultStore.load(getContext(), arg);
}
- if (GosCompatContract.METHOD_CLEAR_MAPS_SCAN_RESULT.equals(method)) {
+ if (GosCompatContract.MapsScan.Method.CLEAR_RESULT.equals(method)) {
return MapsScanResultStore.clear(getContext());
}
return super.call(method, arg, extras);
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResult.java b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResult.java
index 5402c70180d9c..0cbd8cc6fefce 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResult.java
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResult.java
@@ -76,31 +76,31 @@ public List getErrors() {
public Bundle toBundle() {
Bundle bundle = new Bundle();
- bundle.putBoolean(GosCompatContract.KEY_MAPS_SCAN_COMPLETED, mCompleted);
- bundle.putBoolean(GosCompatContract.KEY_MAPS_SCAN_WORKER_THREAD, mWorkerThread);
- bundle.putInt(GosCompatContract.KEY_MAPS_SCAN_SELECTED_RANGES, mSelectedRanges);
- bundle.putLong(GosCompatContract.KEY_MAPS_SCAN_SCANNED_BYTES, mScannedBytes);
- bundle.putInt(GosCompatContract.KEY_MAPS_SCAN_CALLER_TID, mCallerTid);
- bundle.putInt(GosCompatContract.KEY_MAPS_SCAN_WORKER_TID, mWorkerTid);
- bundle.putString(GosCompatContract.KEY_STATUS_TEXT, getStatusText());
- bundle.putString(GosCompatContract.KEY_SUMMARY, getSummary());
- bundle.putStringArrayList(GosCompatContract.KEY_MAPS_SCAN_DETAILS,
+ bundle.putBoolean(GosCompatContract.MapsScan.Key.COMPLETED, mCompleted);
+ bundle.putBoolean(GosCompatContract.MapsScan.Key.WORKER_THREAD, mWorkerThread);
+ bundle.putInt(GosCompatContract.MapsScan.Key.SELECTED_RANGES, mSelectedRanges);
+ bundle.putLong(GosCompatContract.MapsScan.Key.SCANNED_BYTES, mScannedBytes);
+ bundle.putInt(GosCompatContract.MapsScan.Key.CALLER_TID, mCallerTid);
+ bundle.putInt(GosCompatContract.MapsScan.Key.WORKER_TID, mWorkerTid);
+ bundle.putString(GosCompatContract.MapsScan.Key.STATUS_TEXT, getStatusText());
+ bundle.putString(GosCompatContract.MapsScan.Key.SUMMARY, getSummary());
+ bundle.putStringArrayList(GosCompatContract.MapsScan.Key.DETAILS,
new ArrayList<>(mDetails));
- bundle.putStringArrayList(GosCompatContract.KEY_MAPS_SCAN_ERRORS,
+ bundle.putStringArrayList(GosCompatContract.MapsScan.Key.ERRORS,
new ArrayList<>(mErrors));
return bundle;
}
public static MapsScanResult fromBundle(Bundle bundle) {
return new MapsScanResult(
- bundle.getBoolean(GosCompatContract.KEY_MAPS_SCAN_COMPLETED),
- bundle.getBoolean(GosCompatContract.KEY_MAPS_SCAN_WORKER_THREAD),
- bundle.getInt(GosCompatContract.KEY_MAPS_SCAN_SELECTED_RANGES),
- bundle.getLong(GosCompatContract.KEY_MAPS_SCAN_SCANNED_BYTES),
- bundle.getInt(GosCompatContract.KEY_MAPS_SCAN_CALLER_TID),
- bundle.getInt(GosCompatContract.KEY_MAPS_SCAN_WORKER_TID),
- getStringArrayList(bundle, GosCompatContract.KEY_MAPS_SCAN_DETAILS),
- getStringArrayList(bundle, GosCompatContract.KEY_MAPS_SCAN_ERRORS));
+ bundle.getBoolean(GosCompatContract.MapsScan.Key.COMPLETED),
+ bundle.getBoolean(GosCompatContract.MapsScan.Key.WORKER_THREAD),
+ bundle.getInt(GosCompatContract.MapsScan.Key.SELECTED_RANGES),
+ bundle.getLong(GosCompatContract.MapsScan.Key.SCANNED_BYTES),
+ bundle.getInt(GosCompatContract.MapsScan.Key.CALLER_TID),
+ bundle.getInt(GosCompatContract.MapsScan.Key.WORKER_TID),
+ getStringArrayList(bundle, GosCompatContract.MapsScan.Key.DETAILS),
+ getStringArrayList(bundle, GosCompatContract.MapsScan.Key.ERRORS));
}
public static MapsScanResult failed(String error) {
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResultStore.java b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResultStore.java
index c72431bf9021b..6e585c01f9ec7 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResultStore.java
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanResultStore.java
@@ -41,18 +41,18 @@ static void save(Context context, String token, MapsScanResult result) {
.commit();
Properties properties = new Properties();
- properties.setProperty(GosCompatContract.EXTRA_MAPS_SCAN_TOKEN, token);
- properties.setProperty(GosCompatContract.KEY_MAPS_SCAN_COMPLETED,
+ properties.setProperty(GosCompatContract.MapsScan.Extra.TOKEN, token);
+ properties.setProperty(GosCompatContract.MapsScan.Key.COMPLETED,
Boolean.toString(result.isCompleted()));
- properties.setProperty(GosCompatContract.KEY_MAPS_SCAN_WORKER_THREAD,
+ properties.setProperty(GosCompatContract.MapsScan.Key.WORKER_THREAD,
Boolean.toString(result.usedWorkerThread()));
- properties.setProperty(GosCompatContract.KEY_MAPS_SCAN_SELECTED_RANGES,
+ properties.setProperty(GosCompatContract.MapsScan.Key.SELECTED_RANGES,
Integer.toString(result.getSelectedRanges()));
- properties.setProperty(GosCompatContract.KEY_MAPS_SCAN_SCANNED_BYTES,
+ properties.setProperty(GosCompatContract.MapsScan.Key.SCANNED_BYTES,
Long.toString(result.getScannedBytes()));
- properties.setProperty(GosCompatContract.KEY_MAPS_SCAN_CALLER_TID,
+ properties.setProperty(GosCompatContract.MapsScan.Key.CALLER_TID,
Integer.toString(result.getCallerTid()));
- properties.setProperty(GosCompatContract.KEY_MAPS_SCAN_WORKER_TID,
+ properties.setProperty(GosCompatContract.MapsScan.Key.WORKER_TID,
Integer.toString(result.getWorkerTid()));
try (FileOutputStream output = new FileOutputStream(resultFile(context))) {
properties.store(output, null);
@@ -64,31 +64,31 @@ static Bundle load(Context context, String token) {
Bundle bundle = new Bundle();
SharedPreferences prefs = prefs(context);
if (token == null || !token.equals(prefs.getString(KEY_TOKEN, null))) {
- bundle.putBoolean(GosCompatContract.KEY_MAPS_SCAN_RESULT_AVAILABLE, false);
+ bundle.putBoolean(GosCompatContract.MapsScan.Key.RESULT_AVAILABLE, false);
return bundle;
}
boolean completed = prefs.getBoolean(KEY_COMPLETED, false);
boolean workerThread = prefs.getBoolean(KEY_WORKER_THREAD, false);
- bundle.putBoolean(GosCompatContract.KEY_MAPS_SCAN_RESULT_AVAILABLE, true);
- bundle.putBoolean(GosCompatContract.KEY_MAPS_SCAN_COMPLETED, completed);
- bundle.putBoolean(GosCompatContract.KEY_MAPS_SCAN_WORKER_THREAD, workerThread);
- bundle.putInt(GosCompatContract.KEY_MAPS_SCAN_SELECTED_RANGES,
+ bundle.putBoolean(GosCompatContract.MapsScan.Key.RESULT_AVAILABLE, true);
+ bundle.putBoolean(GosCompatContract.MapsScan.Key.COMPLETED, completed);
+ bundle.putBoolean(GosCompatContract.MapsScan.Key.WORKER_THREAD, workerThread);
+ bundle.putInt(GosCompatContract.MapsScan.Key.SELECTED_RANGES,
prefs.getInt(KEY_SELECTED_RANGES, 0));
- bundle.putLong(GosCompatContract.KEY_MAPS_SCAN_SCANNED_BYTES,
+ bundle.putLong(GosCompatContract.MapsScan.Key.SCANNED_BYTES,
prefs.getLong(KEY_SCANNED_BYTES, 0));
- bundle.putInt(GosCompatContract.KEY_MAPS_SCAN_CALLER_TID,
+ bundle.putInt(GosCompatContract.MapsScan.Key.CALLER_TID,
prefs.getInt(KEY_CALLER_TID, 0));
- bundle.putInt(GosCompatContract.KEY_MAPS_SCAN_WORKER_TID,
+ bundle.putInt(GosCompatContract.MapsScan.Key.WORKER_TID,
prefs.getInt(KEY_WORKER_TID, 0));
- bundle.putString(GosCompatContract.KEY_STATUS_TEXT,
+ bundle.putString(GosCompatContract.MapsScan.Key.STATUS_TEXT,
completed && workerThread ? "Completed" : "Failed");
- bundle.putString(GosCompatContract.KEY_SUMMARY,
+ bundle.putString(GosCompatContract.MapsScan.Key.SUMMARY,
completed && workerThread
? "Native maps scan completed from a worker thread."
: "Native maps scan did not complete from a worker thread.");
- bundle.putStringArrayList(GosCompatContract.KEY_MAPS_SCAN_DETAILS, new ArrayList<>());
- bundle.putStringArrayList(GosCompatContract.KEY_MAPS_SCAN_ERRORS, new ArrayList<>());
+ bundle.putStringArrayList(GosCompatContract.MapsScan.Key.DETAILS, new ArrayList<>());
+ bundle.putStringArrayList(GosCompatContract.MapsScan.Key.ERRORS, new ArrayList<>());
return bundle;
}
@@ -97,7 +97,7 @@ static Bundle clear(Context context) {
resultFile(context).delete();
Bundle bundle = new Bundle();
- bundle.putBoolean(GosCompatContract.KEY_MAPS_SCAN_RESULT_AVAILABLE, false);
+ bundle.putBoolean(GosCompatContract.MapsScan.Key.RESULT_AVAILABLE, false);
return bundle;
}
@@ -106,6 +106,6 @@ private static SharedPreferences prefs(Context context) {
}
private static File resultFile(Context context) {
- return new File(context.getFilesDir(), GosCompatContract.MAPS_SCAN_RESULT_FILE);
+ return new File(context.getFilesDir(), GosCompatContract.MapsScan.RESULT_FILE);
}
}
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanTombstoneReporter.java b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanTombstoneReporter.java
index 390ca4d44e6d8..9c43ec2d9cf2c 100644
--- a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanTombstoneReporter.java
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/MapsScanTombstoneReporter.java
@@ -76,7 +76,7 @@ static MapsScanResult getNativeCrashResult(Context context, long startTimeMillis
private static boolean isMatchingNativeCrash(ApplicationExitInfo exit, long startTimeMillis) {
return exit.getTimestamp() >= startTimeMillis
&& exit.getReason() == ApplicationExitInfo.REASON_CRASH_NATIVE
- && GosCompatContract.MAPS_SCAN_PROCESS.equals(exit.getProcessName());
+ && GosCompatContract.MapsScan.PROCESS.equals(exit.getProcessName());
}
private static String formatTombstone(ApplicationExitInfo exit) throws Exception {
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseActivity.kt b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseActivity.kt
new file mode 100644
index 0000000000000..f0801d2c292c9
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseActivity.kt
@@ -0,0 +1,74 @@
+package app.grapheneos.goscompat.checks.dmabuf
+
+import android.app.Activity
+import android.os.Bundle
+import android.os.SystemClock
+import app.grapheneos.goscompat.checks.GosCompatContract
+
+open class DmaBufReleaseActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val token = intent.getStringExtra(GosCompatContract.DmaBufRelease.Extra.TOKEN)
+ val mode = intent.getStringExtra(GosCompatContract.DmaBufRelease.Extra.MODE)
+ ?: GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT
+ val heapName = intent.getStringExtra(GosCompatContract.DmaBufRelease.Extra.HEAP_NAME)
+ val width = intent.getIntExtra(
+ GosCompatContract.DmaBufRelease.Extra.WIDTH,
+ GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_WIDTH,
+ )
+ val height = intent.getIntExtra(
+ GosCompatContract.DmaBufRelease.Extra.HEIGHT,
+ GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_HEIGHT,
+ )
+ val bufferCount = intent.getIntExtra(
+ GosCompatContract.DmaBufRelease.Extra.BUFFER_COUNT,
+ GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_COUNT,
+ )
+ val iterations = intent.getIntExtra(
+ GosCompatContract.DmaBufRelease.Extra.ITERATIONS,
+ GosCompatContract.DmaBufRelease.DEFAULT_ITERATIONS,
+ )
+ val releaseAfterReady = intent.getBooleanExtra(
+ GosCompatContract.DmaBufRelease.Extra.RELEASE_AFTER_READY,
+ false,
+ )
+
+ DmaBufReleaseProgressStore.clear(this)
+
+ Thread({
+ var result = DmaBufReleaseRunner.start(
+ mode = mode,
+ width = width,
+ height = height,
+ bufferCount = bufferCount,
+ iterations = iterations,
+ heapName = heapName,
+ )
+ if (result.ready && releaseAfterReady) {
+ result = try {
+ DmaBufReleaseRunner.release()
+ result.copy(released = true)
+ } catch (e: RuntimeException) {
+ result.copy(
+ ready = false,
+ error = "Manual DMA-BUF release failed: " +
+ "${e.javaClass.simpleName}: ${e.message}",
+ )
+ }
+ }
+ DmaBufReleaseProgressStore.save(applicationContext, token, result)
+ runOnUiThread { finish() }
+ if (!result.ready || releaseAfterReady) {
+ return@Thread
+ }
+
+ // Keep this process and its native buffer references live until shell stops it.
+ while (true) {
+ SystemClock.sleep(1_000)
+ }
+ }, "dmabuf-release-workload").start()
+ }
+}
+
+class DmaBufReleaseManualActivity : DmaBufReleaseActivity()
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleasePanel.kt b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleasePanel.kt
new file mode 100644
index 0000000000000..881ef35007853
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleasePanel.kt
@@ -0,0 +1,365 @@
+package app.grapheneos.goscompat.checks.dmabuf
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Looper
+import android.os.SystemClock
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import app.grapheneos.goscompat.checks.GosCompatContract
+import app.grapheneos.goscompat.checks.ResultSection
+import java.util.UUID
+import java.util.concurrent.CountDownLatch
+
+@Composable
+internal fun DmaBufReleasePanel(activity: Activity) {
+ val runs = remember {
+ DMABUF_UI_OPTIONS.map { option ->
+ DmaBufUiRun(
+ option = option,
+ result = mutableStateOf(null),
+ running = mutableStateOf(false),
+ )
+ }
+ }
+ val allRunning = remember { mutableStateOf(false) }
+
+ DmaBufReleaseCard(
+ runs = runs,
+ allRunning = allRunning.value,
+ onRun = { run -> startDmaBufRelease(activity, run) },
+ onRunAll = {
+ startAllDmaBufRelease(
+ activity = activity,
+ runs = runs,
+ allRunning = allRunning,
+ )
+ },
+ )
+}
+
+private data class DmaBufUiOption(
+ val title: String,
+ val detailsTitle: String,
+ val mode: String,
+ val heapName: String?,
+ val width: Int,
+ val height: Int,
+ val bufferCount: Int,
+)
+
+private data class DmaBufUiRun(
+ val option: DmaBufUiOption,
+ val result: MutableState,
+ val running: MutableState,
+)
+
+private val DMABUF_UI_OPTIONS = listOf(
+ DmaBufUiOption(
+ title = "Direct vframe-secure multi chunk",
+ detailsTitle = "Direct vframe-secure multi chunk details",
+ mode = GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ heapName = GosCompatContract.DmaBufRelease.Heap.VFRAME_SECURE,
+ width = GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_WIDTH,
+ height = GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_HEIGHT,
+ bufferCount = GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_COUNT,
+ ),
+ DmaBufUiOption(
+ title = "Direct vstream-secure multi chunk",
+ detailsTitle = "Direct vstream-secure multi chunk details",
+ mode = GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ heapName = GosCompatContract.DmaBufRelease.Heap.VSTREAM_SECURE,
+ width = GosCompatContract.DmaBufRelease.VSTREAM_SECURE_DIRECT_WIDTH,
+ height = GosCompatContract.DmaBufRelease.VSTREAM_SECURE_DIRECT_HEIGHT,
+ bufferCount = GosCompatContract.DmaBufRelease.VSTREAM_SECURE_DIRECT_COUNT,
+ ),
+ DmaBufUiOption(
+ title = "Direct vframe-secure one chunk",
+ detailsTitle = "Direct vframe-secure one chunk details",
+ mode = GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ heapName = GosCompatContract.DmaBufRelease.Heap.VFRAME_SECURE,
+ width = GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_WIDTH,
+ height = GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_HEIGHT,
+ bufferCount = GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_COUNT,
+ ),
+ DmaBufUiOption(
+ title = "Direct vstream-secure one chunk",
+ detailsTitle = "Direct vstream-secure one chunk details",
+ mode = GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ heapName = GosCompatContract.DmaBufRelease.Heap.VSTREAM_SECURE,
+ width = GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_WIDTH,
+ height = GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_HEIGHT,
+ bufferCount = GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_COUNT,
+ ),
+ DmaBufUiOption(
+ title = "Protected EGL resources",
+ detailsTitle = "Protected EGL details",
+ mode = GosCompatContract.DmaBufRelease.Mode.PROTECTED_EGL,
+ heapName = null,
+ width = GosCompatContract.DmaBufRelease.PROTECTED_EGL_WIDTH,
+ height = GosCompatContract.DmaBufRelease.PROTECTED_EGL_HEIGHT,
+ bufferCount = GosCompatContract.DmaBufRelease.PROTECTED_EGL_RESOURCE_COUNT,
+ ),
+)
+
+private fun startDmaBufRelease(
+ activity: Activity,
+ run: DmaBufUiRun,
+) {
+ val result = run.result
+ val running = run.running
+ if (running.value) {
+ return
+ }
+ running.value = true
+ result.value = null
+
+ Thread {
+ val checkResult = runDmaBufReleaseWorkload(activity, run.option)
+ activity.runOnUiThread {
+ result.value = checkResult
+ running.value = false
+ }
+ }.start()
+}
+
+private fun startAllDmaBufRelease(
+ activity: Activity,
+ runs: List,
+ allRunning: MutableState,
+) {
+ if (allRunning.value || runs.any { it.running.value }) {
+ return
+ }
+ allRunning.value = true
+
+ Thread {
+ try {
+ runs.forEach { run ->
+ activity.runOnUiThread {
+ run.result.value = null
+ run.running.value = true
+ }
+ val checkResult = runDmaBufReleaseWorkload(
+ activity = activity,
+ option = run.option,
+ )
+ activity.runOnUiThread {
+ run.result.value = checkResult
+ run.running.value = false
+ }
+ }
+ } finally {
+ activity.runOnUiThread {
+ runs.forEach { it.running.value = false }
+ allRunning.value = false
+ }
+ }
+ }.start()
+}
+
+private fun runDmaBufReleaseWorkload(
+ activity: Activity,
+ option: DmaBufUiOption,
+): DmaBufReleaseResult {
+ val width = option.width
+ val height = option.height
+ val bufferCount = option.bufferCount
+ val iterations = GosCompatContract.DmaBufRelease.DEFAULT_ITERATIONS
+ val token = UUID.randomUUID().toString()
+
+ return try {
+ activity.runOnUiThreadBlocking {
+ DmaBufReleaseProgressStore.clear(activity)
+ val intent = Intent(activity, DmaBufReleaseManualActivity::class.java)
+ .putExtra(GosCompatContract.DmaBufRelease.Extra.TOKEN, token)
+ .putExtra(GosCompatContract.DmaBufRelease.Extra.MODE, option.mode)
+ .putExtra(GosCompatContract.DmaBufRelease.Extra.WIDTH, width)
+ .putExtra(GosCompatContract.DmaBufRelease.Extra.HEIGHT, height)
+ .putExtra(GosCompatContract.DmaBufRelease.Extra.BUFFER_COUNT, bufferCount)
+ .putExtra(GosCompatContract.DmaBufRelease.Extra.ITERATIONS, iterations)
+ .putExtra(
+ GosCompatContract.DmaBufRelease.Extra.RELEASE_AFTER_READY,
+ true,
+ )
+ if (option.heapName != null) {
+ intent.putExtra(
+ GosCompatContract.DmaBufRelease.Extra.HEAP_NAME,
+ option.heapName,
+ )
+ }
+ activity.startActivity(intent)
+ }
+
+ val deadline = SystemClock.elapsedRealtime() + DMABUF_UI_RESULT_TIMEOUT_MILLIS
+ var latest: DmaBufReleaseResult? = null
+ while (SystemClock.elapsedRealtime() < deadline) {
+ latest = DmaBufReleaseProgressStore.load(activity, token)
+ if (latest != null) {
+ break
+ }
+ SystemClock.sleep(DMABUF_UI_POLL_INTERVAL_MILLIS)
+ }
+
+ latest ?: DmaBufReleaseResult.failed(
+ mode = option.mode,
+ width = width,
+ height = height,
+ requestedBuffers = bufferCount,
+ iterations = iterations,
+ error = "Timed out waiting for DMA-BUF workload readiness",
+ )
+ } catch (e: Exception) {
+ DmaBufReleaseResult.failedFromThrowable(
+ mode = option.mode,
+ width = width,
+ height = height,
+ requestedBuffers = bufferCount,
+ iterations = iterations,
+ throwable = e,
+ errorPrefix = "Failed to start DMA-BUF workload",
+ )
+ }
+}
+
+private fun Activity.runOnUiThreadBlocking(action: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ action()
+ return
+ }
+
+ val latch = CountDownLatch(1)
+ var failure: Throwable? = null
+ runOnUiThread {
+ try {
+ action()
+ } catch (e: Throwable) {
+ failure = e
+ } finally {
+ latch.countDown()
+ }
+ }
+ latch.await()
+ failure?.let { throw it }
+}
+
+@Composable
+private fun DmaBufReleaseCard(
+ runs: List,
+ allRunning: Boolean,
+ onRun: (DmaBufUiRun) -> Unit,
+ onRunAll: () -> Unit,
+) {
+ val anyRunning = allRunning || runs.any { it.running.value }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text(
+ text = "DMA-BUF release",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Button(onClick = onRunAll, enabled = !anyRunning) {
+ Text("Run all")
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = if (allRunning) "Running all options" else "Ready",
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ runs.forEach { run ->
+ val result = run.result.value
+ val running = run.running.value
+ DmaBufReleaseControl(
+ title = run.option.title,
+ result = result,
+ running = running,
+ enabled = !allRunning && !running,
+ onRun = { onRun(run) },
+ )
+ ResultSection(run.option.detailsTitle, result?.details.orEmpty())
+ }
+ }
+ }
+}
+
+@Composable
+private fun DmaBufReleaseControl(
+ title: String,
+ result: DmaBufReleaseResult?,
+ running: Boolean,
+ enabled: Boolean,
+ onRun: () -> Unit,
+) {
+ val status = when {
+ running -> "Running"
+ result != null -> result.statusText
+ else -> "Not run"
+ }
+ val statusColor = when {
+ running -> MaterialTheme.colorScheme.tertiary
+ result?.ready == true -> MaterialTheme.colorScheme.primary
+ result != null -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = status,
+ modifier = Modifier
+ .background(statusColor.copy(alpha = 0.12f), RoundedCornerShape(6.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ color = statusColor,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Button(onClick = onRun, enabled = enabled) {
+ Text("Run")
+ }
+ }
+
+ Text(
+ text = result?.summary ?: "DMA-BUF workload has not been run.",
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+}
+
+private const val DMABUF_UI_RESULT_TIMEOUT_MILLIS = 15_000L
+private const val DMABUF_UI_POLL_INTERVAL_MILLIS = 200L
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseProgressStore.kt b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseProgressStore.kt
new file mode 100644
index 0000000000000..f33ceadaecb7d
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseProgressStore.kt
@@ -0,0 +1,60 @@
+package app.grapheneos.goscompat.checks.dmabuf
+
+import android.content.Context
+import app.grapheneos.goscompat.checks.GosCompatContract
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.Properties
+
+object DmaBufReleaseProgressStore {
+ fun save(context: Context, token: String?, result: DmaBufReleaseResult) {
+ if (token.isNullOrEmpty()) {
+ return
+ }
+
+ try {
+ FileOutputStream(resultFile(context)).use { output ->
+ result.toProperties(token).store(output, null)
+ }
+ } catch (_: IOException) {
+ }
+ }
+
+ fun load(context: Context, token: String): DmaBufReleaseResult? {
+ val properties = loadProperties(context) ?: return null
+ if (properties.getProperty(GosCompatContract.DmaBufRelease.Extra.TOKEN) != token) {
+ return null
+ }
+ if (!properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.RESULT_AVAILABLE,
+ ).toBoolean()
+ ) {
+ return null
+ }
+ return DmaBufReleaseResult.fromProperties(properties)
+ }
+
+ fun clear(context: Context) {
+ resultFile(context).delete()
+ }
+
+ private fun loadProperties(context: Context): Properties? {
+ val file = resultFile(context)
+ if (!file.exists()) {
+ return null
+ }
+
+ return try {
+ Properties().also { properties ->
+ FileInputStream(file).use { input -> properties.load(input) }
+ }
+ } catch (_: IOException) {
+ null
+ }
+ }
+
+ private fun resultFile(context: Context): File =
+ File(context.filesDir, GosCompatContract.DmaBufRelease.RESULT_FILE)
+}
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseResult.kt b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseResult.kt
new file mode 100644
index 0000000000000..51a47e961b47c
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseResult.kt
@@ -0,0 +1,266 @@
+package app.grapheneos.goscompat.checks.dmabuf
+
+import app.grapheneos.goscompat.checks.GosCompatContract
+import java.util.Properties
+
+data class DmaBufReleaseResult(
+ val mode: String,
+ val ready: Boolean,
+ val unsupported: Boolean,
+ val protectedContent: Boolean,
+ val width: Int,
+ val height: Int,
+ val requestedBuffers: Int,
+ val allocatedBuffers: Int,
+ val iterations: Int,
+ val pid: Int,
+ val tid: Int,
+ val heapPath: String,
+ val heapName: String,
+ val allocator: String,
+ val allocation: String,
+ val error: String,
+ val released: Boolean = false,
+) {
+ val statusText: String
+ get() = when {
+ ready && released -> "Released"
+ ready -> "Ready"
+ unsupported -> "Unsupported"
+ else -> "Failed"
+ }
+
+ val summary: String
+ get() = when {
+ mode == GosCompatContract.DmaBufRelease.Mode.PROTECTED_EGL && ready && released ->
+ "Created and released protected EGL resources."
+ mode == GosCompatContract.DmaBufRelease.Mode.PROTECTED_EGL && ready ->
+ "Created protected EGL resources."
+ ready && released ->
+ "Allocated and released $allocatedBuffers direct secure chunk heap DMA-BUF " +
+ "file descriptor."
+ ready ->
+ "Allocated $allocatedBuffers direct secure chunk heap DMA-BUF file descriptor."
+ unsupported -> "Unsupported: $error"
+ error.isNotEmpty() -> "Failed: $error"
+ else -> "DMA-BUF workload failed."
+ }
+
+ val details: List
+ get() = buildList {
+ add("mode=$mode")
+ add("pid=$pid, tid=$tid")
+ add("size=${width}x$height")
+ if (mode == GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT) {
+ add("fds=$allocatedBuffers/$requestedBuffers")
+ } else {
+ add("resources=$allocatedBuffers")
+ }
+ add("iterations=$iterations")
+ add("protectedContent=$protectedContent")
+ add("released=$released")
+ add("heap=$heapPath")
+ add("heapName=$heapName")
+ add("allocator=$allocator")
+ add("allocation=$allocation")
+ if (error.isNotEmpty()) {
+ add("error=$error")
+ }
+ }
+
+ fun toProperties(token: String): Properties = Properties().apply {
+ setProperty(GosCompatContract.DmaBufRelease.Extra.TOKEN, token)
+ setProperty(GosCompatContract.DmaBufRelease.Key.RESULT_AVAILABLE, "true")
+ setProperty(GosCompatContract.DmaBufRelease.Key.READY, ready.toString())
+ setProperty(GosCompatContract.DmaBufRelease.Key.UNSUPPORTED, unsupported.toString())
+ setProperty(GosCompatContract.DmaBufRelease.Key.MODE, mode)
+ setProperty(GosCompatContract.DmaBufRelease.Key.WIDTH, width.toString())
+ setProperty(GosCompatContract.DmaBufRelease.Key.HEIGHT, height.toString())
+ setProperty(
+ GosCompatContract.DmaBufRelease.Key.REQUESTED_BUFFERS,
+ requestedBuffers.toString(),
+ )
+ setProperty(
+ GosCompatContract.DmaBufRelease.Key.ALLOCATED_BUFFERS,
+ allocatedBuffers.toString(),
+ )
+ setProperty(GosCompatContract.DmaBufRelease.Key.ITERATIONS, iterations.toString())
+ setProperty(GosCompatContract.DmaBufRelease.Key.PID, pid.toString())
+ setProperty(GosCompatContract.DmaBufRelease.Key.TID, tid.toString())
+ setProperty(
+ GosCompatContract.DmaBufRelease.Key.PROTECTED_CONTENT,
+ protectedContent.toString(),
+ )
+ setProperty(GosCompatContract.DmaBufRelease.Key.RELEASED, released.toString())
+ setProperty(GosCompatContract.DmaBufRelease.Key.HEAP_PATH, heapPath)
+ setProperty(GosCompatContract.DmaBufRelease.Key.HEAP_NAME, heapName)
+ setProperty(GosCompatContract.DmaBufRelease.Key.ALLOCATOR, allocator)
+ setProperty(GosCompatContract.DmaBufRelease.Key.ALLOCATION, allocation)
+ setProperty(GosCompatContract.DmaBufRelease.Key.ERROR, error)
+ }
+
+ companion object {
+ private const val FIELD_READY = 0
+ private const val FIELD_UNSUPPORTED = 1
+ private const val FIELD_PROTECTED_CONTENT = 2
+ private const val FIELD_ALLOCATED_BUFFERS = 3
+ private const val FIELD_PID = 4
+ private const val FIELD_TID = 5
+ private const val FIELD_HEAP_PATH = 6
+ private const val FIELD_HEAP_NAME = 7
+ private const val FIELD_ALLOCATOR = 8
+ private const val FIELD_ALLOCATION = 9
+ private const val FIELD_ERROR = 10
+ private const val FIELD_COUNT = 11
+
+ fun fromNativeFields(
+ mode: String,
+ width: Int,
+ height: Int,
+ requestedBuffers: Int,
+ iterations: Int,
+ fields: Array?,
+ ): DmaBufReleaseResult {
+ if (fields == null || fields.size != FIELD_COUNT) {
+ return failed(
+ mode = mode,
+ width = width,
+ height = height,
+ requestedBuffers = requestedBuffers,
+ iterations = iterations,
+ error = "Native DMA-BUF workload returned an invalid result",
+ )
+ }
+
+ return DmaBufReleaseResult(
+ mode = mode,
+ ready = fields[FIELD_READY].toBoolean(),
+ unsupported = fields[FIELD_UNSUPPORTED].toBoolean(),
+ protectedContent = fields[FIELD_PROTECTED_CONTENT].toBoolean(),
+ width = width,
+ height = height,
+ requestedBuffers = requestedBuffers,
+ allocatedBuffers = fields[FIELD_ALLOCATED_BUFFERS].toIntOrZero(),
+ iterations = iterations,
+ pid = fields[FIELD_PID].toIntOrZero(),
+ tid = fields[FIELD_TID].toIntOrZero(),
+ heapPath = fields[FIELD_HEAP_PATH],
+ heapName = fields[FIELD_HEAP_NAME],
+ allocator = fields[FIELD_ALLOCATOR],
+ allocation = fields[FIELD_ALLOCATION],
+ error = fields[FIELD_ERROR],
+ )
+ }
+
+ fun fromProperties(properties: Properties): DmaBufReleaseResult = DmaBufReleaseResult(
+ mode = properties.getProperty(GosCompatContract.DmaBufRelease.Key.MODE, ""),
+ ready = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.READY,
+ "",
+ ).toBoolean(),
+ unsupported = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.UNSUPPORTED,
+ "",
+ ).toBoolean(),
+ protectedContent = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.PROTECTED_CONTENT,
+ "",
+ ).toBoolean(),
+ width = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.WIDTH,
+ ).toIntOrZero(),
+ height = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.HEIGHT,
+ ).toIntOrZero(),
+ requestedBuffers = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.REQUESTED_BUFFERS,
+ ).toIntOrZero(),
+ allocatedBuffers = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.ALLOCATED_BUFFERS,
+ ).toIntOrZero(),
+ iterations = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.ITERATIONS,
+ ).toIntOrZero(),
+ pid = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.PID,
+ ).toIntOrZero(),
+ tid = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.TID,
+ ).toIntOrZero(),
+ heapPath = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.HEAP_PATH,
+ "",
+ ),
+ heapName = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.HEAP_NAME,
+ "",
+ ),
+ allocator = properties.getProperty(GosCompatContract.DmaBufRelease.Key.ALLOCATOR, ""),
+ allocation = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.ALLOCATION,
+ "",
+ ),
+ error = properties.getProperty(GosCompatContract.DmaBufRelease.Key.ERROR, ""),
+ released = properties.getProperty(
+ GosCompatContract.DmaBufRelease.Key.RELEASED,
+ "",
+ ).toBoolean(),
+ )
+
+ fun failed(
+ mode: String,
+ width: Int,
+ height: Int,
+ requestedBuffers: Int,
+ iterations: Int,
+ error: String,
+ ): DmaBufReleaseResult = DmaBufReleaseResult(
+ mode = mode,
+ ready = false,
+ unsupported = false,
+ protectedContent = false,
+ width = width,
+ height = height,
+ requestedBuffers = requestedBuffers,
+ allocatedBuffers = 0,
+ iterations = iterations,
+ pid = 0,
+ tid = 0,
+ heapPath = "",
+ heapName = "",
+ allocator = "",
+ allocation = "",
+ error = error,
+ )
+
+ fun failedFromThrowable(
+ mode: String,
+ width: Int,
+ height: Int,
+ requestedBuffers: Int,
+ iterations: Int,
+ throwable: Throwable,
+ errorPrefix: String = "",
+ ): DmaBufReleaseResult = failed(
+ mode = mode,
+ width = width,
+ height = height,
+ requestedBuffers = requestedBuffers,
+ iterations = iterations,
+ error = throwableError(errorPrefix, throwable),
+ )
+
+ private fun throwableError(errorPrefix: String, throwable: Throwable): String =
+ buildString {
+ if (errorPrefix.isNotEmpty()) {
+ append(errorPrefix)
+ append(": ")
+ }
+ append(throwable.javaClass.simpleName)
+ append(": ")
+ append(throwable.message)
+ }
+
+ private fun String?.toIntOrZero(): Int = this?.toIntOrNull() ?: 0
+ }
+}
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseRunner.kt b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseRunner.kt
new file mode 100644
index 0000000000000..7ad353a93b44a
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/dmabuf/DmaBufReleaseRunner.kt
@@ -0,0 +1,87 @@
+package app.grapheneos.goscompat.checks.dmabuf
+
+import app.grapheneos.goscompat.checks.GosCompatContract
+
+object DmaBufReleaseRunner {
+ private var nativeLoaded = false
+
+ @Synchronized
+ fun start(
+ mode: String,
+ width: Int,
+ height: Int,
+ bufferCount: Int,
+ iterations: Int,
+ heapName: String?,
+ ): DmaBufReleaseResult {
+ release()
+
+ return try {
+ ensureNativeLoaded()
+ val fields = when (mode) {
+ GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT ->
+ nativeStartWorkload(
+ heapName,
+ width,
+ height,
+ bufferCount,
+ )
+ GosCompatContract.DmaBufRelease.Mode.PROTECTED_EGL ->
+ nativeStartProtectedEglWorkload(
+ width,
+ height,
+ iterations,
+ )
+ else -> return DmaBufReleaseResult.failed(
+ mode = mode,
+ width = width,
+ height = height,
+ requestedBuffers = bufferCount,
+ iterations = iterations,
+ error = "Unsupported DMA-BUF workload mode: $mode",
+ )
+ }
+ DmaBufReleaseResult.fromNativeFields(
+ mode = mode,
+ width = width,
+ height = height,
+ requestedBuffers = bufferCount,
+ iterations = iterations,
+ fields = fields,
+ )
+ } catch (e: RuntimeException) {
+ DmaBufReleaseResult.failedFromThrowable(mode, width, height, bufferCount, iterations, e)
+ } catch (e: UnsatisfiedLinkError) {
+ DmaBufReleaseResult.failedFromThrowable(mode, width, height, bufferCount, iterations, e)
+ }
+ }
+
+ @Synchronized
+ fun release() {
+ if (nativeLoaded) {
+ nativeReleaseWorkload()
+ }
+ }
+
+ private fun ensureNativeLoaded() {
+ if (!nativeLoaded) {
+ System.loadLibrary("goscompat_dmabuf_release_jni")
+ nativeLoaded = true
+ }
+ }
+
+ private external fun nativeStartWorkload(
+ heapName: String?,
+ width: Int,
+ height: Int,
+ bufferCount: Int,
+ ): Array
+
+ private external fun nativeStartProtectedEglWorkload(
+ width: Int,
+ height: Int,
+ iterations: Int,
+ ): Array
+
+ private external fun nativeReleaseWorkload()
+}
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/webviewua/WebViewUaStartupActivity.java b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/webviewua/WebViewUaStartupActivity.java
new file mode 100644
index 0000000000000..9af1037d0b715
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/webviewua/WebViewUaStartupActivity.java
@@ -0,0 +1,121 @@
+package app.grapheneos.goscompat.checks.webviewua;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.os.Looper;
+import android.os.Process;
+import android.os.Handler;
+import android.os.SystemClock;
+import android.webkit.WebSettings;
+
+import app.grapheneos.goscompat.checks.GosCompatContract;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+public final class WebViewUaStartupActivity extends Activity {
+ private static final Object STARTUP_LOCK = new Object();
+ private static final long UI_RUNNABLE_START_TIMEOUT_MILLIS = 5_000;
+ private static final long EXIT_PROCESS_DELAY_MILLIS = 250;
+
+ private final AtomicBoolean mResultSaved = new AtomicBoolean();
+ private boolean mExitProcessAfterResult;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ String token = getIntent().getStringExtra(
+ GosCompatContract.WebViewUaStartup.Extra.TOKEN);
+ mExitProcessAfterResult = getIntent().getBooleanExtra(
+ GosCompatContract.WebViewUaStartup.Extra.EXIT_PROCESS, false);
+ WebViewUaStartupResultStore.clear(this);
+
+ Thread worker = new Thread(() -> runLockInversionScenario(token),
+ "webview-ua-startup-worker");
+ worker.start();
+ }
+
+ private void runLockInversionScenario(String token) {
+ final int workerTid = Process.myTid();
+ final AtomicInteger uiTid = new AtomicInteger();
+ final AtomicLong durationMillis = new AtomicLong(-1L);
+ final AtomicInteger userAgentLength = new AtomicInteger();
+ final AtomicBoolean workerCompleted = new AtomicBoolean();
+ final AtomicReference error = new AtomicReference<>();
+ final CountDownLatch uiRunnableStarted = new CountDownLatch(1);
+
+ try {
+ synchronized (STARTUP_LOCK) {
+ runOnUiThread(() -> {
+ int currentUiTid = Process.myTid();
+ uiTid.set(currentUiTid);
+ uiRunnableStarted.countDown();
+
+ synchronized (STARTUP_LOCK) {
+ boolean completed = workerCompleted.get();
+ String failure = completed ? null : error.get();
+ if (failure == null && !completed) {
+ failure = "Worker released lock before getDefaultUserAgent completed";
+ }
+ saveResult(token, completed, workerTid != currentUiTid, true,
+ Looper.myLooper() == Looper.getMainLooper(), workerTid,
+ currentUiTid, durationMillis.get(), userAgentLength.get(),
+ failure);
+ finish();
+ }
+ });
+
+ if (!uiRunnableStarted.await(UI_RUNNABLE_START_TIMEOUT_MILLIS,
+ TimeUnit.MILLISECONDS)) {
+ saveResult(token, false, false, false, false, workerTid, 0,
+ durationMillis.get(), userAgentLength.get(),
+ "UI runnable did not start while worker held lock");
+ runOnUiThread(this::finish);
+ return;
+ }
+
+ long startMillis = SystemClock.elapsedRealtime();
+ String userAgent = WebSettings.getDefaultUserAgent(getApplicationContext());
+ durationMillis.set(SystemClock.elapsedRealtime() - startMillis);
+ userAgentLength.set(userAgent != null ? userAgent.length() : 0);
+ if (userAgent == null || userAgent.isEmpty()) {
+ error.set("Default user agent was empty");
+ } else {
+ workerCompleted.set(true);
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ saveResult(token, false, true, uiTid.get() > 0, false, workerTid, uiTid.get(),
+ durationMillis.get(), userAgentLength.get(),
+ "Interrupted while waiting for UI runnable");
+ runOnUiThread(this::finish);
+ } catch (RuntimeException e) {
+ saveResult(token, false, true, uiTid.get() > 0, false, workerTid, uiTid.get(),
+ durationMillis.get(), userAgentLength.get(),
+ e.getClass().getSimpleName() + ": " + e.getMessage());
+ runOnUiThread(this::finish);
+ }
+ }
+
+ private void saveResult(String token, boolean completed, boolean workerThread,
+ boolean uiThread, boolean mainLooper, int workerTid, int uiTid, long durationMillis,
+ int userAgentLength, String error) {
+ if (!mResultSaved.compareAndSet(false, true)) {
+ return;
+ }
+ WebViewUaStartupResultStore.save(getApplicationContext(), token, completed, workerThread,
+ uiThread, mainLooper, workerTid, uiTid, durationMillis, userAgentLength, error);
+ if (mExitProcessAfterResult) {
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ finish();
+ Process.killProcess(Process.myPid());
+ }, EXIT_PROCESS_DELAY_MILLIS);
+ }
+ }
+}
diff --git a/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/webviewua/WebViewUaStartupResultStore.java b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/webviewua/WebViewUaStartupResultStore.java
new file mode 100644
index 0000000000000..d29c11f16db0d
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatCheckApp/src/app/grapheneos/goscompat/checks/webviewua/WebViewUaStartupResultStore.java
@@ -0,0 +1,60 @@
+package app.grapheneos.goscompat.checks.webviewua;
+
+import android.content.Context;
+
+import app.grapheneos.goscompat.checks.GosCompatContract;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Properties;
+
+final class WebViewUaStartupResultStore {
+ private WebViewUaStartupResultStore() {
+ }
+
+ static void save(Context context, String token, boolean completed, boolean workerThread,
+ boolean uiThread, boolean mainLooper, int workerTid, int uiTid, long durationMillis,
+ int userAgentLength, String error) {
+ if (token == null || token.isEmpty()) {
+ return;
+ }
+
+ Properties properties = new Properties();
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Extra.TOKEN, token);
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.RESULT_AVAILABLE,
+ Boolean.toString(true));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.COMPLETED,
+ Boolean.toString(completed));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.WORKER_THREAD,
+ Boolean.toString(workerThread));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.UI_THREAD,
+ Boolean.toString(uiThread));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.MAIN_LOOPER,
+ Boolean.toString(mainLooper));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.WORKER_TID,
+ Integer.toString(workerTid));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.UI_TID,
+ Integer.toString(uiTid));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.DURATION_MS,
+ Long.toString(durationMillis));
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.USER_AGENT_LENGTH,
+ Integer.toString(userAgentLength));
+ if (error != null && !error.isEmpty()) {
+ properties.setProperty(GosCompatContract.WebViewUaStartup.Key.ERROR, error);
+ }
+
+ try (FileOutputStream output = new FileOutputStream(resultFile(context))) {
+ properties.store(output, null);
+ } catch (IOException ignored) {
+ }
+ }
+
+ static void clear(Context context) {
+ resultFile(context).delete();
+ }
+
+ private static File resultFile(Context context) {
+ return new File(context.getFilesDir(), GosCompatContract.WebViewUaStartup.RESULT_FILE);
+ }
+}
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/Android.bp b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/Android.bp
new file mode 100644
index 0000000000000..fc65da028084c
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/Android.bp
@@ -0,0 +1,22 @@
+android_test {
+ name: "GosCompatDmaBufReleaseTests",
+ srcs: [
+ "src/**/*.java",
+ ],
+ manifest: "AndroidManifest.xml",
+ test_config: "AndroidTest.xml",
+ static_libs: [
+ "GosCompatCheckCommon",
+ "androidx.test.ext.junit",
+ "androidx.test.runner",
+ "androidx.test.uiautomator_uiautomator",
+ "truth",
+ ],
+ libs: ["android.test.runner.stubs"],
+ data: [
+ ":GosCompatCheckApp",
+ ],
+ platform_apis: true,
+ certificate: "platform",
+ test_suites: ["device-tests"],
+}
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/AndroidManifest.xml b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/AndroidManifest.xml
new file mode 100644
index 0000000000000..5d64b1c3c1816
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/AndroidTest.xml b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/AndroidTest.xml
new file mode 100644
index 0000000000000..775dbe158fab6
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/AndroidTest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/README.md b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/README.md
new file mode 100644
index 0000000000000..13dcaf24e7edb
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/README.md
@@ -0,0 +1,95 @@
+# GosCompatDmaBufReleaseTests
+
+This module is a targeted regression test for a Pixel kernel memory error (KASAN incompatability
+from rebuilding pointers without the original tags) in the Samsung DMA-BUF secure chunk heap release
+path.
+
+The tests will panic on a KASAN kernel without the patch for the memory error. Note that these tests
+are designed to be non-priv apps.
+
+## Access rules
+
+The helper app intentionally opens the heap devices directly. This is allowed by the device policy
+and file modes:
+
+- `vendor/etc/ueventd.rc` sets both heap devices to mode `0444`.
+- Vendor SEPolicy labels both devices as `dmabuf_system_secure_heap_device`.
+- `system/sepolicy/private/app.te` allows non-isolated app domains to read and open
+ `dmabuf_system_secure_heap_device` character devices.
+
+The native helper only accepts the allowlisted heap names above. It does not use the intent extra as
+an arbitrary file path.
+
+## Relationship to existing AOSP tests
+
+The native helper follows the same basic allocation and release pattern as the existing DMA-BUF heap
+unit tests:
+
+- `system/memory/libdmabufheap/tests/dmabuf_heap_test.cpp`: `DmaBufHeapTest.Allocate` allocates
+ DMA-BUF fds and closes them to release the buffers.
+- `system/memory/libdmabufheap/tests/dmabuf_heap_test.cpp`: `DmaBufHeapTest.RepeatedAllocate`
+ repeats the same allocate and close path.
+- `system/memory/libdmabufheap/tests/dmabuf_heap_test.c`: `libdmabufheaptest()` exercises the C
+ wrapper around the same libdmabufheap allocation flow.
+
+Note that these tests did not panic. In general, CTS tests in AOSP don't exercise these
+Pixel-specific paths or they don't test process teardown.
+
+This helper intentionally does not link `libdmabufheap`, because it is built inside the SDK-built
+helper APK and needs to target Pixel secure heap device names directly. Instead, it opens
+`/dev/dma_heap/vframe-secure` or `/dev/dma_heap/vstream-secure` and issues `DMA_HEAP_IOCTL_ALLOC`,
+then either closes the returned fd during manual release or leaves it open until process teardown
+closes it.
+
+The protected EGL tests use the public EGL/GLES shape that is closest to the GPU driver usage:
+`EGL_EXT_protected_content` on a protected context and pbuffer surface, plus
+`GL_EXT_protected_textures` for protected texture storage. The tests cover explicit release, normal
+app stop, and force stop because different devices can release the underlying DMA-BUF from different
+threads. For example, shiba reproduced the panic from the Mali memory purge thread during explicit
+protected EGL resource release.
+
+`DmaBufReleaseSequenceTests` repeats the same manual release sequence as the standalone UI's Run all
+button in one helper process. This catches timing-sensitive cleanup paths better than the isolated
+per-workload tests because shiba needed several Run all attempts before reproducing the panic.
+
+## Useful atest commands
+
+Run the whole module:
+
+```sh
+atest GosCompatDmaBufReleaseTests
+```
+
+Run the protected EGL cases individually:
+
+```sh
+ATEST_MODULE=GosCompatDmaBufReleaseTests
+TEST_PKG=app.grapheneos.goscompat.dmabufrelease.tests
+
+atest "${ATEST_MODULE}:${TEST_PKG}.ProtectedEglReleaseTests#\
+protectedEglResourcesCanBeManuallyReleasedAfterReady"
+atest "${ATEST_MODULE}:${TEST_PKG}.ProtectedEglReleaseTests#\
+protectedEglResourcesCanBeStoppedAfterReady"
+atest "${ATEST_MODULE}:${TEST_PKG}.ProtectedEglReleaseTests#\
+protectedEglResourcesCanBeForceStoppedAfterReady"
+```
+
+Run the direct secure chunk heap parameterized cases:
+
+```sh
+ATEST_MODULE=GosCompatDmaBufReleaseTests
+TEST_PKG=app.grapheneos.goscompat.dmabufrelease.tests
+
+atest "${ATEST_MODULE}:${TEST_PKG}.SecureChunkHeapReleaseTests#\
+secureChunkHeapBufferCanBeReleasedAfterReady"
+```
+
+Run the repeated UI-shaped manual release sequence:
+
+```sh
+ATEST_MODULE=GosCompatDmaBufReleaseTests
+TEST_PKG=app.grapheneos.goscompat.dmabufrelease.tests
+
+atest "${ATEST_MODULE}:${TEST_PKG}.DmaBufReleaseSequenceTests#\
+manualReleaseRunAllSequenceCanBeRepeatedInOneProcess"
+```
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/DmaBufReleaseSequenceTests.java b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/DmaBufReleaseSequenceTests.java
new file mode 100644
index 0000000000000..b8cddfed5ca35
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/DmaBufReleaseSequenceTests.java
@@ -0,0 +1,64 @@
+package app.grapheneos.goscompat.dmabufrelease.tests;
+
+import android.app.Instrumentation;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.uiautomator.UiDevice;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Certain devices like shiba can't reproduce the panic immediately due to different GPU memory
+ * management models. Multiple runs are required for the panic to trigger from shiba's
+ * mali-mem-purge task
+ */
+public final class DmaBufReleaseSequenceTests {
+ private static final int REPEAT_COUNT = 8;
+ private static final ReleaseTestSupport.Workload[] RUN_ALL_SEQUENCE = {
+ ReleaseTestSupport.Workload.directVframeSecureMultiChunk(),
+ ReleaseTestSupport.Workload.directVstreamSecureMultiChunk(),
+ ReleaseTestSupport.Workload.directVframeSecureOneChunk(),
+ ReleaseTestSupport.Workload.directVstreamSecureOneChunk(),
+ ReleaseTestSupport.Workload.protectedEgl(),
+ };
+
+ private ReleaseTestSupport mSupport;
+
+ @Before
+ public void setUp() throws Exception {
+ Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
+ UiDevice device = UiDevice.getInstance(instrumentation);
+ mSupport = new ReleaseTestSupport(device);
+ mSupport.forceStopHelperApp();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (mSupport != null) {
+ mSupport.forceStopHelperApp();
+ }
+ }
+
+ @Test
+ public void manualReleaseRunAllSequenceCanBeRepeatedInOneProcess() throws Exception {
+ for (int pass = 0; pass < REPEAT_COUNT; pass++) {
+ for (ReleaseTestSupport.Workload workload : RUN_ALL_SEQUENCE) {
+ try {
+ ReleaseTestSupport.DmaBufResult result = mSupport.runReleaseAttempt(
+ workload,
+ ReleaseTestSupport.ReleaseAction.MANUAL_RELEASE,
+ ReleaseTestSupport.HelperProcessMode.REUSE_PROCESS);
+ mSupport.assertReleaseResult(
+ result,
+ workload,
+ ReleaseTestSupport.ReleaseAction.MANUAL_RELEASE);
+ } catch (AssertionError e) {
+ throw new AssertionError("Run all sequence pass " + (pass + 1) + "/"
+ + REPEAT_COUNT + ", workload=" + workload, e);
+ }
+ }
+ }
+ }
+}
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/ProtectedEglReleaseTests.java b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/ProtectedEglReleaseTests.java
new file mode 100644
index 0000000000000..fcc469932eca4
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/ProtectedEglReleaseTests.java
@@ -0,0 +1,51 @@
+package app.grapheneos.goscompat.dmabufrelease.tests;
+
+import android.app.Instrumentation;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.uiautomator.UiDevice;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public final class ProtectedEglReleaseTests {
+ private final ReleaseTestSupport.Workload mWorkload =
+ ReleaseTestSupport.Workload.protectedEgl();
+ private ReleaseTestSupport mSupport;
+
+ @Before
+ public void setUp() {
+ Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
+ UiDevice device = UiDevice.getInstance(instrumentation);
+ mSupport = new ReleaseTestSupport(device);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (mSupport != null) {
+ mSupport.forceStopHelperApp();
+ }
+ }
+
+ @Test
+ public void protectedEglResourcesCanBeForceStoppedAfterReady() throws Exception {
+ run(ReleaseTestSupport.ReleaseAction.FORCE_STOP);
+ }
+
+ @Test
+ public void protectedEglResourcesCanBeStoppedAfterReady() throws Exception {
+ run(ReleaseTestSupport.ReleaseAction.STOP_APP);
+ }
+
+ @Test
+ public void protectedEglResourcesCanBeManuallyReleasedAfterReady() throws Exception {
+ run(ReleaseTestSupport.ReleaseAction.MANUAL_RELEASE);
+ }
+
+ private void run(ReleaseTestSupport.ReleaseAction releaseAction) throws Exception {
+ ReleaseTestSupport.DmaBufResult result =
+ mSupport.runReleaseAttempt(mWorkload, releaseAction);
+ mSupport.assertProtectedEglResult(result, mWorkload, releaseAction);
+ }
+}
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/ReleaseTestSupport.java b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/ReleaseTestSupport.java
new file mode 100644
index 0000000000000..ea9aa4fc8fefc
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/ReleaseTestSupport.java
@@ -0,0 +1,336 @@
+package app.grapheneos.goscompat.dmabufrelease.tests;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import androidx.test.uiautomator.UiDevice;
+
+import app.grapheneos.goscompat.checks.GosCompatContract;
+
+import java.io.StringReader;
+import java.util.Properties;
+import java.util.UUID;
+
+final class ReleaseTestSupport {
+ private static final long READY_TIMEOUT_MILLIS = 20_000;
+ private static final long POST_PROCESS_STOP_WAIT_MILLIS = 5_000;
+ private static final long POST_MANUAL_RELEASE_WAIT_MILLIS = 1_000;
+ private static final long POLL_INTERVAL_MILLIS = 200;
+
+ private final UiDevice mDevice;
+
+ ReleaseTestSupport(UiDevice device) {
+ mDevice = device;
+ }
+
+ void forceStopHelperApp() throws Exception {
+ mDevice.executeShellCommand("am force-stop " + GosCompatContract.App.PACKAGE_NAME);
+ }
+
+ DmaBufResult runReleaseAttempt(Workload workload, ReleaseAction releaseAction)
+ throws Exception {
+ return runReleaseAttempt(workload, releaseAction, HelperProcessMode.CLEAN_START);
+ }
+
+ DmaBufResult runReleaseAttempt(Workload workload, ReleaseAction releaseAction,
+ HelperProcessMode helperProcessMode) throws Exception {
+ int iterations = GosCompatContract.DmaBufRelease.DEFAULT_ITERATIONS;
+ String token = UUID.randomUUID().toString();
+ String attemptLabel = workload.label() + " " + releaseAction.label;
+
+ if (helperProcessMode == HelperProcessMode.CLEAN_START) {
+ forceStopHelperApp();
+ }
+
+ StringBuilder command = new StringBuilder("am start -n ")
+ .append(GosCompatContract.DmaBufRelease.ACTIVITY)
+ .append(" --es ").append(GosCompatContract.DmaBufRelease.Extra.TOKEN)
+ .append(" ").append(token)
+ .append(" --es ").append(GosCompatContract.DmaBufRelease.Extra.MODE)
+ .append(" ").append(workload.mode())
+ .append(" --ei ").append(GosCompatContract.DmaBufRelease.Extra.WIDTH)
+ .append(" ").append(workload.width())
+ .append(" --ei ").append(GosCompatContract.DmaBufRelease.Extra.HEIGHT)
+ .append(" ").append(workload.height())
+ .append(" --ei ").append(GosCompatContract.DmaBufRelease.Extra.BUFFER_COUNT)
+ .append(" ").append(workload.bufferCount())
+ .append(" --ei ").append(GosCompatContract.DmaBufRelease.Extra.ITERATIONS)
+ .append(" ").append(iterations)
+ .append(" --ez ").append(GosCompatContract.DmaBufRelease.Extra.RELEASE_AFTER_READY)
+ .append(" ").append(releaseAction == ReleaseAction.MANUAL_RELEASE);
+ if (workload.heapName() != null) {
+ command.append(" --es ").append(GosCompatContract.DmaBufRelease.Extra.HEAP_NAME)
+ .append(" ").append(workload.heapName());
+ }
+
+ String output = mDevice.executeShellCommand(command.toString());
+ assertWithMessage(attemptLabel + ": " + output).that(output).doesNotContain("Error");
+
+ DmaBufResult result = waitForResult(token, workload.mode());
+
+ if (releaseAction == ReleaseAction.MANUAL_RELEASE) {
+ java.lang.Thread.sleep(POST_MANUAL_RELEASE_WAIT_MILLIS);
+ } else {
+ mDevice.executeShellCommand(releaseAction.shellCommand);
+ waitForDmaBufProcessExit(result.pid(), attemptLabel);
+ java.lang.Thread.sleep(POST_PROCESS_STOP_WAIT_MILLIS);
+ }
+ mDevice.executeShellCommand("true");
+ return result;
+ }
+
+ void assertDirectSecureChunkHeapResult(DmaBufResult result, Workload workload,
+ ReleaseAction releaseAction) {
+ String message = workload.label() + " " + releaseAction.label + ": " + result;
+ assertReady(result, workload, releaseAction);
+ assertWithMessage(message).that(result.allocatedBuffers())
+ .isEqualTo(result.requestedBuffers());
+ assertWithMessage(message).that(result.heapPath())
+ .contains("/dev/dma_heap/" + workload.heapName());
+ assertWithMessage(message).that(result.heapName()).isEqualTo(workload.heapName());
+ assertWithMessage(message).that(result.allocator()).isEqualTo("dma_heap");
+ }
+
+ void assertProtectedEglResult(DmaBufResult result, Workload workload,
+ ReleaseAction releaseAction) {
+ String message = workload.label() + " " + releaseAction.label + ": " + result;
+ assertReady(result, workload, releaseAction);
+ assertWithMessage(message).that(result.allocatedBuffers())
+ .isEqualTo(GosCompatContract.DmaBufRelease.PROTECTED_EGL_RESOURCE_COUNT);
+ assertWithMessage(message).that(result.heapName()).isEqualTo("vendor-selected");
+ assertWithMessage(message).that(result.allocator())
+ .isEqualTo("EGL_EXT_protected_content");
+ }
+
+ void assertReleaseResult(DmaBufResult result, Workload workload,
+ ReleaseAction releaseAction) {
+ if (GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT
+ .equals(workload.mode())) {
+ assertDirectSecureChunkHeapResult(result, workload, releaseAction);
+ return;
+ }
+ if (GosCompatContract.DmaBufRelease.Mode.PROTECTED_EGL.equals(workload.mode())) {
+ assertProtectedEglResult(result, workload, releaseAction);
+ return;
+ }
+ throw new AssertionError("Unsupported DMA-BUF workload mode: " + workload.mode());
+ }
+
+ private void assertReady(DmaBufResult result, Workload workload,
+ ReleaseAction releaseAction) {
+ String message = workload.label() + " " + releaseAction.label + ": " + result;
+ assertWithMessage(message).that(result.mode()).isEqualTo(workload.mode());
+ assertWithMessage(message).that(result.unsupported()).isFalse();
+ assertWithMessage(message).that(result.ready()).isTrue();
+ assertWithMessage(message).that(result.protectedContent()).isTrue();
+ assertWithMessage(message).that(result.released())
+ .isEqualTo(releaseAction == ReleaseAction.MANUAL_RELEASE);
+ assertWithMessage(message).that(result.width()).isAtLeast(1);
+ assertWithMessage(message).that(result.height()).isAtLeast(1);
+ assertWithMessage(message).that(result.requestedBuffers()).isAtLeast(1);
+ assertWithMessage(message).that(result.allocatedBuffers()).isAtLeast(1);
+ assertWithMessage(message).that(result.pid()).isGreaterThan(0);
+ assertWithMessage(message).that(result.tid()).isGreaterThan(0);
+ }
+
+ private DmaBufResult waitForResult(String token, String mode) throws Exception {
+ long deadline = System.currentTimeMillis() + READY_TIMEOUT_MILLIS;
+ while (System.currentTimeMillis() < deadline) {
+ DmaBufResult result = getStoredResult(token);
+ if (result != null) {
+ return result;
+ }
+ java.lang.Thread.sleep(POLL_INTERVAL_MILLIS);
+ }
+
+ throw new AssertionError("Timed out waiting for DMA-BUF release workload result in "
+ + mode + " mode");
+ }
+
+ private DmaBufResult getStoredResult(String token) throws Exception {
+ String output = mDevice.executeShellCommand("run-as " + GosCompatContract.App.PACKAGE_NAME
+ + " cat files/" + GosCompatContract.DmaBufRelease.RESULT_FILE
+ + " 2>/dev/null");
+ if (output == null || output.trim().isEmpty()) {
+ return null;
+ }
+
+ Properties properties = new Properties();
+ try {
+ properties.load(new StringReader(output));
+ if (!token.equals(properties.getProperty(
+ GosCompatContract.DmaBufRelease.Extra.TOKEN))) {
+ return null;
+ }
+
+ return new DmaBufResult(
+ properties.getProperty(GosCompatContract.DmaBufRelease.Key.MODE, ""),
+ getBoolean(properties, GosCompatContract.DmaBufRelease.Key.READY),
+ getBoolean(properties, GosCompatContract.DmaBufRelease.Key.UNSUPPORTED),
+ getBoolean(properties,
+ GosCompatContract.DmaBufRelease.Key.PROTECTED_CONTENT),
+ getInt(properties, GosCompatContract.DmaBufRelease.Key.WIDTH),
+ getInt(properties, GosCompatContract.DmaBufRelease.Key.HEIGHT),
+ getInt(properties,
+ GosCompatContract.DmaBufRelease.Key.REQUESTED_BUFFERS),
+ getInt(properties,
+ GosCompatContract.DmaBufRelease.Key.ALLOCATED_BUFFERS),
+ getInt(properties, GosCompatContract.DmaBufRelease.Key.ITERATIONS),
+ getInt(properties, GosCompatContract.DmaBufRelease.Key.PID),
+ getInt(properties, GosCompatContract.DmaBufRelease.Key.TID),
+ getBoolean(properties, GosCompatContract.DmaBufRelease.Key.RELEASED),
+ properties.getProperty(GosCompatContract.DmaBufRelease.Key.HEAP_PATH, ""),
+ properties.getProperty(GosCompatContract.DmaBufRelease.Key.HEAP_NAME, ""),
+ properties.getProperty(GosCompatContract.DmaBufRelease.Key.ALLOCATOR, ""),
+ properties.getProperty(GosCompatContract.DmaBufRelease.Key.ALLOCATION, ""),
+ properties.getProperty(GosCompatContract.DmaBufRelease.Key.ERROR, ""));
+ } catch (IllegalArgumentException ignored) {
+ return null;
+ }
+ }
+
+ private void waitForDmaBufProcessExit(int pid, String attemptLabel) throws Exception {
+ long deadline = System.currentTimeMillis() + POST_PROCESS_STOP_WAIT_MILLIS;
+ while (System.currentTimeMillis() < deadline) {
+ String pidofOutput = mDevice.executeShellCommand("pidof "
+ + GosCompatContract.DmaBufRelease.PROCESS + " || true");
+ if (!containsPid(pidofOutput, pid)) {
+ return;
+ }
+ java.lang.Thread.sleep(POLL_INTERVAL_MILLIS);
+ }
+
+ throw new AssertionError(attemptLabel + ": DMA-BUF release process stayed alive");
+ }
+
+ private static boolean containsPid(String pidofOutput, int pid) {
+ if (pidofOutput == null) {
+ return false;
+ }
+ String expected = Integer.toString(pid);
+ for (String candidate : pidofOutput.trim().split("\\s+")) {
+ if (expected.equals(candidate)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean getBoolean(Properties properties, String key) {
+ return Boolean.parseBoolean(properties.getProperty(key));
+ }
+
+ private static int getInt(Properties properties, String key) {
+ return Integer.parseInt(properties.getProperty(key, "0"));
+ }
+
+ record Workload(String label, String mode, String heapName, int width, int height,
+ int bufferCount) {
+ static Workload directVframeSecureMultiChunk() {
+ return new Workload(
+ "vframe-secure multi chunk",
+ GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ GosCompatContract.DmaBufRelease.Heap.VFRAME_SECURE,
+ GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_WIDTH,
+ GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_HEIGHT,
+ GosCompatContract.DmaBufRelease.VFRAME_SECURE_DIRECT_COUNT);
+ }
+
+ static Workload directVstreamSecureMultiChunk() {
+ return new Workload(
+ "vstream-secure multi chunk",
+ GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ GosCompatContract.DmaBufRelease.Heap.VSTREAM_SECURE,
+ GosCompatContract.DmaBufRelease.VSTREAM_SECURE_DIRECT_WIDTH,
+ GosCompatContract.DmaBufRelease.VSTREAM_SECURE_DIRECT_HEIGHT,
+ GosCompatContract.DmaBufRelease.VSTREAM_SECURE_DIRECT_COUNT);
+ }
+
+ static Workload directVframeSecureOneChunk() {
+ return new Workload(
+ "vframe-secure one chunk",
+ GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ GosCompatContract.DmaBufRelease.Heap.VFRAME_SECURE,
+ GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_WIDTH,
+ GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_HEIGHT,
+ GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_COUNT);
+ }
+
+ static Workload directVstreamSecureOneChunk() {
+ return new Workload(
+ "vstream-secure one chunk",
+ GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT,
+ GosCompatContract.DmaBufRelease.Heap.VSTREAM_SECURE,
+ GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_WIDTH,
+ GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_HEIGHT,
+ GosCompatContract.DmaBufRelease.SECURE_CHUNK_HEAP_ONE_CHUNK_COUNT);
+ }
+
+ static Workload protectedEgl() {
+ return new Workload(
+ "protected EGL",
+ GosCompatContract.DmaBufRelease.Mode.PROTECTED_EGL,
+ null,
+ GosCompatContract.DmaBufRelease.PROTECTED_EGL_WIDTH,
+ GosCompatContract.DmaBufRelease.PROTECTED_EGL_HEIGHT,
+ GosCompatContract.DmaBufRelease.PROTECTED_EGL_RESOURCE_COUNT);
+ }
+
+ @Override
+ public String toString() {
+ return label;
+ }
+ }
+
+ record DmaBufResult(String mode, boolean ready, boolean unsupported,
+ boolean protectedContent, int width, int height, int requestedBuffers,
+ int allocatedBuffers, int iterations, int pid, int tid, boolean released,
+ String heapPath, String heapName, String allocator, String allocation,
+ String error) {
+ @Override
+ public String toString() {
+ String resourceText = GosCompatContract.DmaBufRelease.Mode.SECURE_CHUNK_HEAP_DIRECT
+ .equals(mode())
+ ? ", fds=" + allocatedBuffers() + "/" + requestedBuffers()
+ : ", resources=" + allocatedBuffers();
+ return "mode=" + mode()
+ + ", ready=" + ready()
+ + ", unsupported=" + unsupported()
+ + ", protectedContent=" + protectedContent()
+ + ", size=" + width() + "x" + height()
+ + resourceText
+ + ", iterations=" + iterations()
+ + ", pid=" + pid()
+ + ", tid=" + tid()
+ + ", released=" + released()
+ + ", heap=" + heapPath()
+ + ", heapName=" + heapName()
+ + ", allocator=" + allocator()
+ + ", allocation=" + allocation()
+ + ", error=" + error();
+ }
+ }
+
+ enum ReleaseAction {
+ FORCE_STOP("force stop", "am force-stop " + GosCompatContract.App.PACKAGE_NAME),
+ STOP_APP("stop app", "am stop-app " + GosCompatContract.App.PACKAGE_NAME),
+ MANUAL_RELEASE("manual release", "");
+
+ final String label;
+ final String shellCommand;
+
+ ReleaseAction(String label, String shellCommand) {
+ this.label = label;
+ this.shellCommand = shellCommand;
+ }
+
+ @Override
+ public String toString() {
+ return label;
+ }
+ }
+
+ enum HelperProcessMode {
+ CLEAN_START,
+ REUSE_PROCESS,
+ }
+}
diff --git a/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/SecureChunkHeapReleaseTests.java b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/SecureChunkHeapReleaseTests.java
new file mode 100644
index 0000000000000..1e5f47b5a7645
--- /dev/null
+++ b/tests/GosCompatTests/GosCompatDmaBufReleaseTests/src/app/grapheneos/goscompat/dmabufrelease/tests/SecureChunkHeapReleaseTests.java
@@ -0,0 +1,87 @@
+package app.grapheneos.goscompat.dmabufrelease.tests;
+
+import android.app.Instrumentation;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.uiautomator.UiDevice;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.util.Arrays;
+import java.util.List;
+
+@RunWith(Parameterized.class)
+public final class SecureChunkHeapReleaseTests {
+ private final ReleaseTestSupport.Workload mWorkload;
+ private final ReleaseTestSupport.ReleaseAction mReleaseAction;
+ private ReleaseTestSupport mSupport;
+
+ @Parameterized.Parameters(name = "{0}, {1}")
+ public static List