diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index b5aa93ca4..960ae2a03 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -1,50 +1,175 @@ -cmake_minimum_required(VERSION 3.22.1) +cmake_minimum_required(VERSION 4.1.2) -project("adrenotools_bridge") +# Keep FetchContent subprojects (libadrenotools, etc.) buildable under CMake +# 4.x even though some declare `cmake_minimum_required(VERSION 3.x)`. CMake 4 +# drops compatibility below 3.5; this baseline replays older defaults for +# fetched projects so they don't fail policy-OLD warnings. +set(CMAKE_POLICY_VERSION_MINIMUM 3.5 CACHE STRING "" FORCE) -# YTDLP execution wrapper for SDK 29+ bypass -# This works on all architectures -add_executable(ytdl_wrapper ytdl_wrapper.c) -set_target_properties(ytdl_wrapper PROPERTIES OUTPUT_NAME "ytdl") -# Rename to libytdl.so so it gets extracted by Android -set_target_properties(ytdl_wrapper PROPERTIES PREFIX "lib" SUFFIX ".so") +project("streamx_native" LANGUAGES C CXX) -# Adrenotools is only for arm64-v8a +# ============================================================================= +# Project-wide build settings +# ============================================================================= + +# Pin standards explicitly so FetchContent submodules don't downgrade us. +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Hidden visibility by default — every JNI entry point is JNIEXPORT and stays +# exported. Saves PLT slots, shrinks the dynamic symbol table, and lets the +# linker inline cross-TU calls more aggressively under LTO. +set(CMAKE_C_VISIBILITY_PRESET hidden) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) + +# Position-independent code is required on Android. AGP already passes -fPIC, +# but pinning it at the CMake level guarantees consistency across submodules. +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Drop transitive PRIVATE/static-lib dependencies from link lines when the +# final target doesn't actually need them (CMake 3.19+). Trims .so size, +# shortens link commands, and prunes dead DT_NEEDED entries. +set(CMAKE_OPTIMIZE_DEPENDENCIES ON) + +# Honor target-level INTERPROCEDURAL_OPTIMIZATION even if a parent scope set +# CMAKE_INTERPROCEDURAL_OPTIMIZATION conditionally. CMP0069 NEW = required. +cmake_policy(SET CMP0069 NEW) +set(CMAKE_POLICY_DEFAULT_CMP0069 NEW) + +# Default CMAKE_BUILD_TYPE for single-config gens that forget to pass one. +# AGP always sets one explicitly, but this protects standalone CMake runs. +if (NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() + +# Convenience flag — true for any build that should be optimized for runtime. +set(IS_OPTIMIZED_BUILD FALSE) +if (CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo" OR CMAKE_BUILD_TYPE STREQUAL "MinSizeRel") + set(IS_OPTIMIZED_BUILD TRUE) +endif() + +# ThinLTO globally for optimized builds. INTERPROCEDURAL_OPTIMIZATION on clang +# translates to -flto=thin (vs. monolithic -flto), which keeps incremental +# link times reasonable while still doing cross-TU inlining and DCE. +if (IS_OPTIMIZED_BUILD) + include(CheckIPOSupported) + check_ipo_supported(RESULT _ipo_ok OUTPUT _ipo_err) + if (_ipo_ok) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) + message(STATUS "IPO/ThinLTO enabled for ${CMAKE_BUILD_TYPE}") + else() + message(WARNING "IPO not supported by this toolchain: ${_ipo_err}") + endif() +endif() + +# Aggressive link-time stripping & merging applied to every target we build +# in this project. Submodule targets (adrenotools, bytehook hooks) inherit +# these via add_link_options so the final .so stays tight regardless of how +# bloated the upstream code is. +# --gc-sections : drop unreferenced sections (paired with -ffunction/-fdata-sections) +# --icf=safe : identical code folding (lld) — merges byte-identical functions +# --as-needed : drop DT_NEEDED entries the binary doesn't actually use +# -z relro,-z now : full RELRO (security; also lets the linker skip lazy resolution) +# -z noexecstack : NX stack +# --hash-style=gnu : smaller, faster .gnu.hash than legacy SysV +# --exclude-libs,ALL : strip static-archive symbols from the dynamic exports +# (we re-add it per-target below; it must come AFTER all -l args) +if (IS_OPTIMIZED_BUILD) + add_link_options( + "LINKER:--gc-sections" + "LINKER:--icf=safe" + "LINKER:--as-needed" + "LINKER:-z,relro,-z,now,-z,noexecstack" + "LINKER:--hash-style=gnu" + ) +endif() + +# Compile flags shared by every target we directly build. Submodules manage +# their own flags (we don't want to override their feature checks). +add_compile_options( + -Wall + -ffunction-sections + -fdata-sections +) +if (IS_OPTIMIZED_BUILD) + add_compile_options(-O3 -ffast-math) +endif() + +# ============================================================================= +# Adrenotools bridge — custom Vulkan driver loader for Qualcomm Adreno GPUs. +# arm64-v8a only (libadrenotools requirement). +# ============================================================================= if (CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") include(FetchContent) + # Upstream bylaws/libadrenotools is the canonical fork. The previous + # eden-emulator fork added adrenotools_set_freedreno_env() — we replicate + # that behavior via plain setenv() in ApplyEnvironmentVariable, so we no + # longer depend on the eden-specific extensions. + # + # EXCLUDE_FROM_ALL: only build adrenotools targets we actually link + # against; skip any of their tests/tools that might exist now or later. + # SYSTEM: mark adrenotools' include paths as -isystem so their headers' + # warnings are silenced regardless of -Wall in this project. FetchContent_Declare( adrenotools - GIT_REPOSITORY https://github.com/bylaws/libadrenotools.git - GIT_TAG master + GIT_REPOSITORY https://github.com/bylaws/libadrenotools.git + GIT_TAG master + GIT_SHALLOW TRUE + EXCLUDE_FROM_ALL TRUE + SYSTEM ) FetchContent_MakeAvailable(adrenotools) - # Suppress warnings from adrenotools internal targets - set_property(TARGET adrenotools PROPERTY COMPILE_OPTIONS "-w") - set_property(TARGET hook_impl PROPERTY COMPILE_OPTIONS "-w") - set_property(TARGET main_hook PROPERTY COMPILE_OPTIONS "-w") - set_property(TARGET file_redirect_hook PROPERTY COMPILE_OPTIONS "-w") - set_property(TARGET gsl_alloc_hook PROPERTY COMPILE_OPTIONS "-w") + # Belt-and-braces: SYSTEM on FetchContent_Declare covers most generators, + # but older Ninja paths still respect -Wall on the included headers. Hard + # -w on the internal targets gives a stable warning floor. + foreach(_at_target adrenotools hook_impl main_hook file_redirect_hook gsl_alloc_hook) + if (TARGET ${_at_target}) + target_compile_options(${_at_target} PRIVATE -w) + # Match our visibility/IPO posture so the linker can fold/strip + # their code into our final .so on equal footing. + set_target_properties(${_at_target} PROPERTIES + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + POSITION_INDEPENDENT_CODE ON + ) + if (IS_OPTIMIZED_BUILD AND _ipo_ok) + set_target_properties(${_at_target} PROPERTIES INTERPROCEDURAL_OPTIMIZATION TRUE) + endif() + endif() + endforeach() - add_library(${CMAKE_PROJECT_NAME} SHARED - adrenotools_bridge.cpp - ) + # ByteHook — PLT-hook engine. The Android Gradle Plugin's `prefab = true` + # exposes the AAR's prefab/ directory; find_package picks up the + # bytehookConfig.cmake shipped inside com.bytedance:bytehook. + find_package(bytehook REQUIRED CONFIG) - target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE + add_library(adrenotools_bridge SHARED adrenotools_bridge.cpp) + + # SYSTEM include — adrenotools headers won't trip our -Wall. + target_include_directories(adrenotools_bridge SYSTEM PRIVATE ${adrenotools_SOURCE_DIR}/include ) - find_package(bytehook REQUIRED CONFIG) - - target_link_libraries(${CMAKE_PROJECT_NAME} + target_link_libraries(adrenotools_bridge PRIVATE android log EGL adrenotools bytehook::bytehook ) -else() + + target_link_options(adrenotools_bridge PRIVATE "LINKER:--exclude-libs,ALL") +endif() + +if (NOT CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") message(STATUS "Skipping adrenotools_bridge for non-arm64 architecture: ${CMAKE_ANDROID_ARCH_ABI}") endif() diff --git a/app/src/main/cpp/adrenotools_bridge.cpp b/app/src/main/cpp/adrenotools_bridge.cpp index 8f7eefa47..736076438 100644 --- a/app/src/main/cpp/adrenotools_bridge.cpp +++ b/app/src/main/cpp/adrenotools_bridge.cpp @@ -4,12 +4,19 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include -#include #include +#include #define LOG_TAG "GpuDriverBridge" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) extern "C" { @@ -18,49 +25,113 @@ extern "C" { #include +// ============================================================================= +// FreedrenoConfig — preserved from the Eden-style integration. Holds the +// in-memory env-var map plus the on-disk .freedreno.conf used to persist HUD +// and TU_DEBUG flags across launches. Surface exposed to Kotlin via +// NativeFreedrenoConfig. +// ============================================================================= +namespace { +struct FreedrenoConfig { + std::map env_vars; + std::string base_path; +}; + +std::unique_ptr g_config; + +std::string GetConfigPath() { + if (g_config && !g_config->base_path.empty()) { + return g_config->base_path + "/.freedreno.conf"; + } + return ""; +} + +bool ApplyEnvironmentVariable(const std::string& key, const std::string& value) { + // bylaws/libadrenotools does not expose adrenotools_set_freedreno_env; + // use plain setenv directly. Adrenotools reads these via getenv() at + // driver init time, which is exactly what setenv populates. + if (setenv(key.c_str(), value.c_str(), 1) != 0) { + LOGE("[Freedreno] setenv %s=%s failed", key.c_str(), value.c_str()); + return false; + } + return true; +} + +std::string GetJString(JNIEnv* env, jstring jstr) { + if (!jstr) return ""; + const char* chars = env->GetStringUTFChars(jstr, nullptr); + if (!chars) return ""; + std::string str(chars); + env->ReleaseStringUTFChars(jstr, chars); + return str; +} +} // namespace + +// ============================================================================= +// ByteHook scaffolding — intercept libmpv/libplacebo/libav* dlopen and +// vk* entry points so the system Vulkan loader returns OUR pre-loaded custom +// driver handle instead of the OEM libvulkan.so. Only meaningful when +// setDriver() has successfully opened a custom driver (g_vulkan_handle != null). +// ============================================================================= namespace { +// All driver-state mutations go through this mutex. setDriver, unloadDriver, +// and isDriverActive can race with each other from different threads (App +// startup, MPV worker, settings UI), and a half-installed hook table is much +// worse than serialising the rare reconfigure path. +std::mutex g_driver_mutex; void* g_vulkan_handle = nullptr; + +// Set by my_vkCreateInstance the first time it routes a call through our +// custom handle. UI uses this (via wasHookUsed()) to distinguish "bridge +// armed and hook was actually exercised" from "bridge armed but the Android +// Vulkan loader bypassed us entirely". When the latter happens, the Vulkan +// instance is silently created against the OEM driver — the user thinks +// the custom driver is active but it isn't. +std::atomic g_hook_used{false}; + bytehook_stub_t g_dlopen_stub = nullptr; bytehook_stub_t g_android_dlopen_ext_stub = nullptr; bytehook_stub_t g_dlsym_stub = nullptr; - bytehook_stub_t g_vkGetInstanceProcAddr_stub = nullptr; bytehook_stub_t g_vkCreateInstance_stub = nullptr; bytehook_stub_t g_vkEnumerateInstanceExtensionProperties_stub = nullptr; bytehook_stub_t g_vkEnumerateInstanceVersion_stub = nullptr; - -bool my_caller_allow_filter(const char *caller_path_name, void *arg) { - if (caller_path_name) { - // Log all callers to help debug - // LOGI("Filter check caller: %s", caller_path_name); - - if (strstr(caller_path_name, "libmpv.so") || - strstr(caller_path_name, "libplayer.so") || - strstr(caller_path_name, "libplacebo.so") || - strstr(caller_path_name, "libavcodec.so") || - strstr(caller_path_name, "libavformat.so") || - strstr(caller_path_name, "libavutil.so") || - strstr(caller_path_name, "libavfilter.so") || - strstr(caller_path_name, "libavdevice.so") || - strstr(caller_path_name, "libswscale.so") || - strstr(caller_path_name, "libswresample.so")) { - return true; - } - } else { - // If caller is unknown, allow it for now to be safe (might be from libmpv via some wrapper) - return true; - } - return false; +bytehook_stub_t g_vkEnumeratePhysicalDevices_stub = nullptr; +bytehook_stub_t g_vkGetDeviceProcAddr_stub = nullptr; + +// Caller filter: intercept calls originating from the media stack AND from +// the Android Vulkan loader itself. libvulkan.so does an internal dlopen on +// /vendor/lib64/hw/vulkan.adreno.so when libplacebo calls vkCreateInstance — +// without hooking that path, the loader bypasses our redirect and silently +// uses the OEM driver (visible in logs as DRIVER_ID_QUALCOMM_PROPRIETARY +// while our bridge thinks the custom driver is armed). +// Unknown callers are allowed (some wrappers may report nullptr) to be safe; +// system libraries and unrelated native code fall through unchanged. +bool my_caller_allow_filter(const char* caller_path_name, void* /* arg */) { + if (!caller_path_name) return true; + return strstr(caller_path_name, "libvulkan.so") != nullptr || + strstr(caller_path_name, "libmpv.so") != nullptr || + strstr(caller_path_name, "libplayer.so") != nullptr || + strstr(caller_path_name, "libplacebo.so") != nullptr || + strstr(caller_path_name, "libavcodec.so") != nullptr || + strstr(caller_path_name, "libavformat.so") != nullptr || + strstr(caller_path_name, "libavutil.so") != nullptr || + strstr(caller_path_name, "libavfilter.so") != nullptr || + strstr(caller_path_name, "libavdevice.so") != nullptr || + strstr(caller_path_name, "libswscale.so") != nullptr || + strstr(caller_path_name, "libswresample.so") != nullptr; } +// Exact match for the system Vulkan loader filename. We DO NOT match custom +// driver names like "libvulkan_freedreno.so" — those go through mpv's +// --vulkan-library option and the linker's normal refcounted dlopen path. +// Both libvulkan.so and the soname-versioned libvulkan.so.1 are covered. bool is_libvulkan(const char* filename) { if (!filename) return false; if (strcmp(filename, "libvulkan.so") == 0 || strcmp(filename, "libvulkan.so.1") == 0) return true; - size_t len = strlen(filename); - if (len >= 13) { - if (strcmp(filename + len - 13, "/libvulkan.so") == 0) return true; - } + if (len >= 13 && strcmp(filename + len - 13, "/libvulkan.so") == 0) return true; + if (len >= 15 && strcmp(filename + len - 15, "/libvulkan.so.1") == 0) return true; return false; } @@ -68,15 +139,19 @@ typedef void* (*PFN_vkGetInstanceProcAddr)(void*, const char*); typedef int (*PFN_vkCreateInstance)(const void*, const void*, void**); typedef int (*PFN_vkEnumerateInstanceExtensionProperties)(const char*, uint32_t*, void*); typedef int (*PFN_vkEnumerateInstanceVersion)(uint32_t*); +typedef int (*PFN_vkEnumeratePhysicalDevices)(void*, uint32_t*, void**); +typedef void* (*PFN_vkGetDeviceProcAddr)(void*, const char*); void* my_vkGetInstanceProcAddr(void* instance, const char* name); int my_vkCreateInstance(const void* pCreateInfo, const void* pAllocator, void** pInstance); int my_vkEnumerateInstanceExtensionProperties(const char* pLayerName, uint32_t* pPropertyCount, void* pProperties); int my_vkEnumerateInstanceVersion(uint32_t* pApiVersion); +int my_vkEnumeratePhysicalDevices(void* instance, uint32_t* pPhysicalDeviceCount, void** pPhysicalDevices); +void* my_vkGetDeviceProcAddr(void* device, const char* pName); void* my_dlopen(const char* filename, int flags) { if (filename && g_vulkan_handle && is_libvulkan(filename)) { - LOGI("Intercepted dlopen for %s -> redirecting to custom driver", filename); + LOGI("Intercepted dlopen(%s) -> custom driver handle", filename); return g_vulkan_handle; } BYTEHOOK_STACK_SCOPE(); @@ -85,19 +160,26 @@ void* my_dlopen(const char* filename, int flags) { void* my_android_dlopen_ext(const char* filename, int flags, const void* extinfo) { if (filename && g_vulkan_handle && is_libvulkan(filename)) { - LOGI("Intercepted android_dlopen_ext for %s -> redirecting to custom driver", filename); + LOGI("Intercepted android_dlopen_ext(%s) -> custom driver handle", filename); return g_vulkan_handle; } BYTEHOOK_STACK_SCOPE(); return BYTEHOOK_CALL_PREV(my_android_dlopen_ext, filename, flags, extinfo); } +// Intercept dlsym only when the caller queries against our custom handle +// or asks for global resolution (RTLD_DEFAULT/RTLD_NEXT). libplacebo and +// libmpv use the global path in some configurations — without this the +// Vulkan entry points slip past the hook table. void* my_dlsym(void* handle, const char* symbol) { - if (g_vulkan_handle && symbol) { - if (strcmp(symbol, "vkGetInstanceProcAddr") == 0) return (void*)my_vkGetInstanceProcAddr; - if (strcmp(symbol, "vkCreateInstance") == 0) return (void*)my_vkCreateInstance; - if (strcmp(symbol, "vkEnumerateInstanceExtensionProperties") == 0) return (void*)my_vkEnumerateInstanceExtensionProperties; - if (strcmp(symbol, "vkEnumerateInstanceVersion") == 0) return (void*)my_vkEnumerateInstanceVersion; + if (g_vulkan_handle && symbol && + (handle == g_vulkan_handle || handle == RTLD_DEFAULT || handle == RTLD_NEXT)) { + if (strcmp(symbol, "vkGetInstanceProcAddr") == 0) return (void*) my_vkGetInstanceProcAddr; + if (strcmp(symbol, "vkCreateInstance") == 0) return (void*) my_vkCreateInstance; + if (strcmp(symbol, "vkEnumerateInstanceExtensionProperties") == 0) return (void*) my_vkEnumerateInstanceExtensionProperties; + if (strcmp(symbol, "vkEnumerateInstanceVersion") == 0) return (void*) my_vkEnumerateInstanceVersion; + if (strcmp(symbol, "vkEnumeratePhysicalDevices") == 0) return (void*) my_vkEnumeratePhysicalDevices; + if (strcmp(symbol, "vkGetDeviceProcAddr") == 0) return (void*) my_vkGetDeviceProcAddr; } BYTEHOOK_STACK_SCOPE(); return BYTEHOOK_CALL_PREV(my_dlsym, handle, symbol); @@ -105,6 +187,12 @@ void* my_dlsym(void* handle, const char* symbol) { void* my_vkGetInstanceProcAddr(void* instance, const char* name) { if (g_vulkan_handle) { + // Intercept known entry points so subsequent calls go through our + // hooks regardless of whether the caller used dlsym or this GPA. + if (name) { + if (strcmp(name, "vkEnumeratePhysicalDevices") == 0) return (void*) my_vkEnumeratePhysicalDevices; + if (strcmp(name, "vkGetDeviceProcAddr") == 0) return (void*) my_vkGetDeviceProcAddr; + } auto func = (PFN_vkGetInstanceProcAddr) dlsym(g_vulkan_handle, "vkGetInstanceProcAddr"); if (func) return func(instance, name); } @@ -116,7 +204,15 @@ int my_vkCreateInstance(const void* pCreateInfo, const void* pAllocator, void** if (g_vulkan_handle) { LOGI("Intercepted vkCreateInstance"); auto func = (PFN_vkCreateInstance) dlsym(g_vulkan_handle, "vkCreateInstance"); - if (func) return func(pCreateInfo, pAllocator, pInstance); + if (func) { + int result = func(pCreateInfo, pAllocator, pInstance); + // Only mark the hook as "actually used" once the custom driver's + // vkCreateInstance succeeds — a non-zero VkResult means the + // custom driver rejected the params, and we shouldn't claim the + // hook is working in that case. + if (result == 0) g_hook_used.store(true, std::memory_order_relaxed); + return result; + } } BYTEHOOK_STACK_SCOPE(); return BYTEHOOK_CALL_PREV(my_vkCreateInstance, pCreateInfo, pAllocator, pInstance); @@ -140,8 +236,85 @@ int my_vkEnumerateInstanceVersion(uint32_t* pApiVersion) { return BYTEHOOK_CALL_PREV(my_vkEnumerateInstanceVersion, pApiVersion); } +int my_vkEnumeratePhysicalDevices(void* instance, uint32_t* pPhysicalDeviceCount, void** pPhysicalDevices) { + if (g_vulkan_handle) { + LOGI("Intercepted vkEnumeratePhysicalDevices"); + auto func = (PFN_vkEnumeratePhysicalDevices) dlsym(g_vulkan_handle, "vkEnumeratePhysicalDevices"); + if (func) return func(instance, pPhysicalDeviceCount, pPhysicalDevices); + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_vkEnumeratePhysicalDevices, instance, pPhysicalDeviceCount, pPhysicalDevices); +} + +void* my_vkGetDeviceProcAddr(void* device, const char* pName) { + if (g_vulkan_handle) { + auto func = (PFN_vkGetDeviceProcAddr) dlsym(g_vulkan_handle, "vkGetDeviceProcAddr"); + if (func) return func(device, pName); + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_vkGetDeviceProcAddr, device, pName); +} + +void unhook_all_stubs() { + auto unhook = [](bytehook_stub_t& stub) { + if (stub) { + bytehook_unhook(stub); + stub = nullptr; + } + }; + unhook(g_dlopen_stub); + unhook(g_android_dlopen_ext_stub); + unhook(g_dlsym_stub); + unhook(g_vkGetInstanceProcAddr_stub); + unhook(g_vkCreateInstance_stub); + unhook(g_vkEnumerateInstanceExtensionProperties_stub); + unhook(g_vkEnumerateInstanceVersion_stub); + unhook(g_vkEnumeratePhysicalDevices_stub); + unhook(g_vkGetDeviceProcAddr_stub); +} + +void install_all_hooks() { + static bool bytehook_initialized = false; + if (!bytehook_initialized) { + bytehook_init(BYTEHOOK_MODE_AUTOMATIC, false); + bytehook_initialized = true; + } + auto hook = [](bytehook_stub_t& stub, const char* sym, void* new_func) { + if (!stub) { + stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, sym, new_func, nullptr, nullptr); + if (!stub) LOGW("Failed to install hook for %s", sym); + } + }; + hook(g_dlopen_stub, "dlopen", (void*) my_dlopen); + hook(g_android_dlopen_ext_stub, "android_dlopen_ext", (void*) my_android_dlopen_ext); + hook(g_dlsym_stub, "dlsym", (void*) my_dlsym); + hook(g_vkGetInstanceProcAddr_stub, "vkGetInstanceProcAddr", (void*) my_vkGetInstanceProcAddr); + hook(g_vkCreateInstance_stub, "vkCreateInstance", (void*) my_vkCreateInstance); + hook(g_vkEnumerateInstanceExtensionProperties_stub, "vkEnumerateInstanceExtensionProperties", (void*) my_vkEnumerateInstanceExtensionProperties); + hook(g_vkEnumerateInstanceVersion_stub, "vkEnumerateInstanceVersion", (void*) my_vkEnumerateInstanceVersion); + hook(g_vkEnumeratePhysicalDevices_stub, "vkEnumeratePhysicalDevices", (void*) my_vkEnumeratePhysicalDevices); + hook(g_vkGetDeviceProcAddr_stub, "vkGetDeviceProcAddr", (void*) my_vkGetDeviceProcAddr); + LOGI("ByteHook hooks registered (9 entry points, caller-filtered to media stack)"); +} + +// Read a single line from a sysfs path. Returns an empty string when the +// file is missing or unreadable (very common when running on non-Adreno or +// on devices that don't expose the kgsl debug surface). +std::string read_sysfs(const char* path) { + std::ifstream file(path); + if (!file.is_open()) return ""; + std::string line; + std::getline(file, line); + while (!line.empty() && (line.back() == '\n' || line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) { + line.pop_back(); + } + return line; +} } // namespace +// ============================================================================= +// JNI entry points +// ============================================================================= extern "C" { JNIEXPORT jboolean JNICALL @@ -154,65 +327,79 @@ Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_setDriver( jstring fileRedirectDir, jstring tmpDir) { + std::lock_guard lock(g_driver_mutex); + const char* nativeHookLibDir = hookLibDir ? env->GetStringUTFChars(hookLibDir, nullptr) : nullptr; const char* nativeDriverDirRaw = customDriverDir ? env->GetStringUTFChars(customDriverDir, nullptr) : nullptr; const char* nativeDriverName = customDriverName ? env->GetStringUTFChars(customDriverName, nullptr) : nullptr; const char* nativeFileRedirectDir = fileRedirectDir ? env->GetStringUTFChars(fileRedirectDir, nullptr) : nullptr; const char* nativeTmpDir = tmpDir ? env->GetStringUTFChars(tmpDir, nullptr) : nullptr; + // adrenotools expects the driver directory to end with '/' — normalise. std::string driverDirStr = nativeDriverDirRaw ? nativeDriverDirRaw : ""; - if (!driverDirStr.empty() && driverDirStr.back() != '/') { - driverDirStr += '/'; - } + if (!driverDirStr.empty() && driverDirStr.back() != '/') driverDirStr += '/'; const char* nativeDriverDir = driverDirStr.empty() ? nullptr : driverDirStr.c_str(); - LOGI("setDriver: hookLibDir=%s, customDriverDir=%s, customDriverName=%s, tmpDir=%s", - nativeHookLibDir ? nativeHookLibDir : "null", - nativeDriverDir ? nativeDriverDir : "null", - nativeDriverName ? nativeDriverName : "null", - nativeTmpDir ? nativeTmpDir : "null"); + LOGI("setDriver: hookLibDir=%s driverDir=%s driverName=%s redirect=%s tmp=%s", + nativeHookLibDir ? nativeHookLibDir : "(null)", + nativeDriverDir ? nativeDriverDir : "(null)", + nativeDriverName ? nativeDriverName : "(null)", + nativeFileRedirectDir ? nativeFileRedirectDir : "(null)", + nativeTmpDir ? nativeTmpDir : "(null)"); + + // Re-init path: clean up the previous load before opening the new one. + // We deliberately do NOT dlclose the old handle — any live Vulkan + // objects (instances, devices, command buffers) still hold pointers + // into that .so's code; closing it would be use-after-free. The handle + // leaks for the lifetime of the process, which is the safe trade. + if (g_vulkan_handle) { + LOGI("setDriver: previous handle present, unhooking before re-init"); + unhook_all_stubs(); + g_vulkan_handle = nullptr; + } + // Reset the "hook was used" flag on every (re)init so wasHookUsed() + // reflects only the current driver session, not a stale signal from a + // previously-armed driver. + g_hook_used.store(false, std::memory_order_relaxed); void* handle = nullptr; int featureFlags = 0; - if (nativeFileRedirectDir && strlen(nativeFileRedirectDir) > 0) { featureFlags |= ADRENOTOOLS_DRIVER_FILE_REDIRECT; } - if (nativeDriverName && strlen(nativeDriverName) > 0) { + // System-driver mode: no custom name supplied. Hooks stay armed for + // file-redirect features but we don't try to swap libvulkan. + if (!nativeDriverName || strlen(nativeDriverName) == 0) { handle = adrenotools_open_libvulkan( - 2 /* RTLD_NOW */, featureFlags | ADRENOTOOLS_DRIVER_CUSTOM, nativeTmpDir, nativeHookLibDir, - nativeDriverDir, nativeDriverName, nativeFileRedirectDir, nullptr); - if (handle) { - LOGI("Successfully loaded custom driver: %s", nativeDriverName); - } else { - LOGE("Failed to load custom driver: %s", nativeDriverName); - } - } - - if (!handle) { + 2 /* RTLD_NOW */, featureFlags, + nativeTmpDir, nativeHookLibDir, + nullptr, nullptr, + nativeFileRedirectDir, nullptr); + if (handle) LOGI("setDriver: loaded system driver via adrenotools"); + } else { + // Custom driver: open it via adrenotools. No silent fallback to the + // system loader — if the custom .so fails to load we want the call + // to return false so the Kotlin side can surface a real error + // instead of pretending the driver is active while libplacebo ends + // up on the OEM Vulkan blob. handle = adrenotools_open_libvulkan( - 2 /* RTLD_NOW */, featureFlags, nativeTmpDir, nativeHookLibDir, - nullptr, nullptr, nativeFileRedirectDir, nullptr); + 2 /* RTLD_NOW */, featureFlags | ADRENOTOOLS_DRIVER_CUSTOM, + nativeTmpDir, nativeHookLibDir, + nativeDriverDir, nativeDriverName, + nativeFileRedirectDir, nullptr); if (handle) { - LOGI("Successfully initialized system driver with adrenotools hooks"); + LOGI("setDriver: loaded custom driver %s", nativeDriverName); + } else { + LOGE("setDriver: failed to load custom driver %s (no fallback)", nativeDriverName); } } if (handle) { g_vulkan_handle = handle; - bytehook_init(BYTEHOOK_MODE_AUTOMATIC, false); - - // Register hooks with caller filtering - if (!g_dlopen_stub) g_dlopen_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "dlopen", (void*)my_dlopen, nullptr, nullptr); - if (!g_android_dlopen_ext_stub) g_android_dlopen_ext_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "android_dlopen_ext", (void*)my_android_dlopen_ext, nullptr, nullptr); - if (!g_dlsym_stub) g_dlsym_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "dlsym", (void*)my_dlsym, nullptr, nullptr); - if (!g_vkGetInstanceProcAddr_stub) g_vkGetInstanceProcAddr_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkGetInstanceProcAddr", (void*)my_vkGetInstanceProcAddr, nullptr, nullptr); - if (!g_vkCreateInstance_stub) g_vkCreateInstance_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkCreateInstance", (void*)my_vkCreateInstance, nullptr, nullptr); - if (!g_vkEnumerateInstanceExtensionProperties_stub) g_vkEnumerateInstanceExtensionProperties_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkEnumerateInstanceExtensionProperties", (void*)my_vkEnumerateInstanceExtensionProperties, nullptr, nullptr); - if (!g_vkEnumerateInstanceVersion_stub) g_vkEnumerateInstanceVersion_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkEnumerateInstanceVersion", (void*)my_vkEnumerateInstanceVersion, nullptr, nullptr); - - LOGI("ByteHook initialized for libmpv redirection"); + install_all_hooks(); + } else { + LOGE("setDriver: driver load failed"); } if (nativeHookLibDir) env->ReleaseStringUTFChars(hookLibDir, nativeHookLibDir); @@ -226,7 +413,7 @@ Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_setDriver( JNIEXPORT jboolean JNICALL Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_isAdrenoDevice( - JNIEnv* env, + JNIEnv* /* env */, jobject /* this */) { return access("/dev/kgsl-3d0", F_OK) == 0 ? JNI_TRUE : JNI_FALSE; } @@ -235,10 +422,183 @@ JNIEXPORT jstring JNICALL Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_getGpuInfo( JNIEnv* env, jobject /* this */) { + // Best source: the kgsl sysfs node that Qualcomm's kernel exposes, + // e.g. "Adreno (TM) 740". World-readable, no permission needed. + std::string gpuModel = read_sysfs("/sys/class/kgsl/kgsl-3d0/gpu_model"); + if (!gpuModel.empty()) { + LOGI("GPU model from sysfs: %s", gpuModel.c_str()); + return env->NewStringUTF(gpuModel.c_str()); + } + + // Fallback: chip id reads as a hex string like "0x07030001". Won't + // parse cleanly with parseAdrenoModel but at least tells the user + // something more specific than the generic banner. + std::string chipId = read_sysfs("/sys/class/kgsl/kgsl-3d0/gpu_chip_id"); + if (!chipId.empty()) { + std::string result = "Adreno GPU (Chip ID: " + chipId + ")"; + LOGI("GPU chip ID from sysfs: %s", chipId.c_str()); + return env->NewStringUTF(result.c_str()); + } + + // Last resort: device-presence probe only. if (access("/dev/kgsl-3d0", F_OK) == 0) { return env->NewStringUTF("Qualcomm Adreno GPU Detected"); } return env->NewStringUTF("Generic GPU Driver Active"); } +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_isDriverActive( + JNIEnv* /* env */, + jobject /* this */) { + std::lock_guard lock(g_driver_mutex); + return g_vulkan_handle != nullptr ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_wasHookUsed( + JNIEnv* /* env */, + jobject /* this */) { + return g_hook_used.load(std::memory_order_relaxed) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_unloadDriver( + JNIEnv* /* env */, + jobject /* this */) { + std::lock_guard lock(g_driver_mutex); + if (!g_vulkan_handle) { + LOGI("unloadDriver: no driver loaded, nothing to do"); + return JNI_TRUE; + } + LOGI("unloadDriver: unhooking all stubs and clearing driver handle"); + unhook_all_stubs(); + // Intentionally NOT dlclose'ing the handle — Vulkan objects may still + // reference the driver's code. The handle leaks for the process + // lifetime; the alternative is a guaranteed use-after-free crash. + g_vulkan_handle = nullptr; + return JNI_TRUE; +} + +// ============================================================================= +// NativeFreedrenoConfig JNI — preserved Eden-style env-var storage. Used by +// GpuDriverHelper for HUD/TU_DEBUG toggles independently of the bytehook path. +// ============================================================================= + +JNIEXPORT void JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_setFreedrenoBasePath( + JNIEnv* env, jclass /* clazz */, jstring jbasePath) { + if (!g_config) g_config = std::make_unique(); + g_config->base_path = GetJString(env, jbasePath); +} + +JNIEXPORT void JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_initializeFreedrenoConfig( + JNIEnv* /* env */, jclass /* clazz */) { + if (!g_config) g_config = std::make_unique(); +} + +JNIEXPORT void JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_saveFreedrenoConfig( + JNIEnv* /* env */, jclass /* clazz */) { + if (!g_config) return; + const std::string config_path = GetConfigPath(); + if (config_path.empty()) return; + FILE* file = fopen(config_path.c_str(), "w"); + if (!file) return; + for (const auto& entry : g_config->env_vars) { + fprintf(file, "%s=%s\n", entry.first.c_str(), entry.second.c_str()); + } + fclose(file); +} + +JNIEXPORT void JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_reloadFreedrenoConfig( + JNIEnv* /* env */, jclass /* clazz */) { + if (!g_config) return; + const std::string config_path = GetConfigPath(); + if (config_path.empty()) return; + g_config->env_vars.clear(); + FILE* file = fopen(config_path.c_str(), "r"); + if (!file) return; + char line[512]; + while (fgets(line, sizeof(line), file)) { + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') line[--len] = '\0'; + if (len == 0 || line[0] == '#') continue; + const char* eq = strchr(line, '='); + if (!eq) continue; + std::string key(line, eq - line); + std::string value(eq + 1); + g_config->env_vars[key] = value; + ApplyEnvironmentVariable(key, value); + } + fclose(file); +} + +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_setFreedrenoEnv( + JNIEnv* env, jclass /* clazz */, jstring jvarName, jstring jvalue) { + if (!g_config) return JNI_FALSE; + auto var_name = GetJString(env, jvarName); + auto value = GetJString(env, jvalue); + if (var_name.empty()) return JNI_FALSE; + g_config->env_vars[var_name] = value; + return ApplyEnvironmentVariable(var_name, value) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_getFreedrenoEnv( + JNIEnv* env, jclass /* clazz */, jstring jvarName) { + if (!g_config) return env->NewStringUTF(""); + auto var_name = GetJString(env, jvarName); + auto it = g_config->env_vars.find(var_name); + return it != g_config->env_vars.end() ? env->NewStringUTF(it->second.c_str()) : env->NewStringUTF(""); +} + +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_isFreedrenoEnvSet( + JNIEnv* env, jclass /* clazz */, jstring jvarName) { + if (!g_config) return JNI_FALSE; + auto var_name = GetJString(env, jvarName); + auto it = g_config->env_vars.find(var_name); + return (it != g_config->env_vars.end() && !it->second.empty()) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_clearFreedrenoEnv( + JNIEnv* env, jclass /* clazz */, jstring jvarName) { + if (!g_config) return JNI_FALSE; + auto var_name = GetJString(env, jvarName); + auto it = g_config->env_vars.find(var_name); + if (it != g_config->env_vars.end()) { + g_config->env_vars.erase(it); + unsetenv(var_name.c_str()); + return JNI_TRUE; + } + return JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_clearAllFreedrenoEnv( + JNIEnv* /* env */, jclass /* clazz */) { + if (!g_config) return; + for (const auto& entry : g_config->env_vars) { + unsetenv(entry.first.c_str()); + } + g_config->env_vars.clear(); +} + +JNIEXPORT jstring JNICALL +Java_app_gyrolet_mpvrx_utils_NativeFreedrenoConfig_getFreedrenoEnvSummary( + JNIEnv* env, jclass /* clazz */) { + if (!g_config || g_config->env_vars.empty()) return env->NewStringUTF(""); + std::string summary; + for (const auto& entry : g_config->env_vars) { + if (!summary.empty()) summary += ","; + summary += entry.first + "=" + entry.second; + } + return env->NewStringUTF(summary.c_str()); +} + } // extern "C" diff --git a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriver.kt b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriver.kt index ef5bbbce8..201ad7d5d 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriver.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriver.kt @@ -12,5 +12,13 @@ data class GpuDriver( val vendor: String = "", val driverPath: String, // Path to the extracted driver directory val vulkanLibName: String, // Usually libvulkan_freedreno.so - val isSystem: Boolean = false + val isSystem: Boolean = false, + // Epoch millis when the driver was installed. 0 = unknown (older installs + // from before this field existed). Surfaced as an info chip in the UI. + val installDate: Long = 0L, + // Total on-disk size of the extracted driver dir. 0 = not measured. + val fileSizeBytes: Long = 0L, + // Minimum Vulkan API version declared in meta.json (e.g. "1.1.0"). + // Display-only; not used for compatibility gating. + val minApi: String = "", ) diff --git a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverBridge.kt b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverBridge.kt index 80833be0e..87a4e9b1f 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverBridge.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverBridge.kt @@ -1,21 +1,37 @@ package app.gyrolet.mpvrx.domain.gpu +import android.util.Log + object GpuDriverBridge { + private const val TAG = "GpuDriverBridge" private var isLibraryLoaded = false init { + // One-shot library load — log directly via android.util.Log because + // this runs the first time the object is touched, which can be + // before Koin starts (GpuLog requires Koin to be up). try { System.loadLibrary("adrenotools_bridge") isLibraryLoaded = true - android.util.Log.i("GpuDriverBridge", "Successfully loaded adrenotools_bridge") + Log.i(TAG, "libadrenotools_bridge.so loaded") } catch (t: Throwable) { isLibraryLoaded = false - android.util.Log.e("GpuDriverBridge", "Failed to load adrenotools_bridge", t) + // Expected on non-arm64 devices where the .so isn't packaged. + Log.w(TAG, "libadrenotools_bridge.so not loaded: ${t.message}") } } fun isAvailable(): Boolean = isLibraryLoaded + // Loads the custom Vulkan driver via adrenotools and arms ByteHook so any + // subsequent dlopen("libvulkan.so") from libmpv/libplacebo/libav* returns + // the same handle. Caller supplies absolute paths; nulls/empties skip + // optional features. + // hookLibDir - applicationInfo.nativeLibraryDir (adrenotools hook .so live here) + // customDriverDir - parent dir of the custom .so (or null for system) + // customDriverName - filename, e.g. "libvulkan_freedreno.so" + // fileRedirectDir - dir for ADRENOTOOLS_DRIVER_FILE_REDIRECT (null disables) + // tmpDir - writable scratch dir for adrenotools' linker shim external fun setDriver( hookLibDir: String?, customDriverDir: String?, @@ -27,4 +43,29 @@ object GpuDriverBridge { external fun isAdrenoDevice(): Boolean external fun getGpuInfo(): String + + /** + * Returns true if a Vulkan handle is currently loaded in the native + * bridge (custom or system). Cheap — a mutex-guarded null check. + */ + external fun isDriverActive(): Boolean + + /** + * Returns true once `my_vkCreateInstance` has successfully routed a + * vkCreateInstance call through the custom driver handle. Stays false + * if the Android Vulkan loader bypassed our hooks and the instance + * was created against the OEM driver. UI uses this to distinguish + * "armed and working" from "armed but silently overridden". + */ + external fun wasHookUsed(): Boolean + + /** + * Unhooks all ByteHook stubs and clears the driver handle so libmpv + * stops being routed through the custom driver. Idempotent. The + * underlying .so is NOT dlclose'd to avoid use-after-free crashes + * from live Vulkan objects; the handle leaks for the process + * lifetime — acceptable, since drivers only get swapped a few times + * per session at most. + */ + external fun unloadDriver(): Boolean } diff --git a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverManager.kt b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverManager.kt index 736f0f0f7..7ea145306 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverManager.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverManager.kt @@ -3,43 +3,54 @@ package app.gyrolet.mpvrx.domain.gpu import android.content.Context import android.net.Uri import android.os.Build -import android.util.Log +import android.os.SystemClock +import app.gyrolet.mpvrx.utils.GpuLog import okhttp3.OkHttpClient import okhttp3.Request import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream +import java.io.IOException import java.util.UUID import java.util.zip.ZipInputStream +private const val TAG = "GpuDriverManager" + +// ELF magic bytes: 0x7f 'E' 'L' 'F'. Any extracted .so we plan to dlopen +// MUST start with these four bytes — otherwise the package is corrupt or +// the user dropped in a non-driver zip and we'd crash inside the linker. +private val ELF_MAGIC = byteArrayOf(0x7f, 0x45, 0x4c, 0x46) + class GpuDriverManager(private val context: Context, private val okHttpClient: OkHttpClient) { - private val driversDir: File - get() { - val dir = File(context.filesDir, "gpu_drivers") - if (!dir.exists()) dir.mkdirs() - return dir - } + private val driversDir = File(context.filesDir, "gpu_drivers") + private val json = Json { ignoreUnknownKeys = true } - private fun getSafGpuDriverFolder(): androidx.documentfile.provider.DocumentFile? { - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context) - val baseStorageFolder = prefs.getString("base_storage_folder", "") ?: "" - if (baseStorageFolder.isBlank()) return null - - return try { - val tree = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, Uri.parse(baseStorageFolder)) - val gpuFolder = tree?.findFile("gpudriver") - if (gpuFolder != null && gpuFolder.isDirectory) gpuFolder else null - } catch (e: Exception) { - null + // Installed-drivers cache. Reads are cheap individually but happen on + // startup AND every screen open AND every driver selection. Invalidated + // on install/delete AND auto-invalidated via mtime signature when the + // gpu_drivers/ contents change externally (manual file-manager edits). + @Volatile private var installedCache: List? = null + @Volatile private var installedSignature: Map? = null + + // Remote-fetch cache, persisted to a temp file in cacheDir so it survives + // app restarts. GitHub anon API allows 60 req/hr/IP and we issue 6 + // requests per fetch — without this, ~10 screen opens exhaust the limit. + private val remoteCacheFile = File(context.cacheDir, "gpu_remote_drivers_cache.json") + private val remoteCacheTtlMs = 60 * 60 * 1000L // 1 hour + + init { + if (!driversDir.exists()) { + driversDir.mkdirs() } } - private val json = Json { ignoreUnknownKeys = true } - private val repoList = listOf( DriverRepo("Eden Adreno Tools", "eden-emulator/libadrenotools", 0), DriverRepo("Mr. Purple Turnip", "MrPurple666/purple-turnip", 1), @@ -102,7 +113,44 @@ class GpuDriverManager(private val context: Context, private val okHttpClient: O return driverMap.firstOrNull { adrenoModel in it.first }?.second ?: "Unsupported" } + /** Total on-disk size of the driver dir, in bytes. 0 if not present. */ + fun getDriverDiskSize(driverPath: String): Long { + val dir = File(driverPath) + if (!dir.exists()) return 0L + return dir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } + + /** Sum of all installed driver sizes, in bytes. Used by the UI summary. */ + fun getTotalDriversDiskSize(): Long = + driversDir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + + /** + * Resolves the Adreno GPU model number for the current device. Used by + * the recommendation UI (DeviceInfoHub + fetch sheet). Prefers a + * Build.SOC_MODEL / Build.BOARD lookup over parsing the native bridge's + * string, because the bridge currently only reports the generic + * "Qualcomm Adreno GPU Detected" without a model number — which would + * always parse to 0 and always recommend "Unsupported". + */ + fun getAdrenoModelNumber(): Int { + val fromBuild = app.gyrolet.mpvrx.utils.AdrenoLookup.resolve() + if (fromBuild > 0) return fromBuild + return parseAdrenoModel(getGpuModel()) + } + fun getInstalledDriversSync(): List { + // Fast path: compute the current mtime signature (cheap: 1 listFiles + + // N stat calls). If it matches the cached snapshot the contents + // haven't changed — return memoized list without reading any JSON. + // This also catches manual file-manager edits the user might do. + val currentSig = computeInstalledSignature() + val cached = installedCache + if (cached != null && installedSignature == currentSig) { + GpuLog.v(TAG) { "installed-cache HIT (${cached.size} drivers, sig=$currentSig)" } + return cached + } + GpuLog.d(TAG) { "installed-cache MISS — rescanning $driversDir (cachedSig=$installedSignature, currentSig=$currentSig)" } + val drivers = mutableListOf() drivers.add(GpuDriver("system", "System Default", "Use the built-in system GPU driver", isSystem = true, driverPath = "", vulkanLibName = "")) @@ -112,25 +160,17 @@ class GpuDriverManager(private val context: Context, private val okHttpClient: O try { val metaContent = metaFile.readText() val metaJson = json.parseToJsonElement(metaContent).jsonObject - var expectedLibName = metaJson["vulkanLibName"]?.jsonPrimitive?.content ?: "libvulkan_freedreno.so" - var actualDriverDir = dir.absolutePath - var foundLibName = expectedLibName - val soFiles = dir.walkTopDown().filter { it.isFile && it.name.endsWith(".so") }.toList() - if (soFiles.isNotEmpty()) { - val match = soFiles.find { it.name == expectedLibName } - ?: soFiles.find { it.name.contains("vulkan", ignoreCase = true) } - ?: soFiles.first() - - actualDriverDir = match.parentFile?.absolutePath ?: dir.absolutePath - foundLibName = match.name - - // Ensure executable and read-only permissions for all found .so files to fix existing installations - soFiles.forEach { - it.setExecutable(true, false) - it.setReadOnly() - } - } - + val (actualDir, actualLib) = resolveDriverLibAndDir( + dir, + metaJson["vulkanLibName"]?.jsonPrimitive?.content ?: "libvulkan_freedreno.so", + ) + // installDate: prefer the value written into meta.json + // at install time; fall back to the dir's mtime so even + // older installs (pre-installDate-field) show something. + val installDate = metaJson["installDate"]?.jsonPrimitive?.longOrNull + ?: dir.lastModified() + val totalBytes = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + drivers.add(GpuDriver( id = dir.name, name = metaJson["name"]?.jsonPrimitive?.content ?: dir.name, @@ -138,27 +178,58 @@ class GpuDriverManager(private val context: Context, private val okHttpClient: O author = metaJson["author"]?.jsonPrimitive?.content ?: "", version = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "", vendor = metaJson["vendor"]?.jsonPrimitive?.content ?: "", - driverPath = actualDriverDir, - vulkanLibName = foundLibName + driverPath = actualDir, + vulkanLibName = actualLib, + installDate = installDate, + fileSizeBytes = totalBytes, + minApi = metaJson["minApi"]?.jsonPrimitive?.content ?: "", )) } catch (e: Exception) { - Log.e("GpuDriverManager", "Failed to parse meta.json in ${dir.name}", e) + GpuLog.e(TAG, "Failed to parse meta.json in ${dir.name}", e) } } } + installedCache = drivers + installedSignature = currentSig + GpuLog.d(TAG) { "installed-cache rebuilt: ${drivers.size} drivers (incl. system)" } return drivers } + // Cheap snapshot of the gpu_drivers/ dir: { subfolderName -> mtime }. + // Subfolder mtime changes whenever any file inside (including meta.json) + // is added/removed/replaced, so this catches external edits without + // having to actually read any file contents. + private fun computeInstalledSignature(): Map { + return driversDir.listFiles() + ?.filter { it.isDirectory } + ?.associate { it.name to it.lastModified() } + ?: emptyMap() + } + suspend fun getInstalledDrivers(): List = withContext(Dispatchers.IO) { getInstalledDriversSync() } - suspend fun fetchRemoteDriverGroups(): List = withContext(Dispatchers.IO) { - repoList.map { repo -> + suspend fun fetchRemoteDriverGroups(forceRefresh: Boolean = false): List = withContext(Dispatchers.IO) { + if (forceRefresh) { + GpuLog.d(TAG) { "fetchRemoteDriverGroups: forceRefresh — bypassing disk cache" } + } else { + readRemoteCache()?.let { + GpuLog.v(TAG) { "remote-cache HIT (${it.size} groups, ${it.sumOf { g -> g.releases.size }} releases)" } + return@withContext it + } + GpuLog.d(TAG) { "remote-cache MISS — fetching from GitHub" } + } + val fetchStart = SystemClock.uptimeMillis() + val groups = repoList.map { repo -> async { + val perRepoStart = SystemClock.uptimeMillis() try { val request = Request.Builder() - .url("https://api.github.com/repos/${repo.path}/releases") + // per_page=10 — only the 10 most-recent releases per + // repo. Default is 30; cuts response size ~3× and + // keeps anonymous rate-limit headroom. + .url("https://api.github.com/repos/${repo.path}/releases?per_page=10") .header("Accept", "application/vnd.github.v3+json") .header("User-Agent", "MpvRx-GpuFetcher") .build() @@ -166,7 +237,7 @@ class GpuDriverManager(private val context: Context, private val okHttpClient: O val response = okHttpClient.newCall(request).execute() if (!response.isSuccessful) throw Exception("HTTP ${response.code}") - val content = response.body.string() + val content = response.body?.string() ?: "" val releasesJson = json.parseToJsonElement(content).jsonArray val remoteReleases = releasesJson.mapIndexed { index, release -> @@ -196,125 +267,205 @@ class GpuDriverManager(private val context: Context, private val okHttpClient: O ) }.filter { it.drivers.isNotEmpty() } - RemoteDriverGroup(repo.name, remoteReleases, repo.sort) + val group = RemoteDriverGroup(repo.name, remoteReleases, repo.sort) + GpuLog.v(TAG) { "fetched '${repo.name}' in ${SystemClock.uptimeMillis() - perRepoStart}ms: ${remoteReleases.size} releases" } + group } catch (e: Exception) { - Log.e("GpuDriverManager", "Failed to fetch from ${repo.name}: ${e.message}") + GpuLog.w(TAG, "Failed to fetch from ${repo.name} after ${SystemClock.uptimeMillis() - perRepoStart}ms: ${e.message}") RemoteDriverGroup(repo.name, emptyList(), repo.sort) } } }.awaitAll().sortedBy { it.sort } + GpuLog.d(TAG) { "fetchRemoteDriverGroups: ${repoList.size} repos done in ${SystemClock.uptimeMillis() - fetchStart}ms, total ${groups.sumOf { it.releases.size }} releases" } + // Only cache when at least one repo returned data — empty responses + // usually mean rate-limit or transient network error and we'd rather + // retry next time than serve empty for an hour. + if (groups.any { it.releases.isNotEmpty() }) { + writeRemoteCache(groups) + } else { + GpuLog.w(TAG, "All repo fetches returned empty — not caching (likely rate-limited or offline)") + } + groups + } + + private fun readRemoteCache(): List? { + if (!remoteCacheFile.exists()) { + GpuLog.v(TAG) { "remote-cache file does not exist" } + return null + } + return try { + val envelope = json.decodeFromString(remoteCacheFile.readText()) + val ageMs = System.currentTimeMillis() - envelope.timestamp + if (ageMs < remoteCacheTtlMs) { + GpuLog.v(TAG) { "remote-cache file age=${ageMs}ms (< ${remoteCacheTtlMs}ms TTL) — valid" } + envelope.groups + } else { + GpuLog.d(TAG) { "remote-cache expired (age=${ageMs}ms) — deleting file" } + // Expired — drop the file so we don't read it again next time. + remoteCacheFile.delete() + null + } + } catch (e: Exception) { + // Corrupt file (partial write, format change, etc.) — discard. + GpuLog.w(TAG, "remote-cache file corrupt — discarding: ${e.message}") + remoteCacheFile.delete() + null + } + } + + private fun writeRemoteCache(groups: List) { + try { + val envelope = RemoteCacheFile(System.currentTimeMillis(), groups) + remoteCacheFile.writeText(json.encodeToString(envelope)) + GpuLog.v(TAG) { "remote-cache file written: ${remoteCacheFile.length()} bytes" } + } catch (e: Exception) { + GpuLog.w(TAG, "Failed to persist remote cache: ${e.message}") + } } suspend fun installDriver(zipUri: Uri): Result = withContext(Dispatchers.IO) { + val installStart = SystemClock.uptimeMillis() try { val tempId = UUID.randomUUID().toString() val targetDir = File(driversDir, tempId) targetDir.mkdirs() + GpuLog.d(TAG) { "installDriver: extracting $zipUri → $targetDir" } + var entryCount = 0 + var totalBytes = 0L + val targetCanonical = targetDir.canonicalPath context.contentResolver.openInputStream(zipUri)?.use { inputStream -> ZipInputStream(inputStream).use { zipStream -> var entry = zipStream.nextEntry while (entry != null) { val file = File(targetDir, entry.name) + // Zip-slip guard: reject entries whose resolved path + // escapes targetDir (e.g., name = "../../databases/x"). + if (!file.canonicalPath.startsWith(targetCanonical + File.separator) && + file.canonicalPath != targetCanonical) { + GpuLog.e(TAG, "zip-slip blocked: '${entry.name}' would escape $targetCanonical") + targetDir.deleteRecursively() + return@withContext Result.failure( + SecurityException("Invalid ZIP entry path: ${entry.name}") + ) + } if (entry.isDirectory) { file.mkdirs() } else { file.parentFile?.mkdirs() FileOutputStream(file).use { output -> - zipStream.copyTo(output) + totalBytes += zipStream.copyTo(output) } + // Extracted .so files must be executable for the + // dynamic linker to mmap them. ZIP doesn't preserve + // POSIX mode bits reliably across producers, so set + // it explicitly here. if (file.name.endsWith(".so")) { file.setExecutable(true, false) } + entryCount++ } zipStream.closeEntry() entry = zipStream.nextEntry } } } + GpuLog.v(TAG) { "extracted $entryCount files, $totalBytes bytes" } val metaFile = File(targetDir, "meta.json") if (!metaFile.exists()) { + GpuLog.w(TAG, "installDriver: zip missing meta.json — rejecting") targetDir.deleteRecursively() return@withContext Result.failure(Exception("Missing meta.json in driver package")) } val metaContent = metaFile.readText() val metaJson = json.parseToJsonElement(metaContent).jsonObject - var expectedLibName = metaJson["vulkanLibName"]?.jsonPrimitive?.content ?: "libvulkan_freedreno.so" - - // Search for the actual driver file in case it's in a subdirectory or has a different name - var actualDriverDir = targetDir.absolutePath - var foundLibName = expectedLibName - val soFiles = targetDir.walkTopDown().filter { it.isFile && it.name.endsWith(".so") }.toList() - if (soFiles.isNotEmpty()) { - val match = soFiles.find { it.name == expectedLibName } - ?: soFiles.find { it.name.contains("vulkan", ignoreCase = true) } - ?: soFiles.first() - - actualDriverDir = match.parentFile?.absolutePath ?: targetDir.absolutePath - foundLibName = match.name - - // Ensure executable and read-only permissions for all found .so files - soFiles.forEach { - it.setExecutable(true, false) - it.setReadOnly() - } + val (actualDir, actualLib) = resolveDriverLibAndDir( + targetDir, + metaJson["vulkanLibName"]?.jsonPrimitive?.content ?: "libvulkan_freedreno.so", + ) + + // ELF magic check: the .so we'll actually dlopen must be a real + // ELF binary. Catches the "user dropped a non-driver zip" case + // before adrenotools_open_libvulkan crashes the process. + val mainLib = File(actualDir, actualLib) + if (mainLib.exists() && !isValidElfBinary(mainLib)) { + GpuLog.e(TAG, "installDriver: '$actualLib' is not a valid ELF binary — rejecting") + targetDir.deleteRecursively() + return@withContext Result.failure( + Exception("Driver file '$actualLib' is not a valid ELF binary. The package may be corrupt."), + ) } - + + val driverName = metaJson["name"]?.jsonPrimitive?.content ?: tempId + val driverVersion = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "" + + // Dedup by name + version: if the same combo is already installed, + // wipe the old dir so the new one replaces it cleanly. excludeId + // keeps us from deleting the dir we just extracted to. + findExistingDriver(driverName, driverVersion, excludeId = tempId)?.let { existingId -> + GpuLog.i(TAG) { "replacing existing driver '$driverName' v$driverVersion (oldId=$existingId)" } + File(driversDir, existingId).deleteRecursively() + } + + // Stamp installDate into meta.json so future scans don't have to + // fall back to the dir's mtime (which the OS may change for + // unrelated reasons — touch by external tools, restore-from-backup). + val installTimestamp = System.currentTimeMillis() + runCatching { + val updatedMeta = buildJsonObject { + metaJson.forEach { (k, v) -> put(k, v) } + put("installDate", installTimestamp) + } + metaFile.writeText(updatedMeta.toString()) + }.onFailure { GpuLog.w(TAG, "could not stamp installDate into meta.json: ${it.message}") } + + val finalSize = targetDir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + val driver = GpuDriver( id = tempId, - name = metaJson["name"]?.jsonPrimitive?.content ?: tempId, + name = driverName, description = metaJson["description"]?.jsonPrimitive?.content ?: "", author = metaJson["author"]?.jsonPrimitive?.content ?: "", - version = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "", + version = driverVersion, vendor = metaJson["vendor"]?.jsonPrimitive?.content ?: "", - driverPath = actualDriverDir, - vulkanLibName = foundLibName + driverPath = actualDir, + vulkanLibName = actualLib, + installDate = installTimestamp, + fileSizeBytes = finalSize, + minApi = metaJson["minApi"]?.jsonPrimitive?.content ?: "", ) - - // Backup the zip to the SAF folder if possible - try { - val safFolder = getSafGpuDriverFolder() - if (safFolder != null) { - val zipFileName = driver.name.replace(Regex("[^a-zA-Z0-9.-]"), "_") + ".zip" - var destFile = safFolder.findFile(zipFileName) - if (destFile == null) { - destFile = safFolder.createFile("application/zip", zipFileName) - } - if (destFile != null) { - context.contentResolver.openInputStream(zipUri)?.use { input -> - context.contentResolver.openOutputStream(destFile.uri)?.use { output -> - input.copyTo(output) - } - } - } - } - } catch (e: Exception) { - Log.e("GpuDriverManager", "Failed to backup zip to SAF", e) - } - + // Mirror the install zip into the user's SAF gpudriver/ folder + // (if configured) so they can recover it without re-downloading. + runCatching { backupZipToSaf(zipUri, driver.name) } + .onFailure { GpuLog.w(TAG, "SAF zip backup failed: ${it.message}") } + installedCache = null + GpuLog.i(TAG) { "installed '${driver.name}' v${driver.version} (id=${driver.id}) in ${SystemClock.uptimeMillis() - installStart}ms" } Result.success(driver) } catch (e: Exception) { - Log.e("GpuDriverManager", "Failed to install driver", e) + GpuLog.e(TAG, "Failed to install driver after ${SystemClock.uptimeMillis() - installStart}ms", e) Result.failure(e) } } suspend fun downloadAndInstallDriver(remoteDriver: RemoteGpuDriver, onProgress: (Float) -> Unit): Result = withContext(Dispatchers.IO) { + val dlStart = SystemClock.uptimeMillis() try { val tempFile = File(context.cacheDir, "driver_download.zip") + GpuLog.d(TAG) { "download: ${remoteDriver.name} from ${remoteDriver.downloadUrl}" } val request = Request.Builder() .url(remoteDriver.downloadUrl) .header("User-Agent", "MpvRx-GpuFetcher") .build() - + val response = okHttpClient.newCall(request).execute() if (!response.isSuccessful) throw Exception("HTTP ${response.code}") - - val body = response.body + + val body = response.body ?: throw Exception("Empty response body") val totalSize = body.contentLength() - + GpuLog.v(TAG) { "download: ${totalSize} bytes expected" } + body.byteStream().use { input -> FileOutputStream(tempFile).use { output -> val buffer = ByteArray(8192) @@ -329,56 +480,192 @@ class GpuDriverManager(private val context: Context, private val okHttpClient: O } } } - + GpuLog.d(TAG) { "download: complete in ${SystemClock.uptimeMillis() - dlStart}ms" } val result = installDriver(Uri.fromFile(tempFile)) tempFile.delete() result } catch (e: Exception) { - Log.e("GpuDriverManager", "Failed to download and install driver", e) + GpuLog.e(TAG, "Failed to download and install driver after ${SystemClock.uptimeMillis() - dlStart}ms", e) Result.failure(e) } } fun deleteDriver(id: String) { val dir = File(driversDir, id) + // Read the driver name from meta.json BEFORE wiping the dir so we + // can also remove its SAF backup zip (otherwise the storage section + // keeps showing an "Install" entry for the driver the user just + // deleted — confusing because they expected delete to mean "gone"). + val driverName = runCatching { + File(dir, "meta.json").takeIf { it.exists() } + ?.readText() + ?.let { json.parseToJsonElement(it).jsonObject["name"]?.jsonPrimitive?.content } + }.getOrNull() + if (dir.exists()) { dir.deleteRecursively() + GpuLog.i(TAG) { "deleted driver id=$id" } + } else { + GpuLog.w(TAG, "deleteDriver: id=$id not found on disk") + } + + if (!driverName.isNullOrBlank()) { + runCatching { deleteSafBackupByName(driverName) } + .onFailure { GpuLog.w(TAG, "failed to clean SAF backup for '$driverName': ${it.message}") } + } + + installedCache = null + } + + // Removes the auto-created SAF backup zip (if any) that backupZipToSaf + // wrote during installDriver. Filename derivation mirrors backupZipToSaf + // so they stay in lockstep. + private fun deleteSafBackupByName(driverName: String) { + val safFolder = getSafGpuDriverFolder() ?: return + val safeName = driverName.replace(Regex("[^a-zA-Z0-9.-]"), "_") + ".zip" + val zipFile = safFolder.findFile(safeName) ?: return + if (zipFile.delete()) { + GpuLog.v(TAG) { "deleted SAF backup gpudriver/$safeName" } + } else { + GpuLog.w(TAG, "could not delete SAF backup gpudriver/$safeName") + } + } + + // Sniffs the first four bytes against the ELF magic. Returns true only if + // the file exists, is readable, and starts with \x7fELF. Lazy stream — no + // need to read the whole .so just to validate the header. + private fun isValidElfBinary(file: File): Boolean { + if (!file.isFile || file.length() < 4) return false + return try { + FileInputStream(file).use { fis -> + val header = ByteArray(4) + fis.read(header) == 4 && header.contentEquals(ELF_MAGIC) + } + } catch (e: IOException) { + GpuLog.w(TAG, "ELF check failed for ${file.name}: ${e.message}") + false + } + } + + // Looks for a previously-installed driver with the same name + version. + // Used by installDriver to atomically replace an old install when the + // user reinstalls the same release (otherwise duplicates pile up). + // excludeId skips a specific dir name so we don't delete the one we + // just extracted to. + private fun findExistingDriver(name: String, version: String, excludeId: String? = null): String? { + driversDir.listFiles()?.filter { it.isDirectory }?.forEach { dir -> + if (dir.name == excludeId) return@forEach + val metaFile = File(dir, "meta.json") + if (!metaFile.exists()) return@forEach + try { + val obj = json.parseToJsonElement(metaFile.readText()).jsonObject + val existingName = obj["name"]?.jsonPrimitive?.content ?: "" + val existingVersion = obj["driverVersion"]?.jsonPrimitive?.content ?: "" + if (existingName == name && existingVersion == version) return dir.name + } catch (_: Exception) { /* ignore unparseable metadata */ } + } + return null + } + + // Walk a driver dir to find the actual vulkan library — driver packages + // ship inconsistent layouts: some keep libvulkan_freedreno.so at the root, + // some bury it under arm64-v8a/, some rename it. Prefer an exact filename + // match, then any .so with "vulkan" in the name, then any .so as a last + // resort. Returns (parentDir, fileName) of the chosen .so, or the fallback + // (dir, expectedLib) if no .so files exist. + // + // As a side effect, marks every .so found executable (the linker needs it) + // and read-only (defensive against stray edits at runtime). + private fun resolveDriverLibAndDir(dir: File, expectedLib: String): Pair { + val soFiles = dir.walkTopDown().filter { it.isFile && it.name.endsWith(".so") }.toList() + if (soFiles.isEmpty()) return dir.absolutePath to expectedLib + val match = soFiles.firstOrNull { it.name == expectedLib } + ?: soFiles.firstOrNull { it.name.contains("vulkan", ignoreCase = true) } + ?: soFiles.first() + soFiles.forEach { + it.setExecutable(true, false) + it.setReadOnly() + } + return (match.parentFile?.absolutePath ?: dir.absolutePath) to match.name + } + + // Resolves the user-selected SAF root and returns its `gpudriver/` + // subfolder if present. Pulls the URI from the same preference key used + // by AdvancedPreferencesScreen so external storage stays a single source + // of truth. + private fun getSafGpuDriverFolder(): androidx.documentfile.provider.DocumentFile? { + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context) + val baseStorageFolder = prefs.getString("base_storage_folder", "") ?: "" + if (baseStorageFolder.isBlank()) return null + return try { + val tree = androidx.documentfile.provider.DocumentFile.fromTreeUri( + context, + Uri.parse(baseStorageFolder), + ) + tree?.findFile("gpudriver")?.takeIf { it.isDirectory } + } catch (_: Exception) { + null + } + } + + private fun backupZipToSaf(zipUri: Uri, driverName: String) { + val safFolder = getSafGpuDriverFolder() ?: return + val safeName = driverName.replace(Regex("[^a-zA-Z0-9.-]"), "_") + ".zip" + val destFile = safFolder.findFile(safeName) + ?: safFolder.createFile("application/zip", safeName) + ?: return + context.contentResolver.openInputStream(zipUri)?.use { input -> + context.contentResolver.openOutputStream(destFile.uri)?.use { output -> + input.copyTo(output) + } } + GpuLog.v(TAG) { "backed up '$driverName' zip to SAF gpudriver/$safeName" } } + /** + * Scans the user's SAF `gpudriver/` folder for zip packages and parses + * each one's `meta.json` (read-only — does not extract). Used by the GPU + * driver preferences screen to surface "drag-and-drop" drivers the user + * dropped into the folder without launching the file-picker flow. + */ suspend fun getSafDrivers(): List = withContext(Dispatchers.IO) { val drivers = mutableListOf() val safFolder = getSafGpuDriverFolder() ?: return@withContext drivers - - safFolder.listFiles().filter { it.name?.endsWith(".zip", ignoreCase = true) == true }.forEach { zipFile -> - try { - context.contentResolver.openInputStream(zipFile.uri)?.use { inputStream -> - java.util.zip.ZipInputStream(inputStream).use { zipStream -> - var entry = zipStream.nextEntry - while (entry != null) { - if (!entry.isDirectory && entry.name.lowercase().endsWith("meta.json")) { - val metaContent = String(zipStream.readBytes()) - val metaJson = json.parseToJsonElement(metaContent).jsonObject - drivers.add(SafGpuDriver( - uri = zipFile.uri, - name = metaJson["name"]?.jsonPrimitive?.content ?: zipFile.name ?: "Unknown", - description = metaJson["description"]?.jsonPrimitive?.content ?: "", - author = metaJson["author"]?.jsonPrimitive?.content ?: "", - version = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "", - vendor = metaJson["vendor"]?.jsonPrimitive?.content ?: "", - fileSize = zipFile.length() - )) - break + + safFolder.listFiles() + .filter { it.name?.endsWith(".zip", ignoreCase = true) == true } + .forEach { zipFile -> + try { + context.contentResolver.openInputStream(zipFile.uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.lowercase().endsWith("meta.json")) { + val metaContent = String(zipStream.readBytes()) + val metaJson = json.parseToJsonElement(metaContent).jsonObject + drivers.add( + SafGpuDriver( + uri = zipFile.uri, + name = metaJson["name"]?.jsonPrimitive?.content + ?: zipFile.name ?: "Unknown", + description = metaJson["description"]?.jsonPrimitive?.content ?: "", + author = metaJson["author"]?.jsonPrimitive?.content ?: "", + version = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "", + vendor = metaJson["vendor"]?.jsonPrimitive?.content ?: "", + fileSize = zipFile.length(), + ), + ) + break + } + zipStream.closeEntry() + entry = zipStream.nextEntry } - zipStream.closeEntry() - entry = zipStream.nextEntry } } + } catch (e: Exception) { + GpuLog.w(TAG, "Failed to parse SAF zip ${zipFile.name}: ${e.message}") } - } catch (e: Exception) { - Log.e("GpuDriverManager", "Failed to parse SAF zip: ${zipFile.name}", e) } - } drivers } @@ -389,11 +676,19 @@ class GpuDriverManager(private val context: Context, private val okHttpClient: O val author: String, val version: String, val vendor: String, - val fileSize: Long + val fileSize: Long, ) + @Serializable data class RemoteGpuDriver(val name: String, val downloadUrl: String) + @Serializable data class RemoteRelease(val title: String, val version: String, val isLatest: Boolean, val drivers: List) + @Serializable data class RemoteDriverGroup(val name: String, val releases: List, val sort: Int) private data class DriverRepo(val name: String, val path: String, val sort: Int, val useTagName: Boolean = false) + + // Disk cache envelope: timestamp lets us check TTL without trusting + // the OS file mtime (which can be wrong after timezone/clock changes). + @Serializable + private data class RemoteCacheFile(val timestamp: Long, val groups: List) } diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/GpuDriverPreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/GpuDriverPreferences.kt index 9db31ce06..9a230a69b 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/preferences/GpuDriverPreferences.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/GpuDriverPreferences.kt @@ -8,4 +8,14 @@ class GpuDriverPreferences( val activeDriverId = preferenceStore.getString("gpu_driver_active_id", "system") val showDriverHud = preferenceStore.getBoolean("gpu_driver_show_hud", false) val hasAcceptedGpuWarning = preferenceStore.getBoolean("gpu_driver_warning_accepted", false) + // OFF by default — gates v/d/i log emission across the GPU driver + // subsystem so we don't spam logcat (or do unnecessary string building) + // in normal usage. Errors/warnings remain unconditional. + val verboseLogging = preferenceStore.getBoolean("gpu_driver_verbose_logging", false) + + // Set by the crash-loop guard when a stale loading-marker is detected at + // app start: the previous launch died while a custom driver was active. + // The next UI surface (MPVView creation) reads + clears it and toasts the + // user. Empty string = no pending notice. + val crashRevertedFrom = preferenceStore.getString("gpu_driver_crash_reverted_from", "") } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GpuDriverPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GpuDriverPreferencesScreen.kt index f4150baf9..c3d71a039 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GpuDriverPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GpuDriverPreferencesScreen.kt @@ -8,7 +8,7 @@ import android.os.Build import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import app.gyrolet.mpvrx.ui.components.AdaptiveSwitch +import app.gyrolet.mpvrx.ui.player.components.expressive.ExpressiveSwitch as AdaptiveSwitch import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -36,13 +36,17 @@ import app.gyrolet.mpvrx.domain.gpu.GpuDriverManager import app.gyrolet.mpvrx.preferences.GpuDriverPreferences import app.gyrolet.mpvrx.preferences.preference.collectAsState import app.gyrolet.mpvrx.presentation.Screen -import app.gyrolet.mpvrx.presentation.components.LiquidDialog +import app.gyrolet.mpvrx.ui.theme.LiquidGlassAlertDialog as LiquidDialog import app.gyrolet.mpvrx.ui.icons.Icon import app.gyrolet.mpvrx.ui.icons.Icons as AppIcons import app.gyrolet.mpvrx.ui.utils.LocalBackStack import app.gyrolet.mpvrx.ui.utils.popSafely - +import app.gyrolet.mpvrx.utils.GpuDriverHelper +import app.gyrolet.mpvrx.utils.GpuDriverLogger +import app.gyrolet.mpvrx.utils.NativeFreedrenoConfig +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.koin.compose.koinInject @@ -70,22 +74,26 @@ object GpuDriverPreferencesScreen : Screen { val expandedReleases = remember { mutableStateMapOf() } val activeDriverId by preferences.activeDriverId.collectAsState() - val hasAcceptedWarning by preferences.hasAcceptedGpuWarning.collectAsState() - var showCompatibilityWarning by remember { mutableStateOf(!hasAcceptedWarning && !driverManager.isAdrenoSupported()) } val isArm64 = remember { Build.SUPPORTED_ABIS.contains("arm64-v8a") } val isQualcomm = remember { driverManager.isAdrenoSupported() } val isSupported = isArm64 && isQualcomm - val gpuModel = remember { + // Prefer a Build.SOC_MODEL / Build.BOARD lookup for the display + // label too — the native bridge string is the generic + // "Qualcomm Adreno GPU Detected" on every Adreno device. Fall back + // to the bridge string only if AdrenoLookup doesn't know the SoC. + val gpuModel = remember { val bridgeInfo = driverManager.getGpuModel() - if (bridgeInfo.contains("Architecture Not Supported") || bridgeInfo.contains("Generic GPU")) { - "Detected ${Build.HARDWARE} (${Build.MODEL})" - } else { - bridgeInfo + val enriched = app.gyrolet.mpvrx.utils.AdrenoLookup.displayLabel() + when { + bridgeInfo.contains("Architecture Not Supported") -> bridgeInfo + enriched != null -> enriched + bridgeInfo.contains("Generic GPU") -> "Detected ${Build.HARDWARE} (${Build.MODEL})" + else -> bridgeInfo } } - val adrenoModel = remember { driverManager.parseAdrenoModel(gpuModel) } + val adrenoModel = remember { driverManager.getAdrenoModelNumber() } val recommendedDriver = remember { driverManager.getRecommendedDriver(adrenoModel) } LaunchedEffect(Unit) { @@ -141,37 +149,6 @@ object GpuDriverPreferencesScreen : Screen { .padding(padding) .fillMaxSize() ) { - if (showCompatibilityWarning) { - LiquidDialog( - onDismissRequest = { /* Must accept or go back */ }, - title = { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { - Icon(AppIcons.Default.Aperture, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(28.dp)) - Spacer(modifier = Modifier.height(12.dp)) - Text("Compatibility Warning") - } - }, - text = { - Text("Custom GPU drivers are primarily designed for Qualcomm Adreno GPUs. Your device does not appear to have one.\n\nThere is no guarantee these drivers will work, and they could cause crashes or graphical glitches. Proceed with caution.") - }, - confirmButton = { - Button( - onClick = { - preferences.hasAcceptedGpuWarning.set(true) - showCompatibilityWarning = false - } - ) { - Text("I Understand") - } - }, - dismissButton = { - TextButton(onClick = { backstack.popSafely() }) { - Text("Go Back") - } - } - ) - } - if (showInfoDialog) { TechnicalInfoDialog(onDismiss = { showInfoDialog = false }) } @@ -187,11 +164,21 @@ object GpuDriverPreferencesScreen : Screen { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Icon(AppIcons.Outlined.Info, contentDescription = null, tint = MaterialTheme.colorScheme.error) Spacer(modifier = Modifier.width(16.dp)) - Text( - if (!isArm64) stringResource(R.string.gpu_driver_not_supported) - else "Custom drivers require a Qualcomm Adreno GPU.", - color = MaterialTheme.colorScheme.onErrorContainer - ) + Column { + Text( + "GPU is unsupported", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + if (!isArm64) stringResource(R.string.gpu_driver_not_supported) + else "Custom GPU drivers only work on Qualcomm Adreno hardware (no Mali, PowerVR, etc.). The driver loader is disabled on this device.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } } } } @@ -201,8 +188,42 @@ object GpuDriverPreferencesScreen : Screen { DeviceInfoHub(gpuModel, recommendedDriver) } + // Live driver status — green when a custom driver is + // armed, red when the last load failed and we auto- + // reverted to system. Sourced from GpuDriverHelper's + // mutable activeDriverStatus, which is set on every + // code path inside initialize(). Read inside a + // remember(activeDriverId) so the banner refreshes + // when the user changes the active driver pref. + item { + val status = remember(activeDriverId) { + app.gyrolet.mpvrx.utils.GpuDriverHelper.activeDriverStatus + } + when (status) { + app.gyrolet.mpvrx.utils.GpuDriverHelper.DriverStatus.CUSTOM_FAILED -> + StatusBanner( + icon = AppIcons.Outlined.Info, + text = "Custom driver failed to load. Automatically reverted to system driver.", + container = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f), + content = MaterialTheme.colorScheme.onErrorContainer, + accent = MaterialTheme.colorScheme.error, + ) + app.gyrolet.mpvrx.utils.GpuDriverHelper.DriverStatus.CUSTOM_ACTIVE -> + StatusBanner( + icon = AppIcons.Default.Check, + text = "Custom driver active and running", + container = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + content = MaterialTheme.colorScheme.onPrimaryContainer, + accent = MaterialTheme.colorScheme.primary, + bold = true, + ) + else -> Unit + } + } + item { val showHud by preferences.showDriverHud.collectAsState() + val verboseLogging by preferences.verboseLogging.collectAsState() PreferenceSectionHeader(title = "General Settings") PreferenceCard { Row( @@ -215,6 +236,16 @@ object GpuDriverPreferencesScreen : Screen { } AdaptiveSwitch(checked = showHud, onCheckedChange = { preferences.showDriverHud.set(it) }) } + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.gpu_driver_verbose_log), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.gpu_driver_verbose_log_summary), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) + } + AdaptiveSwitch(checked = verboseLogging, onCheckedChange = { preferences.verboseLogging.set(it) }) + } } } @@ -228,28 +259,61 @@ object GpuDriverPreferencesScreen : Screen { isSelected = activeDriverId == driver.id, onSelect = { if (activeDriverId != driver.id) { - preferences.activeDriverId.set(driver.id) - Toast.makeText(context, R.string.gpu_driver_restart_required, Toast.LENGTH_LONG).show() + // Live-swap: tear down the old bridge hooks and + // arm the new driver in-process. Saves the user a + // force-close + cold-start. Pref is only persisted + // after the bridge confirms the load. On failure + // applyDriverChange THROWS — we deliberately let + // it propagate so the app crashes with the real + // cause instead of silently falling back to the + // OEM driver. Boot-time guard recovers on the + // next launch. + scope.launch(Dispatchers.IO) { + GpuDriverHelper.applyDriverChange(context, driver.id) + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Driver '${driver.name}' applied — start a new video to use it", + Toast.LENGTH_LONG, + ).show() + } + } } }, onDelete = { scope.launch { + val wasActive = activeDriverId == driver.id driverManager.deleteDriver(driver.id) drivers.clear() drivers.addAll(driverManager.getInstalledDrivers()) safDrivers.clear() safDrivers.addAll(driverManager.getSafDrivers()) - if (activeDriverId == driver.id) { - preferences.activeDriverId.set("system") + if (wasActive) { + // Live-swap to system so the bridge isn't + // pointing at a now-deleted .so. + withContext(Dispatchers.IO) { + GpuDriverHelper.applyDriverChange(context, "system") + } } } } ) } - if (safDrivers.isNotEmpty()) { - item { PreferenceSectionHeader("Drivers in Storage (gpudriver/)") } - items(safDrivers, key = { it.uri.toString() }) { safDriver -> + // Hide SAF entries that duplicate an already-installed + // driver — showing an "Install" button next to a driver + // you've already installed is just noise. The auto- + // backup zip is still on disk for crash recovery; once + // the user uninstalls, the entry re-appears here so they + // can reinstall without redownloading. + val installedNames = drivers + .filterNot { it.isSystem } + .mapTo(mutableSetOf()) { it.name } + val visibleSafDrivers = safDrivers.filter { it.name !in installedNames } + + if (isSupported && visibleSafDrivers.isNotEmpty()) { + item { PreferenceSectionHeader(title = "Drivers in Storage (gpudriver/)") } + items(visibleSafDrivers, key = { it.uri.toString() }) { safDriver -> SafDriverItem( driver = safDriver, onInstall = { @@ -260,17 +324,30 @@ object GpuDriverPreferencesScreen : Screen { drivers.addAll(driverManager.getInstalledDrivers()) safDrivers.clear() safDrivers.addAll(driverManager.getSafDrivers()) - preferences.activeDriverId.set(result.getOrNull()?.id ?: "system") - Toast.makeText(context, "Driver installed and activated", Toast.LENGTH_SHORT).show() + // Auto-activate the freshly installed driver + // via the live-swap path so the user doesn't + // need a restart to try it out. Throws on + // failure (no silent OEM fallback). + val newId = result.getOrNull()?.id + if (newId != null) { + withContext(Dispatchers.IO) { + GpuDriverHelper.applyDriverChange(context, newId) + } + } + Toast.makeText(context, R.string.gpu_driver_install_success, Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, "Failed to install driver", Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.gpu_driver_install_failed, result.exceptionOrNull()?.message), + Toast.LENGTH_LONG, + ).show() } } - } + }, ) } } - + item { Spacer(modifier = Modifier.height(80.dp)) } } @@ -279,39 +356,64 @@ object GpuDriverPreferencesScreen : Screen { tonalElevation = 8.dp, color = MaterialTheme.colorScheme.surface ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Button( - onClick = { launcher.launch("application/zip") }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) + if (isSupported) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Icon(AppIcons.Default.FileUpload, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.gpu_driver_install_from_file), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Button( - onClick = { - showFetchSheet = true - if (remoteDriverGroups.isEmpty()) { - scope.launch { - isFetching = true - remoteDriverGroups.clear() - remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups()) - isFetching = false + Button( + onClick = { launcher.launch("application/zip") }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + Icon(AppIcons.Default.FileUpload, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.gpu_driver_install_from_file), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button( + onClick = { + showFetchSheet = true + if (remoteDriverGroups.isEmpty()) { + scope.launch { + isFetching = true + remoteDriverGroups.clear() + remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups()) + isFetching = false + } } - } - }, - modifier = Modifier.weight(1f) + }, + modifier = Modifier.weight(1f) + ) { + Icon(AppIcons.Default.Download, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.gpu_driver_fetch_drivers), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } else { + // Non-Adreno / non-arm64 hardware: replace the + // SAF picker and remote-driver downloader with a + // single warning so the user can't sideload or + // download an incompatible driver and crash the + // app on the next vkCreateInstance. + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon(AppIcons.Default.Download, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.gpu_driver_fetch_drivers), maxLines = 1, overflow = TextOverflow.Ellipsis) + Icon( + AppIcons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Custom GPU installation disabled for preventing playback crashes", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) } } } @@ -341,7 +443,7 @@ object GpuDriverPreferencesScreen : Screen { scope.launch { isFetching = true remoteDriverGroups.clear() - remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups()) + remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups(forceRefresh = true)) isFetching = false } }, @@ -376,7 +478,7 @@ object GpuDriverPreferencesScreen : Screen { scope.launch { isFetching = true remoteDriverGroups.clear() - remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups()) + remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups(forceRefresh = true)) isFetching = false } }) { @@ -429,8 +531,6 @@ object GpuDriverPreferencesScreen : Screen { if (result.isSuccess) { drivers.clear() drivers.addAll(driverManager.getInstalledDrivers()) - safDrivers.clear() - safDrivers.addAll(driverManager.getSafDrivers()) showFetchSheet = false Toast.makeText(context, R.string.gpu_driver_install_success, Toast.LENGTH_SHORT).show() } else { @@ -527,6 +627,67 @@ object GpuDriverPreferencesScreen : Screen { } } + @Composable + private fun SafDriverItem( + driver: GpuDriverManager.SafGpuDriver, + onInstall: () -> Unit, + ) { + Card( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp).fillMaxWidth(), + onClick = onInstall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(20.dp), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + AppIcons.Default.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + Text( + driver.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = listOfNotNull( + driver.version.takeIf { it.isNotEmpty() }?.let { "v$it" }, + driver.author.takeIf { it.isNotEmpty() }?.let { "by $it" }, + ).joinToString(" • ") + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Button( + onClick = onInstall, + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.height(32.dp), + ) { + Text("Install", style = MaterialTheme.typography.labelSmall) + } + } + } + } + @Composable private fun DriverItem( driver: GpuDriver, @@ -571,6 +732,27 @@ object GpuDriverPreferencesScreen : Screen { } if (driver.isSystem) { Text(driver.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + // Metadata chips: size + install date + min Vulkan API. + // Each chip is conditional — empty fields don't render + // a placeholder, so older drivers without these values + // just show nothing extra. + val chips = buildList { + if (driver.fileSizeBytes > 0) add(formatFileSize(driver.fileSizeBytes)) + if (driver.installDate > 0) add(formatInstallDate(driver.installDate)) + if (driver.minApi.isNotEmpty()) add("Vulkan ${driver.minApi}") + } + if (chips.isNotEmpty()) { + Text( + chips.joinToString(" • "), + style = MaterialTheme.typography.labelSmall, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 2.dp), + ) + } } } if (!driver.isSystem) { @@ -604,55 +786,6 @@ object GpuDriverPreferencesScreen : Screen { } } - @Composable - private fun SafDriverItem( - driver: GpuDriverManager.SafGpuDriver, - onInstall: () -> Unit - ) { - Card( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp).fillMaxWidth(), - onClick = onInstall, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), - shape = RoundedCornerShape(20.dp) - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.secondaryContainer), - contentAlignment = Alignment.Center - ) { - Icon(AppIcons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer) - } - Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { - Text( - driver.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - listOfNotNull( - if (driver.version.isNotEmpty()) "v${driver.version}" else null, - if (driver.author.isNotEmpty()) "by ${driver.author}" else null - ).joinToString(" • "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Button( - onClick = onInstall, - contentPadding = PaddingValues(horizontal = 12.dp), - modifier = Modifier.height(32.dp) - ) { - Text("Install", style = MaterialTheme.typography.labelSmall) - } - } - } - } - @Composable private fun RemoteGroupItem( group: GpuDriverManager.RemoteDriverGroup, @@ -815,6 +948,8 @@ object GpuDriverPreferencesScreen : Screen { private fun TechnicalInfoDialog(onDismiss: () -> Unit) { val context = LocalContext.current val driverManager = koinInject() + val gpuPrefs = koinInject() + val shareScope = rememberCoroutineScope() val systemInfo = remember { buildString { @@ -875,15 +1010,31 @@ object GpuDriverPreferencesScreen : Screen { Spacer(modifier = Modifier.width(12.dp)) Text("Technical Info") } - IconButton( - onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - val clip = android.content.ClipData.newPlainText("MpvRx System Info", systemInfo) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "System info copied to clipboard", Toast.LENGTH_SHORT).show() + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { + // Heavy: collects state + runs `logcat -d`. + // Off-main thread to keep the dialog snappy. + shareScope.launch { + val dump = withContext(Dispatchers.IO) { + GpuDriverLogger.collect(context, driverManager, gpuPrefs) + } + GpuDriverLogger.share(context, dump) + } + } + ) { + Icon(AppIcons.Default.Download, contentDescription = "Share diagnostic log", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + } + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("StreamX System Info", systemInfo) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "System info copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) { + Icon(AppIcons.Default.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) } - ) { - Icon(AppIcons.Default.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) } } }, @@ -964,4 +1115,57 @@ object GpuDriverPreferencesScreen : Screen { } } } + + // Compact status card rendered above the General Settings section. Driven + // by GpuDriverHelper.activeDriverStatus — green when a custom driver is + // armed, red when the last load failed and we auto-reverted. + @Composable + private fun StatusBanner( + icon: app.gyrolet.mpvrx.ui.icons.AppIcon, + text: String, + container: Color, + content: Color, + accent: Color, + bold: Boolean = false, + ) { + Card( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp).fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = container), + shape = RoundedCornerShape(16.dp), + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = accent, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + color = content, + ) + } + } + } + + // Renders bytes as "12.3 MB" / "456 KB" / "789 B" — driver sizes are + // typically 5–100 MB so MB is the common case. Two-decimal precision + // keeps the chip narrow without losing useful detail. + private fun formatFileSize(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(mb) + return "%.2f GB".format(mb / 1024.0) + } + + // Renders an epoch-millis install timestamp as a short locale date. + // Uses java.text directly to avoid pulling in a new dep; the formatter is + // re-created per call which is fine for a UI list of ~5–20 drivers. + private fun formatInstallDate(epochMillis: Long): String { + val fmt = java.text.SimpleDateFormat("MMM d, yyyy", java.util.Locale.getDefault()) + return fmt.format(java.util.Date(epochMillis)) + } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/AdrenoLookup.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/AdrenoLookup.kt new file mode 100644 index 000000000..c7195c38f --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/AdrenoLookup.kt @@ -0,0 +1,98 @@ +package app.gyrolet.mpvrx.utils + +import android.os.Build + +/** + * Maps an Android device's build identifiers to a Qualcomm Adreno GPU + * model number. Used by the GPU driver recommendation UI because the + * native bridge's getGpuInfo() currently only reports the generic string + * "Qualcomm Adreno GPU Detected" without a model number, which makes + * GpuDriverManager.getRecommendedDriver(0) always return "Unsupported". + * + * Build.SOC_MODEL is preferred (API 31+, stable manufacturer-provided + * identifier). Build.BOARD codenames are the fallback for older devices + * and for OEMs that don't fill SOC_MODEL. Returns 0 if no entry matches. + */ +object AdrenoLookup { + + // Build.SOC_MODEL -> Adreno number. Strings are the Qualcomm part + // numbers manufacturers print on the SoC (e.g. SM8550 = SD 8 Gen 2). + private val socToAdreno: Map = mapOf( + // 8-series (flagship) + "SM8850" to 840, // SD 8 Elite Gen 5 (rumored) + "SM8750" to 830, // SD 8 Elite + "SM8650" to 750, // SD 8 Gen 3 + "SM8635" to 735, // SD 8s Gen 3 + "SM8550" to 740, // SD 8 Gen 2 + "SM8475" to 730, // SD 8+ Gen 1 + "SM8450" to 730, // SD 8 Gen 1 + "SM8350" to 660, // SD 888 + "SM8325" to 660, // SD 888+ + "SM8250" to 650, // SD 865 / 865+ + "SM8150" to 640, // SD 855 / 855+ + // 7-series + "SM7675" to 732, // SD 7+ Gen 3 + "SM7550" to 720, // SD 7 Gen 3 + "SM7475" to 725, // SD 7+ Gen 2 + "SM7450" to 644, // SD 7 Gen 1 + "SM6450" to 710, // SD 7s Gen 2 + "SM7325" to 642, // SD 778G / 778G+ + "SM7250" to 620, // SD 765G / 768G + "SM7225" to 619, // SD 750G + // 6-series + "SM6375" to 619, // SD 695 + "SM6350" to 618, // SD 690 / 732G + "SM6225" to 610, // SD 4 Gen 2 + // 4-series + "SM4375" to 619, // SD 4 Gen 1 + "SM4350" to 619, // SD 480 5G + ) + + // Build.BOARD codename -> Adreno number. Lowercased before lookup. + // Codenames are Qualcomm platform internal names and are stable + // across OEMs unless the OEM explicitly overrides Build.BOARD. + private val boardToAdreno: Map = mapOf( + // 8-series codenames + "sun" to 830, // SD 8 Elite + "pineapple" to 750, // SD 8 Gen 3 + "kalama" to 740, // SD 8 Gen 2 + "bitra" to 730, // SD 8+ Gen 1 alt + "waipio" to 730, // SD 8+ Gen 1 + "taro" to 730, // SD 8 Gen 1 + "lahaina" to 660, // SD 888 + "kona" to 650, // SD 865 + "msm8150" to 640, // SD 855 + "sdm855" to 640, // SD 855 + "sdm845" to 630, // SD 845 + "msm8998" to 540, // SD 835 + // 7-series + "crow" to 725, // SD 7+ Gen 2 + "parrot" to 644, // SD 7 Gen 1 + "ravelin" to 710, // SD 7s Gen 2 + "diwali" to 642, // SD 778G+ + "shima" to 642, // SD 778G + "yupik" to 642, // SD 778G+ + "saipan" to 620, // SD 765G / 768G + // 6/4-series + "holi" to 619, // SD 480 5G / 4 Gen 1 + "khaje" to 619, // SD 4 Gen 1 + "bengal" to 610, // SD 460 + "monaco" to 619, // SD 4 Gen 2 + ) + + /** Resolves the Adreno model number from device build info. 0 = unknown. */ + fun resolve(): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val soc = Build.SOC_MODEL + if (soc.isNotEmpty()) socToAdreno[soc]?.let { return it } + } + boardToAdreno[Build.BOARD.lowercase()]?.let { return it } + return 0 + } + + /** Human-readable label, e.g. "Qualcomm Adreno 740". Null if unknown. */ + fun displayLabel(): String? { + val n = resolve() + return if (n > 0) "Qualcomm Adreno $n" else null + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverHelper.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverHelper.kt index c7ade4cc1..8ddf4eb79 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverHelper.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverHelper.kt @@ -2,126 +2,437 @@ package app.gyrolet.mpvrx.utils import android.content.Context import android.os.Build -import android.util.Log +import android.os.SystemClock +import android.system.Os +import app.gyrolet.mpvrx.domain.gpu.GpuDriver import app.gyrolet.mpvrx.domain.gpu.GpuDriverBridge import app.gyrolet.mpvrx.domain.gpu.GpuDriverManager import app.gyrolet.mpvrx.preferences.GpuDriverPreferences import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.io.File object GpuDriverHelper : KoinComponent { private const val TAG = "GpuDriverHelper" + // Shared with MPVView.applyCustomVulkanDriver — must match. + const val LOADING_MARKER_NAME = "gpu_driver_loading.flag" + private val preferences: GpuDriverPreferences by inject() private val driverManager: GpuDriverManager by inject() private var isInitialized = false + /** Live status of the driver subsystem, queryable from the UI. */ + enum class DriverStatus { + NOT_INITIALIZED, + UNSUPPORTED_ARCH, + BRIDGE_UNAVAILABLE, + SYSTEM, + CUSTOM_ACTIVE, + CUSTOM_FAILED, + } + + @Volatile + var activeDriverStatus: DriverStatus = DriverStatus.NOT_INITIALIZED + private set + /** * Initializes the GPU driver on app startup. * This should be called early in Application.onCreate(). */ @Synchronized fun initialize(context: Context) { - if (isInitialized) return - + if (isInitialized) { + GpuLog.v(TAG) { "initialize() called again, already initialized — no-op" } + return + } + + val startMs = SystemClock.uptimeMillis() + GpuLog.d(TAG) { "initialize() start; ABIs=${Build.SUPPORTED_ABIS.joinToString(",")}, HW=${Build.HARDWARE}, board=${Build.BOARD}" } + + // ── Crash-loop guard ────────────────────────────────────────────── + // If the loading-marker file exists at app start, the previous + // launch died while a custom Vulkan driver was active. The marker + // is written by MPVView before the custom driver is loaded and + // deleted ~30s after init succeeds — its presence here is strong + // evidence the driver killed the app. Auto-revert to system so the + // user isn't stuck in a crash loop they can't escape from the UI. + runCatching { + val marker = File(context.cacheDir, LOADING_MARKER_NAME) + if (marker.exists()) { + val crashedId = runCatching { marker.readText().trim() } + .getOrDefault("(unknown)") + GpuLog.e(TAG, "previous launch crashed with custom driver '$crashedId' — reverting to system") + preferences.activeDriverId.set("system") + preferences.crashRevertedFrom.set(crashedId) + marker.delete() + } + }.onFailure { + GpuLog.w(TAG, "crash-loop guard error: ${it.message}", it) + } + try { - if (!GpuDriverBridge.isAvailable()) { - Log.d(TAG, "Native library not available, skipping GPU driver initialization") + // ABI check FIRST: a single in-memory array lookup, no syscalls. + // On non-arm64 devices, this avoids loading the native lib AND + // the .freedreno.conf disk read, saving startup time. + if (!Build.SUPPORTED_ABIS.contains("arm64-v8a")) { + GpuLog.d(TAG) { "skipping: device is not arm64-v8a" } + activeDriverStatus = DriverStatus.UNSUPPORTED_ARCH isInitialized = true return } - if (!Build.SUPPORTED_ABIS.contains("arm64-v8a")) { - Log.d(TAG, "Custom GPU drivers only supported on arm64-v8a") + if (!NativeFreedrenoConfig.isAvailable() || !GpuDriverBridge.isAvailable()) { + GpuLog.d(TAG) { "skipping: native bridge library not loaded" } + activeDriverStatus = DriverStatus.BRIDGE_UNAVAILABLE isInitialized = true return } - val activeDriverId = preferences.activeDriverId.get() - - // File redirect dir if HUD or debug is enabled - var fileRedirectDir: String? = null - if (preferences.showDriverHud.get()) { - // In Azahar/Adrenotools this allows shader/config redirection - fileRedirectDir = context.cacheDir.absolutePath + // Non-Adreno arm64 (Mali, PowerVR, etc.): adrenotools and the + // Turnip / Freedreno drivers only target Qualcomm hardware. + // If activeDriverId still points at a custom driver — typical + // scenario is a backup restored from an Adreno phone — loading + // it would crash on vkCreateInstance. Demote the pref to system + // and arm system-mode hooks. The bridge stays loaded so the + // file-redirect / debug env features still work; we just never + // attempt a custom-driver load. + if (!GpuDriverBridge.isAdrenoDevice()) { + GpuLog.i(TAG) { "non-Adreno device — forcing system driver, custom selection disabled" } + val activeId = preferences.activeDriverId.get() + if (activeId.isNotEmpty() && activeId != "system") { + GpuLog.w(TAG, "demoting stale custom driver pref '$activeId' to system (no Adreno hardware)") + preferences.activeDriverId.set("system") + } + val hookLibDir = context.applicationInfo.nativeLibraryDir + val tmpDir = context.cacheDir.absolutePath + runCatching { + GpuDriverBridge.setDriver(hookLibDir, null, null, null, tmpDir) + } + activeDriverStatus = DriverStatus.SYSTEM + isInitialized = true + return } - if (activeDriverId == "system") { - Log.d(TAG, "Using system default GPU driver") - // Load system driver with hooks (to allow file redirection if needed) - val success = GpuDriverBridge.setDriver( - hookLibDir = context.applicationInfo.nativeLibraryDir, + // ── Freedreno env-var path (Eden-style) ────────────────────── + // HUD/TU_DEBUG flags are read by the driver at its own boot time + // via getenv(). Independent of who eventually loads libvulkan. + val cachePath = context.cacheDir.absolutePath + GpuLog.v(TAG) { "freedreno base path = $cachePath" } + NativeFreedrenoConfig.setFreedrenoBasePath(cachePath) + NativeFreedrenoConfig.initializeFreedrenoConfig() + NativeFreedrenoConfig.reloadFreedrenoConfig() + GpuLog.v(TAG) { "freedreno reloaded, env=${NativeFreedrenoConfig.getFreedrenoEnvSummary()}" } + + val hud = preferences.showDriverHud.get() + GpuLog.d(TAG) { "HUD pref = $hud" } + if (hud) { + NativeFreedrenoConfig.setFreedrenoEnv("TU_DEBUG", "sysmem,stat") + NativeFreedrenoConfig.setFreedrenoEnv("KGSL_REDIRECT_HUD", "1") + GpuLog.v(TAG) { "HUD env applied: TU_DEBUG=sysmem,stat, KGSL_REDIRECT_HUD=1" } + } else { + NativeFreedrenoConfig.clearFreedrenoEnv("TU_DEBUG") + NativeFreedrenoConfig.clearFreedrenoEnv("KGSL_REDIRECT_HUD") + GpuLog.v(TAG) { "HUD env cleared" } + } + + // ── ByteHook bridge path ───────────────────────────────────── + // Eagerly call GpuDriverBridge.setDriver so subsequent dlopen( + // "libvulkan.so") calls from libmpv/libplacebo are rerouted to + // the custom driver. MPVView's --vulkan-library path stays in + // place for direct loads of custom-named .so files; the linker + // refcounts the handle, so loading via both paths is safe. + val activeDriverId = preferences.activeDriverId.get() + val hookLibDir = context.applicationInfo.nativeLibraryDir + val tmpDir = context.cacheDir.absolutePath + // File-redirect dir is only meaningful when HUD/debug needs the + // driver to spill files into our cache. Off by default. + val fileRedirectDir: String? = if (hud) cachePath else null + + if (activeDriverId == "system" || activeDriverId.isEmpty()) { + GpuLog.d(TAG) { "active driver pref = system — arming hooks for system loader" } + val ok = GpuDriverBridge.setDriver( + hookLibDir = hookLibDir, customDriverDir = null, customDriverName = null, fileRedirectDir = fileRedirectDir, - tmpDir = context.cacheDir.absolutePath + tmpDir = tmpDir, ) - if (success) { - Log.i(TAG, "Successfully initialized system GPU driver hooks") - } else { - Log.w(TAG, "Failed to initialize system GPU driver hooks") - } + GpuLog.i(TAG) { "system driver hooks ${if (ok) "armed" else "FAILED"}" } + activeDriverStatus = DriverStatus.SYSTEM isInitialized = true return } - // Load drivers synchronously to avoid race condition with MPV initialization - val drivers = driverManager.getInstalledDriversSync() - val activeDriver = drivers.find { it.id == activeDriverId } + val driver = runCatching { driverManager.getInstalledDriversSync() } + .getOrNull() + ?.firstOrNull { it.id == activeDriverId && !it.isSystem } - if (activeDriver != null && !activeDriver.isSystem) { - Log.d(TAG, "Initializing custom GPU driver: ${activeDriver.name}") - - // Create an ICD json file to force Vulkan loaders to use this driver - val icdFile = java.io.File(activeDriver.driverPath, "icd.json") - val icdContent = """ - { - "file_format_version": "1.0.0", - "ICD": { - "library_path": "${activeDriver.driverPath}/${activeDriver.vulkanLibName}", - "api_version": "1.1.0" - } - } - """.trimIndent() - icdFile.writeText(icdContent) - - // Inject the VK_ICD_FILENAMES environment variable - try { - android.system.Os.setenv("VK_ICD_FILENAMES", icdFile.absolutePath, true) - Log.i(TAG, "Injected VK_ICD_FILENAMES=${icdFile.absolutePath}") - } catch (e: Exception) { - Log.e(TAG, "Failed to set VK_ICD_FILENAMES", e) - } + // "Active driver id no longer in the installed list" is a config + // problem (user deleted the .so behind our back), not a driver + // crash. Reset the pref to system and fall through to the + // system-driver branch — no point throwing for this. + if (driver == null) { + GpuLog.w(TAG, "active driver id='$activeDriverId' not installed — resetting pref to system") + preferences.activeDriverId.set("system") + GpuDriverBridge.setDriver(hookLibDir, null, null, fileRedirectDir, tmpDir) + activeDriverStatus = DriverStatus.SYSTEM + isInitialized = true + return + } - val success = GpuDriverBridge.setDriver( - hookLibDir = context.applicationInfo.nativeLibraryDir, - customDriverDir = activeDriver.driverPath, - customDriverName = activeDriver.vulkanLibName, - fileRedirectDir = fileRedirectDir, - tmpDir = context.cacheDir.absolutePath + // ── Pre-flight file validation ─────────────────────────────── + // Genuine driver-load failures: throw instead of silently + // swapping to system, so the user sees the real cause in logcat + // / a crash dialog rather than playback "just working" on the + // OEM driver while the UI claims the custom one is active. + val driverDir = File(driver.driverPath) + val driverLib = File(driver.driverPath, driver.vulkanLibName) + if (!driverDir.isDirectory) { + activeDriverStatus = DriverStatus.CUSTOM_FAILED + throw IllegalStateException( + "GPU driver dir missing for '${driver.name}': ${driver.driverPath}" ) - - if (success) { - Log.i(TAG, "Successfully initialized custom GPU driver: ${activeDriver.name}") - } else { - Log.e(TAG, "Failed to initialize custom GPU driver: ${activeDriver.name}. Driver may be incompatible.") - // Don't automatically revert to system here to allow user to see the error/retry - // and because it might fail due to temporary conditions (though unlikely for local files) - } + } + if (!driverLib.isFile) { + activeDriverStatus = DriverStatus.CUSTOM_FAILED + throw IllegalStateException( + "GPU driver lib missing for '${driver.name}': ${driverLib.absolutePath}" + ) + } + + GpuLog.d(TAG) { "active driver = '${driver.name}' v${driver.version} (${driver.driverPath}/${driver.vulkanLibName})" } + + // Write a Vulkan ICD manifest next to the driver and surface it + // to the standard Vulkan loader via env vars. This is the path + // the Android loader uses when it ISN'T routed through our + // bytehook (e.g. the OS-level Vulkan service ahead of libmpv). + injectVulkanIcd(driver) + + // Mesa/Turnip-specific tuning. No-op for non-Mesa drivers. + configureMesaEnvironment(driver.vulkanLibName) + + // Crash-loop guard for the bridge load. The marker is written + // ONLY around the setDriver call (which loads the custom .so + // and arms bytehook). If that call crashes the process, the + // marker survives and the next launch reverts to system. + // The actual vkCreateInstance test fires later (when MPVView + // starts playback); MPVView writes its own marker around that + // and clears it on wasHookUsed() success. + val marker = File(context.cacheDir, LOADING_MARKER_NAME) + runCatching { marker.writeText(driver.id) } + .onFailure { GpuLog.w(TAG, "could not write crash-guard marker: ${it.message}") } + + val ok = GpuDriverBridge.setDriver( + hookLibDir = hookLibDir, + customDriverDir = driver.driverPath, + customDriverName = driver.vulkanLibName, + fileRedirectDir = fileRedirectDir, + tmpDir = tmpDir, + ) + + // setDriver returned without crashing — clear the bridge-load + // marker immediately. Any further crash would be from MPV / + // libplacebo / Vulkan instance creation, which MPVView guards + // separately. + runCatching { marker.delete() } + + if (ok) { + GpuLog.i(TAG) { "custom driver '${driver.name}' armed via bridge" } + activeDriverStatus = DriverStatus.CUSTOM_ACTIVE } else { - // Fallback if the active driver is missing from installed list - GpuDriverBridge.setDriver( - hookLibDir = context.applicationInfo.nativeLibraryDir, - customDriverDir = null, - customDriverName = null, - fileRedirectDir = fileRedirectDir, - tmpDir = context.cacheDir.absolutePath + // No silent fallback. The boot-time crash-loop guard above + // handles the recovery story on the NEXT launch — within + // this launch we want the failure to be loud so users (and + // logs) see the real cause instead of the app pretending + // the driver is active while libplacebo ends up on the OEM + // Vulkan blob. + activeDriverStatus = DriverStatus.CUSTOM_FAILED + throw IllegalStateException( + "GpuDriverBridge.setDriver failed for '${driver.name}' " + + "(${driver.vulkanLibName}). Driver may be incompatible " + + "with this device's Vulkan loader." ) } - + isInitialized = true } catch (t: Throwable) { - Log.e(TAG, "CRITICAL: Native crash or error in GpuDriverHelper.initialize", t) + // Log loudly, mark failed, then rethrow. The user explicitly + // wants real driver-load failures to surface as a crash so the + // root cause is visible in logcat / Play Console / the bug + // report, instead of being masked by an in-process fallback + // that silently switches to the OEM Vulkan driver. + GpuLog.e(TAG, "CRITICAL: GpuDriverHelper.initialize failed — letting it propagate", t) + activeDriverStatus = DriverStatus.CUSTOM_FAILED isInitialized = true + throw t + } finally { + GpuLog.d(TAG) { "initialize() done in ${SystemClock.uptimeMillis() - startMs}ms" } + } + } + + /** + * Live-swap the GPU driver without an app restart. Called by the + * GPU Driver Preferences screen when the user picks a different + * driver. The flow: + * + * 1. Unhook the currently-armed bridge (clears the bytehook stubs; + * does NOT dlclose the old .so — that would crash any live + * Vulkan objects). + * 2. Persist the new pref so subsequent launches stay consistent. + * 3. Call [GpuDriverBridge.setDriver] with the new driver's paths + * (or null for system mode). + * 4. Update [activeDriverStatus] so the UI banner refreshes. + * + * Safety: the new hook table only affects FUTURE dlopen/vkCreate + * calls. Anything libplacebo has already resolved (e.g. an active + * playback's cached vk function pointers) keeps working against the + * old handle until the next VO is created. So this is safe to call + * mid-session, but the user has to start a new video for the new + * driver to actually drive frames. + * + * On failure, throws — same policy as initialize(). A failed swap + * leaves the bridge in an unhooked state, so silently returning + * would let the next video fall back to the OEM Vulkan driver while + * the UI claims the custom one is active. Caller should let the + * exception propagate so the app crashes with the real cause in + * logcat; the boot-time guard recovers on the next launch. + */ + @Synchronized + fun applyDriverChange(context: Context, newDriverId: String) { + check(isInitialized) { "GpuDriverHelper not yet initialized — cannot swap drivers" } + check(Build.SUPPORTED_ABIS.contains("arm64-v8a") && GpuDriverBridge.isAvailable()) { + "Native bridge unavailable on this device" + } + // Non-Adreno hardware: refuse to swap to a custom driver. UI gates + // this at the prefs screen too, but enforce here as well so any + // future caller (auto-restore, deep-link, debug command) can't slip + // a custom driver onto a Mali / PowerVR device and crash the app. + check(GpuDriverBridge.isAdrenoDevice() || newDriverId == "system" || newDriverId.isEmpty()) { + "Cannot apply custom GPU driver on non-Adreno hardware" + } + + val hookLibDir = context.applicationInfo.nativeLibraryDir + val tmpDir = context.cacheDir.absolutePath + val hud = preferences.showDriverHud.get() + val fileRedirectDir: String? = if (hud) context.cacheDir.absolutePath else null + + GpuLog.i(TAG) { "applyDriverChange: swapping to '$newDriverId' (was ${preferences.activeDriverId.get()})" } + + // Tear down the previous hook table before arming the new one. + // Idempotent — safe even if no driver was loaded. Underlying .so + // is NOT dlclose'd to keep any live Vulkan objects valid. + runCatching { GpuDriverBridge.unloadDriver() } + + if (newDriverId == "system" || newDriverId.isEmpty()) { + preferences.activeDriverId.set("system") + val ok = GpuDriverBridge.setDriver(hookLibDir, null, null, fileRedirectDir, tmpDir) + GpuLog.i(TAG) { "applyDriverChange: system mode hooks ${if (ok) "armed" else "FAILED"}" } + activeDriverStatus = DriverStatus.SYSTEM + return + } + + val driver = runCatching { driverManager.getInstalledDriversSync() } + .getOrNull() + ?.firstOrNull { it.id == newDriverId && !it.isSystem } + ?: throw IllegalStateException("Driver id '$newDriverId' is not in the installed list") + + val driverDir = File(driver.driverPath) + val driverLib = File(driver.driverPath, driver.vulkanLibName) + if (!driverDir.isDirectory) { + activeDriverStatus = DriverStatus.CUSTOM_FAILED + throw IllegalStateException("Driver dir missing: ${driver.driverPath}") + } + if (!driverLib.isFile) { + activeDriverStatus = DriverStatus.CUSTOM_FAILED + throw IllegalStateException("Driver lib missing: ${driverLib.absolutePath}") + } + + injectVulkanIcd(driver) + configureMesaEnvironment(driver.vulkanLibName) + + val ok = GpuDriverBridge.setDriver( + hookLibDir = hookLibDir, + customDriverDir = driver.driverPath, + customDriverName = driver.vulkanLibName, + fileRedirectDir = fileRedirectDir, + tmpDir = tmpDir, + ) + if (!ok) { + activeDriverStatus = DriverStatus.CUSTOM_FAILED + throw IllegalStateException( + "GpuDriverBridge.setDriver failed for '${driver.name}' (${driver.vulkanLibName}). " + + "Driver may be incompatible with this device's Vulkan loader." + ) + } + + // Only persist the pref after the bridge confirmed the load — + // otherwise a bad driver poisons the next launch too. + preferences.activeDriverId.set(newDriverId) + activeDriverStatus = DriverStatus.CUSTOM_ACTIVE + GpuLog.i(TAG) { "applyDriverChange: '${driver.name}' armed live (start a new video to use it)" } + } + + /** + * Writes a Vulkan ICD (Installable Client Driver) JSON manifest next + * to the driver and exports three env vars so the standard Vulkan + * loader picks up our driver regardless of which protocol level it's + * using. + * + * VK_ICD_FILENAMES — original env var (Vulkan loader pre-1.3.234) + * VK_DRIVER_FILES — replacement env var (Vulkan loader 1.3.234+) + * VK_ADD_DRIVER_FILES — additive variant (does not exclude system ICDs) + * + * Without this, even when our bytehook successfully rewrites + * dlopen("libvulkan.so") to return the custom handle, the Android + * Vulkan service can still consult /vendor/lib64/hw/ during its + * own startup probes and reach the OEM blob ahead of us. + */ + private fun injectVulkanIcd(driver: GpuDriver) { + runCatching { + val fullLibPath = "${driver.driverPath}/${driver.vulkanLibName}" + val icdFile = File(driver.driverPath, "icd.json") + val icdContent = """ + { + "file_format_version": "1.0.0", + "ICD": { + "library_path": "$fullLibPath", + "api_version": "1.3.0" + } + } + """.trimIndent() + icdFile.writeText(icdContent) + + val icdPath = icdFile.absolutePath + Os.setenv("VK_ICD_FILENAMES", icdPath, true) + Os.setenv("VK_DRIVER_FILES", icdPath, true) + Os.setenv("VK_ADD_DRIVER_FILES", icdPath, true) + GpuLog.i(TAG) { "ICD manifest written + env exposed: $icdPath" } + }.onFailure { + GpuLog.w(TAG, "ICD JSON / env injection failed: ${it.message}", it) + } + } + + /** + * Sets Mesa/Turnip-specific env vars for Freedreno-flavoured drivers. + * Skipped for non-Mesa drivers (Qualcomm proprietary blob etc.) since + * the vars would be no-ops at best and can confuse non-Mesa loaders. + */ + private fun configureMesaEnvironment(vulkanLibName: String) { + val isMesa = vulkanLibName.contains("freedreno", ignoreCase = true) || + vulkanLibName.contains("turnip", ignoreCase = true) + if (!isMesa) return + + runCatching { + // Force the freedreno backend inside Mesa's loader (prevents + // it from selecting another Mesa Vulkan driver if multiple + // are linked into the same .so). + Os.setenv("MESA_LOADER_DRIVER_OVERRIDE", "freedreno", true) + // Triple-buffered low-latency present mode; the default FIFO + // adds a frame of input lag that's visible during seek/skip. + Os.setenv("MESA_VK_WSI_PRESENT_MODE", "mailbox", true) + GpuLog.v(TAG) { "Mesa env applied: driver=freedreno, present=mailbox" } + }.onFailure { + GpuLog.w(TAG, "Mesa env setenv failed: ${it.message}") } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverLogger.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverLogger.kt new file mode 100644 index 000000000..4b1634233 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverLogger.kt @@ -0,0 +1,145 @@ +package app.gyrolet.mpvrx.utils + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import app.gyrolet.mpvrx.BuildConfig +import app.gyrolet.mpvrx.domain.gpu.GpuDriverBridge +import app.gyrolet.mpvrx.domain.gpu.GpuDriverManager +import app.gyrolet.mpvrx.preferences.GpuDriverPreferences +import java.io.BufferedReader +import java.io.InputStreamReader +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Builds a human-readable diagnostic dump for the custom GPU driver feature + * and exposes a share intent so users can hand it off to a bug report. + * + * Includes: device/GPU/Vulkan info, native bridge state, current and installed + * drivers, Freedreno env vars, and recent logcat lines for our own tags. + */ +object GpuDriverLogger { + + private val GPU_TAGS = listOf("GpuDriverHelper", "GpuDriverBridge", "GpuDriverManager", "NativeFreedreno", "App") + + fun collect( + context: Context, + manager: GpuDriverManager, + prefs: GpuDriverPreferences, + ): String = buildString { + val now = SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz", Locale.US).format(Date()) + appendLine("=== StreamX GPU Driver Diagnostic ===") + appendLine("Generated: $now") + appendLine("App: ${BuildConfig.APPLICATION_ID} (build ${BuildConfig.GIT_SHA})") + appendLine() + + appendLine("--- Device ---") + appendLine("Manufacturer: ${Build.MANUFACTURER}") + appendLine("Model: ${Build.MODEL}") + appendLine("Device: ${Build.DEVICE}") + appendLine("Hardware: ${Build.HARDWARE}") + appendLine("Board: ${Build.BOARD}") + appendLine("Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString(", ")}") + appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") + appendLine() + + appendLine("--- GPU / Vulkan ---") + appendLine("GPU model (bridge): ${runCatching { manager.getGpuModel() }.getOrElse { "ERROR: ${it.message}" }}") + appendLine("Adreno supported: ${runCatching { manager.isAdrenoSupported() }.getOrElse { "ERROR: ${it.message}" }}") + val vulkanFeature = context.packageManager.systemAvailableFeatures + .find { it.name == PackageManager.FEATURE_VULKAN_HARDWARE_VERSION } + if (vulkanFeature != null) { + val v = vulkanFeature.version + appendLine("Vulkan HW version: ${v shr 22}.${(v shr 12) and 0x3FF}.${v and 0xFFF}") + } else { + appendLine("Vulkan HW version: not reported") + } + appendLine() + + appendLine("--- Native bridge ---") + appendLine("adrenotools_bridge loaded: ${GpuDriverBridge.isAvailable()}") + appendLine("NativeFreedrenoConfig loaded: ${NativeFreedrenoConfig.isAvailable()}") + val envSummary = runCatching { + if (NativeFreedrenoConfig.isAvailable()) NativeFreedrenoConfig.getFreedrenoEnvSummary() else "" + }.getOrElse { "ERROR: ${it.message}" } + appendLine("Freedreno env: ${if (envSummary.isEmpty()) "(none)" else envSummary}") + appendLine() + + appendLine("--- Preferences ---") + appendLine("Active driver id: ${prefs.activeDriverId.get()}") + appendLine("Show HUD: ${prefs.showDriverHud.get()}") + appendLine("Warning accepted: ${prefs.hasAcceptedGpuWarning.get()}") + appendLine() + + appendLine("--- Installed drivers ---") + val installed = runCatching { manager.getInstalledDriversSync() } + .getOrElse { + appendLine("ERROR: ${it.message}") + emptyList() + } + if (installed.isEmpty()) { + appendLine("(none)") + } else { + installed.forEach { d -> + appendLine("- id=${d.id} name='${d.name}' version='${d.version}' vendor='${d.vendor}' vulkanLib='${d.vulkanLibName}' system=${d.isSystem}") + } + } + appendLine() + + appendLine("--- Memory ---") + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val mi = ActivityManager.MemoryInfo().also { am.getMemoryInfo(it) } + appendLine("Total RAM: ${mi.totalMem / (1024 * 1024)} MB") + appendLine("Available RAM: ${mi.availMem / (1024 * 1024)} MB") + appendLine("Low memory: ${mi.lowMemory}") + appendLine() + + appendLine("--- Recent logcat (our tags) ---") + append(dumpLogcat()) + } + + /** + * Reads our own process logcat with `-d` (dump-and-exit), filtered to the + * GPU driver tags. Since Jelly Bean apps can only read their own process + * logs without READ_LOGS permission — perfect for our case. + * + * Dumps the full kernel log buffer for those tags — no line cap. The + * underlying OS ring buffer (usually ~256 KB) is the hard limit. + */ + private fun dumpLogcat(): String { + return try { + // -d: dump and exit -v threadtime: include timestamp/pid/tid + // *:S silences everything not explicitly enabled. + val cmd = mutableListOf("logcat", "-d", "-v", "threadtime") + GPU_TAGS.forEach { cmd += "$it:V" } + cmd += "*:S" + + val process = ProcessBuilder(cmd).redirectErrorStream(true).start() + val text = BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() } + process.waitFor() + if (text.isBlank()) "(no log entries captured)\n" else text + } catch (t: Throwable) { + "logcat capture failed: ${t.message}\n" + } + } + + /** + * Fires an ACTION_SEND chooser with the diagnostic as plain text body. + * No FileProvider / WRITE_EXTERNAL_STORAGE needed. + */ + fun share(context: Context, content: String) { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "StreamX GPU Driver Diagnostic") + putExtra(Intent.EXTRA_TEXT, content) + } + val chooser = Intent.createChooser(send, "Share diagnostic log").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(chooser) + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/GpuLog.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuLog.kt new file mode 100644 index 000000000..453b1a917 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuLog.kt @@ -0,0 +1,40 @@ +package app.gyrolet.mpvrx.utils + +import android.util.Log +import app.gyrolet.mpvrx.preferences.GpuDriverPreferences +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Gated logger for the GPU driver subsystem. + * + * v/d/i are inline + lambda-based: when the verboseLogging preference is OFF + * the message lambda never runs, so we pay nothing for string templating or + * the `Log.*` JNI hop in the hot path. w/e are unconditional because + * warnings/errors indicate problems users need to know about. + * + * Pattern: `GpuLog.d(TAG) { "cache hit for $id" }` + */ +object GpuLog : KoinComponent { + @PublishedApi internal val prefs: GpuDriverPreferences by inject() + + inline fun v(tag: String, message: () -> String) { + if (prefs.verboseLogging.get()) Log.v(tag, message()) + } + + inline fun d(tag: String, message: () -> String) { + if (prefs.verboseLogging.get()) Log.d(tag, message()) + } + + inline fun i(tag: String, message: () -> String) { + if (prefs.verboseLogging.get()) Log.i(tag, message()) + } + + fun w(tag: String, message: String, t: Throwable? = null) { + if (t != null) Log.w(tag, message, t) else Log.w(tag, message) + } + + fun e(tag: String, message: String, t: Throwable? = null) { + if (t != null) Log.e(tag, message, t) else Log.e(tag, message) + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/NativeFreedrenoConfig.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/NativeFreedrenoConfig.kt new file mode 100644 index 000000000..19298360c --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/NativeFreedrenoConfig.kt @@ -0,0 +1,67 @@ +package app.gyrolet.mpvrx.utils + +import android.util.Log + +/** + * Provides access to Freedreno/Turnip driver configuration through JNI bindings. + * Logic migrated from Eden Android to ensure robust integration and stability. + */ +object NativeFreedrenoConfig { + private const val TAG = "NativeFreedreno" + private var isLibraryLoaded = false + + init { + // One-shot library load — direct android.util.Log (no Koin dependency + // here, since this can run before Koin is up). + try { + System.loadLibrary("adrenotools_bridge") + isLibraryLoaded = true + Log.i(TAG, "libadrenotools_bridge.so loaded for freedreno bridge") + } catch (t: Throwable) { + isLibraryLoaded = false + Log.w(TAG, "libadrenotools_bridge.so not loaded: ${t.message}") + } + } + + fun isAvailable(): Boolean = isLibraryLoaded + + @JvmStatic + @Synchronized + external fun setFreedrenoBasePath(basePath: String) + + @JvmStatic + @Synchronized + external fun initializeFreedrenoConfig() + + @JvmStatic + @Synchronized + external fun saveFreedrenoConfig() + + @JvmStatic + @Synchronized + external fun reloadFreedrenoConfig() + + @JvmStatic + @Synchronized + external fun setFreedrenoEnv(varName: String, value: String): Boolean + + @JvmStatic + @Synchronized + external fun getFreedrenoEnv(varName: String): String + + @JvmStatic + @Synchronized + external fun isFreedrenoEnvSet(varName: String): Boolean + + @JvmStatic + @Synchronized + external fun clearFreedrenoEnv(varName: String): Boolean + + @JvmStatic + @Synchronized + external fun clearAllFreedrenoEnv() + + @JvmStatic + @Synchronized + external fun getFreedrenoEnvSummary(): String +}