diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index ccc3c12c2..cc765a205 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -44,6 +44,11 @@ add_subdirectory(waylandcomp) add_library(sdnative SHARED sdnative/sdnative.c) target_link_libraries(sdnative log) +# The device info window's Vulkan section: the phone's own driver, asked once through the system +# loader (opened with dlopen, so a phone without Vulkan still loads the app). +add_library(deviceinfo SHARED deviceinfo/vkinfo.c) +target_link_libraries(deviceinfo dl) + # Rebuild Termux terminal-emulator's PTY JNI helper with the app's NDK instead of carrying its # prebuilt libtermux.so with a redundant libstdc++.so dependency. add_library(termux SHARED termux/termux.c) diff --git a/app/src/main/cpp/deviceinfo/vkinfo.c b/app/src/main/cpp/deviceinfo/vkinfo.c new file mode 100644 index 000000000..a8d769818 --- /dev/null +++ b/app/src/main/cpp/deviceinfo/vkinfo.c @@ -0,0 +1,112 @@ +// What the phone's own Vulkan driver says about its GPU, for the app's device info window: one +// instance on the system loader, the first physical device's properties, a few limits and the +// extensions DXVK and VKD3D-Proton lean on. Returned as "key=value" lines; nothing is kept open. +#include +#include +#include +#include +#define VK_NO_PROTOTYPES +#include + +#define LINE(...) do { if (used < sizeof(out)) used += snprintf(out + used, sizeof(out) - used, __VA_ARGS__); } while (0) + +static const char *const EXTENSIONS[] = { + "VK_EXT_robustness2", "VK_EXT_transform_feedback", "VK_EXT_custom_border_color", + "VK_KHR_dynamic_rendering", "VK_EXT_extended_dynamic_state3", "VK_EXT_descriptor_indexing", + "VK_KHR_timeline_semaphore", "VK_EXT_mesh_shader", "VK_KHR_ray_query", +}; + +static const char *type_name(VkPhysicalDeviceType type) { + switch (type) { + case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated GPU"; + case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete GPU"; + case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual GPU"; + case VK_PHYSICAL_DEVICE_TYPE_CPU: return "CPU"; + default: return "other"; + } +} + +JNIEXPORT jstring JNICALL +Java_com_droiddeck_launcher_gpu_VulkanInfo_nativeQuery(JNIEnv *env, jclass clazz) { + static char out[4096]; + size_t used = 0; + out[0] = 0; + void *lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL); + if (!lib) { LINE("error=no Vulkan loader\n"); return (*env)->NewStringUTF(env, out); } + PFN_vkGetInstanceProcAddr gipa = (PFN_vkGetInstanceProcAddr) dlsym(lib, "vkGetInstanceProcAddr"); + PFN_vkCreateInstance create = gipa ? (PFN_vkCreateInstance) gipa(NULL, "vkCreateInstance") : NULL; + PFN_vkEnumerateInstanceVersion instanceVersion = gipa ? (PFN_vkEnumerateInstanceVersion) gipa(NULL, "vkEnumerateInstanceVersion") : NULL; + if (!create) { LINE("error=no vkCreateInstance\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); } + uint32_t loader = VK_API_VERSION_1_0; + if (instanceVersion) instanceVersion(&loader); + LINE("loader=%u.%u.%u\n", VK_API_VERSION_MAJOR(loader), VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader)); + + VkApplicationInfo app = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "DroidDeck device info", + .apiVersion = loader >= VK_API_VERSION_1_1 ? VK_API_VERSION_1_1 : VK_API_VERSION_1_0 }; + VkInstanceCreateInfo info = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app }; + VkInstance instance = VK_NULL_HANDLE; + if (create(&info, NULL, &instance) != VK_SUCCESS) { LINE("error=vkCreateInstance failed\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); } + +#define GET(name) PFN_##name name = (PFN_##name) gipa(instance, #name) + GET(vkDestroyInstance); GET(vkEnumeratePhysicalDevices); GET(vkGetPhysicalDeviceProperties); + GET(vkGetPhysicalDeviceProperties2); GET(vkGetPhysicalDeviceMemoryProperties); + GET(vkEnumerateDeviceExtensionProperties); GET(vkGetPhysicalDeviceFeatures); + uint32_t count = 1; + VkPhysicalDevice device = VK_NULL_HANDLE; + VkResult listed = vkEnumeratePhysicalDevices ? vkEnumeratePhysicalDevices(instance, &count, &device) : VK_ERROR_INITIALIZATION_FAILED; + if ((listed != VK_SUCCESS && listed != VK_INCOMPLETE) || count == 0 || !device || !vkGetPhysicalDeviceProperties) { + LINE("error=no Vulkan device\n"); + } else { + VkPhysicalDeviceProperties p; + vkGetPhysicalDeviceProperties(device, &p); + LINE("device=%s\n", p.deviceName); + LINE("type=%s\n", type_name(p.deviceType)); + LINE("api=%u.%u.%u\n", VK_API_VERSION_MAJOR(p.apiVersion), VK_API_VERSION_MINOR(p.apiVersion), VK_API_VERSION_PATCH(p.apiVersion)); + // Qualcomm packs its driver version as 10.10.12 bits, the same layout as VK_MAKE_VERSION. + LINE("driver_version=%u.%u.%u\n", p.driverVersion >> 22, (p.driverVersion >> 12) & 0x3ff, p.driverVersion & 0xfff); + LINE("vendor_id=0x%04x\ndevice_id=0x%08x\n", p.vendorID, p.deviceID); + LINE("max_image_2d=%u\n", p.limits.maxImageDimension2D); + + // Driver name and build string (Vulkan 1.2 or VK_KHR_driver_properties). + uint32_t extCount = 0; + if (vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, NULL); + static VkExtensionProperties exts[512]; + if (extCount > 512) extCount = 512; + if (extCount && vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, exts); + int driverProps = p.apiVersion >= VK_API_VERSION_1_2; + for (uint32_t i = 0; i < extCount; i++) + if (!strcmp(exts[i].extensionName, "VK_KHR_driver_properties")) driverProps = 1; + if (driverProps && vkGetPhysicalDeviceProperties2) { + VkPhysicalDeviceDriverProperties driver = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES }; + VkPhysicalDeviceProperties2 p2 = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &driver }; + vkGetPhysicalDeviceProperties2(device, &p2); + LINE("driver_name=%s\n", driver.driverName); + LINE("driver_info=%s\n", driver.driverInfo); + LINE("conformance=%u.%u.%u.%u\n", driver.conformanceVersion.major, driver.conformanceVersion.minor, + driver.conformanceVersion.subminor, driver.conformanceVersion.patch); + } + LINE("extension_count=%u\n", extCount); + for (size_t e = 0; e < sizeof(EXTENSIONS) / sizeof(*EXTENSIONS); e++) { + int has = 0; + for (uint32_t i = 0; i < extCount && !has; i++) has = !strcmp(exts[i].extensionName, EXTENSIONS[e]); + LINE("ext.%s=%s\n", EXTENSIONS[e], has ? "yes" : "no"); + } + if (vkGetPhysicalDeviceFeatures) { + VkPhysicalDeviceFeatures f; + vkGetPhysicalDeviceFeatures(device, &f); + LINE("geometry_shader=%s\ntessellation_shader=%s\ntexture_bc=%s\n", + f.geometryShader ? "yes" : "no", f.tessellationShader ? "yes" : "no", f.textureCompressionBC ? "yes" : "no"); + } + if (vkGetPhysicalDeviceMemoryProperties) { + VkPhysicalDeviceMemoryProperties m; + vkGetPhysicalDeviceMemoryProperties(device, &m); + VkDeviceSize largest = 0; + for (uint32_t i = 0; i < m.memoryHeapCount; i++) + if ((m.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) && m.memoryHeaps[i].size > largest) largest = m.memoryHeaps[i].size; + LINE("device_heap_mb=%llu\n", (unsigned long long) (largest >> 20)); + } + } + if (vkDestroyInstance) vkDestroyInstance(instance, NULL); + dlclose(lib); + return (*env)->NewStringUTF(env, out); +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DeviceInfo.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DeviceInfo.kt new file mode 100644 index 000000000..5e30ebb4a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DeviceInfo.kt @@ -0,0 +1,166 @@ +package com.droiddeck.launcher.gpu + +import android.app.ActivityManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.session.SessionPrefs +import java.io.File +import java.util.Locale + +/** + * Everything the device info window shows about the phone's chip: SoC, GPU, CPU, memory, system, + * the phone's own Vulkan driver and the drivers DroidDeck uses. Reads files and asks Vulkan once + * ([VulkanInfo]), so [collect] belongs off the main thread. Values the phone does not give are + * left out rather than guessed. + */ +object DeviceInfo { + class Section(val title: String, val rows: List>) + + fun collect(context: Context): List
{ + val vk = VulkanInfo.query() + // After Vulkan has been asked, detection can use its name too. + val gpu = GpuInfo.detect() + return listOf( + chip(), gpu(gpu), cpu(), memory(context), system(), vulkan(context, vk), drivers(context, gpu), + ).filter { it.rows.isNotEmpty() } + } + + /** The whole window as plain text, for a bug report. */ + fun text(sections: List
): String = buildString { + append("DroidDeck device info\n") + for (s in sections) { + append('\n').append(s.title).append('\n') + for ((k, v) in s.rows) append(" ").append(k).append(": ").append(v).append('\n') + } + } + + private fun MutableList>.add(key: String, value: String?) { + if (!value.isNullOrBlank() && value != Build.UNKNOWN) add(key to value.trim()) + } + + private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null } + + private fun prop(name: String): String = GpuInfo.systemProperty(name) + + private fun chip() = Section("Chip (SoC)", buildList> { + val model = SocNames.model() + val platform = prop("ro.board.platform") + add("Name", SocNames.name(model, platform)) + add("Model", model) + if (Build.VERSION.SDK_INT >= 31) add("Maker", Build.SOC_MANUFACTURER) + add("Platform", platform) + add("Hardware", Build.HARDWARE) + add("Board", Build.BOARD) + val id = read("/sys/devices/soc0/soc_id") + val machine = read("/sys/devices/soc0/machine") + add("SoC id", listOfNotNull(id, machine).joinToString(" · ")) + add("Family", read("/sys/devices/soc0/family")) + }) + + private fun gpu(gpu: GpuInfo) = Section("GPU", buildList> { + add("GPU", gpu.name) + add("Family", gpu.family.label) + add("Model found from", when (gpu.modelSource) { + "kernel" -> "the kernel's GPU name" + "vulkan" -> "the Vulkan driver (the kernel's name has no model)" + "platform" -> "the platform (the kernel's name has no model)" + else -> "unknown" + }) + add("Kernel name", gpu.kgslName) + val kgsl = "/sys/class/kgsl/kgsl-3d0" + add("Clock range", gpuRange()) + add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" }) + add("Temperature", read("$kgsl/temp")?.toLongOrNull()?.let { if (it > 1000) "${it / 1000} °C" else "$it °C" }) + add("Support", gpu.supportText) + }) + + private fun cpu() = Section("CPU", buildList> { + add("Cores", Runtime.getRuntime().availableProcessors().toString()) + // Each cpufreq policy is one cluster: the cores it covers and their speed range. + val policies = File("/sys/devices/system/cpu/cpufreq").listFiles { f -> f.name.matches(Regex("policy\\d+")) } + ?.sortedBy { it.name.removePrefix("policy").toInt() }.orEmpty() + for (p in policies) { + val cpus = (read("${p.path}/related_cpus") ?: read("${p.path}/affected_cpus"))?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty() + val min = read("${p.path}/cpuinfo_min_freq")?.toLongOrNull() + val max = read("${p.path}/cpuinfo_max_freq")?.toLongOrNull() ?: continue + val cores = if (cpus.size > 1) "cores ${cpus.first()}-${cpus.last()}" else "core ${cpus.firstOrNull() ?: p.name.removePrefix("policy")}" + add("Cluster ${p.name.removePrefix("policy")}", "${cpus.size.coerceAtLeast(1)} × $cores · " + + (if (min != null) ghz(min) + "–" else "") + ghz(max)) + } + add("ABI", Build.SUPPORTED_ABIS.joinToString(", ")) + }) + + private fun ghz(khz: Long) = String.format(Locale.US, "%.2f GHz", khz / 1e6) + + /** The GPU's slowest and fastest step: KGSL's list, else a devfreq GPU node (Mali and others). */ + private fun gpuRange(): String? { + fun mhz(hz: Long) = "${hz / 1_000_000} MHz" + val kgsl = read("/sys/class/kgsl/kgsl-3d0/gpu_available_frequencies") + ?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty() + if (kgsl.isNotEmpty()) return "${mhz(kgsl.min())}–${mhz(kgsl.max())} (${kgsl.size} steps)" + read("/sys/class/kgsl/kgsl-3d0/max_gpuclk")?.toLongOrNull()?.let { return "up to ${mhz(it)}" } + val node = File("/sys/class/devfreq").listFiles()?.firstOrNull { f -> + val n = f.name.lowercase() + (n.contains("gpu") || n.contains("mali") || n.contains("kgsl-3d")) && listOf("bus", "bw", "memlat").none { n.contains(it) } + } ?: return null + val steps = read("${node.path}/available_frequencies")?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty() + if (steps.isNotEmpty()) return "${mhz(steps.min())}–${mhz(steps.max())} (${steps.size} steps)" + val min = read("${node.path}/min_freq")?.toLongOrNull() + val max = read("${node.path}/max_freq")?.toLongOrNull() ?: return null + return (if (min != null) "${mhz(min)}–" else "up to ") + mhz(max) + } + + private fun memory(context: Context) = Section("Memory", buildList> { + val am = context.getSystemService(ActivityManager::class.java) ?: return@buildList + val mi = ActivityManager.MemoryInfo().also(am::getMemoryInfo) + add("Total", FileUtils.sizeToString(mi.totalMem)) + add("Available now", FileUtils.sizeToString(mi.availMem)) + }) + + private fun system() = Section("System", buildList> { + add("Device", "${Build.MANUFACTURER} ${Build.MODEL}") + add("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") + add("Security patch", Build.VERSION.SECURITY_PATCH) + add("Kernel", System.getProperty("os.version")) + add("Build", Build.DISPLAY) + }) + + private fun vulkan(context: Context, vk: Map) = Section("Vulkan (the phone's own driver)", buildList> { + vk["error"]?.let { add("Error", it) } + add("Device", vk["device"]) + add("Type", vk["type"]) + add("API version", vk["api"]) + add("Loader version", vk["loader"]) + add("Driver", listOfNotNull(vk["driver_name"], vk["driver_version"]).joinToString(" ")) + // Qualcomm's build string starts with its own label ("Driver Build: 43c70540de, ..."). + add("Driver build", vk["driver_info"]?.replace(Regex("(?i)^driver build:\\s*"), "")) + add("Conformance", vk["conformance"]) + add("Vendor / device id", listOfNotNull(vk["vendor_id"], vk["device_id"]).joinToString(" / ")) + add("Max texture size", vk["max_image_2d"]) + add("GPU memory heap", vk["device_heap_mb"]?.let { "$it MB" }) + add("Extensions", vk["extension_count"]) + val features = listOf("geometry_shader" to "Geometry shaders", "tessellation_shader" to "Tessellation", "texture_bc" to "BC textures") + for ((key, label) in features) add(label, vk[key]) + for ((key, value) in vk) if (key.startsWith("ext.")) add(key.removePrefix("ext."), value) + // What Android certifies the phone for. + val pm = context.packageManager + val hw = pm.systemAvailableFeatures.associate { it.name to it.version } + hw[PackageManager.FEATURE_VULKAN_HARDWARE_VERSION]?.let { + add("Android-certified version", "${it shr 22}.${(it shr 12) and 0x3ff}.${it and 0xfff}") + } + hw[PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL]?.let { add("Android hardware level", it.toString()) } + }) + + private fun drivers(context: Context, gpu: GpuInfo) = Section("Drivers DroidDeck uses", buildList> { + add("Driver mode", SessionPrefs.gpuDriverMode(context)) + val turnip = TurnipDriver(context) + val choice = SessionPrefs.androidDriver(context).ifEmpty { turnip.autoId() } + add("Display driver", "${turnip.displayName(choice)} ${turnip.driverVersion(choice)}".trim()) + val linux = LinuxVulkanDriverManager(context) + val id = SessionPrefs.linuxDriver(context) + add("Game driver", if (id.isEmpty()) "the runtime's own Turnip" else "${linux.getDriverName(id)} ${linux.getDriverVersion(id)}".trim()) + if (gpu.oneUi8Gen2) add("Note", "One UI on an 8 Gen 2: needs the OneUI Turnip build") + }) +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt index 3054f0733..c4a05a526 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt @@ -16,10 +16,14 @@ data class GpuInfo( /** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */ val model: Int, val family: Family, - /** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */ + /** The SoC as a person would say it ("Snapdragon 8 Gen 2 (QCS8550)"), or "" when the phone does not say. */ val soc: String, /** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */ val oneUi8Gen2: Boolean, + /** KGSL's own name for the GPU, as the kernel spells it ("Adreno740v2", "Adreno33v2"). */ + val kgslName: String = "", + /** Where [model] came from: "kernel", "vulkan" or "platform", "" when unknown. */ + val modelSource: String = "", ) { enum class Family(val label: String) { A8XX("Adreno 8xx"), @@ -56,17 +60,49 @@ data class GpuInfo( val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists() val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid") .firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) } - val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else "" - val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0 + // Where vendors put the chip's model, named when it is known: "Snapdragon 8 Gen 2 (QCS8550)". + val soc = if (adreno) SocNames.label() else "" + val fromKernel = raw?.let { threeDigits(it) } ?: 0 + // Some kernels name the GPU without its model (AYANEO's Pocket FIT: "Adreno33v2"). The + // Vulkan driver's own name, when this process has asked it, then the platform's code + // name stand in - the GPU is the same on every phone of a platform. + val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0 + val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel() + val model = maxOf(fromKernel, fromVulkan, fromPlatform) + val source = when { + fromKernel > 0 -> "kernel" + fromVulkan > 0 -> "vulkan" + fromPlatform > 0 -> "platform" + else -> "" + } val family = familyOf(adreno, model) val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true) return GpuInfo( name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno", model = model, family = family, soc = soc, oneUi8Gen2 = samsung && model == 740, + kgslName = raw.orEmpty(), modelSource = source, ) } + private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + + /** Qualcomm platform code names whose GPU is known for certain. */ + private val PLATFORMS = mapOf( + "msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730, + "kalama" to 740, "pineapple" to 750, "sun" to 830, + ) + + internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int = + PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0 + + /** android.os.SystemProperties.get, which apps may read but not call directly. */ + fun systemProperty(name: String): String = try { + Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: "" + } catch (_: Exception) { + "" + } + internal fun familyOf(adreno: Boolean, model: Int): Family = when { !adreno -> Family.NOT_ADRENO model == 0 -> Family.ADRENO_UNKNOWN diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SocNames.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SocNames.kt new file mode 100644 index 000000000..76adb4bf2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SocNames.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.gpu + +import android.os.Build + +/** + * Which chip this is, as a person would say it. Handhelds often leave Android's SoC model blank + * (the AYANEO Pocket FIT does), so the model is looked for where vendors put it, the same order + * ClusterTune uses; then it is named from a table of models we are sure of, or from the platform + * code name as a family ("Snapdragon 8 Gen 3 family") when the model itself is a vendor variant. + */ +object SocNames { + /** Model codes whose product name is certain. */ + private val MODELS = mapOf( + "SM8150" to "Snapdragon 855", "SM8250" to "Snapdragon 865", "SM8250-AC" to "Snapdragon 870", + "SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1", + "SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3", + "SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5", + "SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3", + "SM6375" to "Snapdragon 695", + ) + + /** Platform code names: every chip of one platform shares its CPU and GPU design. */ + private val PLATFORMS = mapOf( + "msmnile" to "Snapdragon 855/860", "kona" to "Snapdragon 865/870", "lahaina" to "Snapdragon 888", + "taro" to "Snapdragon 8 Gen 1", "cape" to "Snapdragon 8+ Gen 1", "kalama" to "Snapdragon 8 Gen 2", + "pineapple" to "Snapdragon 8 Gen 3", "sun" to "Snapdragon 8 Elite", + ) + + /** The chip's model code ("QCS8550", "SG8350P"), or "" when the phone does not say. */ + fun model(prop: (String) -> String = GpuInfo::systemProperty): String { + val sdk = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it != Build.UNKNOWN }.orEmpty() else "" + return listOf(prop("ro.soc.model"), prop("ro.vendor.qti.soc_model"), sdk, prop("ro.fota.platform")) + .map { it.trim() } + // ro.fota.platform is often a branch name ("MSM_13.0"), not a chip. + .firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty() + } + + /** "Snapdragon 8 Gen 2", "Snapdragon 8 Gen 3 family", or null when neither the model nor the platform is known. */ + fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? = + MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" } + + /** For the GPU card: "Snapdragon 8 Gen 2 (QCS8550)", the bare model, or "". */ + fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String { + val name = name(model, platform) + return when { + name != null && model.isNotEmpty() -> "$name ($model)" + name != null -> name + else -> model + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/VulkanInfo.kt b/app/src/main/java/com/droiddeck/launcher/gpu/VulkanInfo.kt new file mode 100644 index 000000000..ee4ca1a69 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/VulkanInfo.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.gpu + +import android.util.Log + +/** + * The phone's own Vulkan driver, as it describes itself (native/deviceinfo): device name, API and + * driver versions, a few limits and the extensions DXVK/VKD3D lean on. Asked once per process and + * kept; the first [query] creates a Vulkan instance, so it belongs off the main thread. + */ +object VulkanInfo { + @Volatile private var cached: Map? = null + + @JvmStatic private external fun nativeQuery(): String + + /** Blocking on first use. Empty-ish map with an "error" key when Vulkan cannot be asked. */ + @Synchronized + fun query(): Map { + cached?.let { return it } + val text = try { + System.loadLibrary("deviceinfo") + nativeQuery() + } catch (t: Throwable) { + Log.w("VulkanInfo", "query failed", t) + "error=" + (t.message ?: t.javaClass.simpleName) + } + val map = text.lineSequence().mapNotNull { line -> + val eq = line.indexOf('=') + if (eq <= 0) null else line.substring(0, eq) to line.substring(eq + 1) + }.toMap(LinkedHashMap()) + cached = map + return map + } + + /** What [query] found, if it has run in this process; never blocks. */ + fun cachedOrNull(): Map? = cached +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt index f3f9dd631..b4c7e23ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -1,6 +1,17 @@ package com.droiddeck.launcher.ui import com.droiddeck.launcher.R +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.focusable +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import com.droiddeck.launcher.gpu.DeviceInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -180,29 +191,104 @@ private fun DriverDropdown(rows: List, selected: String, fallback: St } } +/** + * This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip - + * SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once, + * off the main thread, with a button that copies it for a bug report. + */ @Composable private fun DeviceCard(s: GpuDriversState) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current + val context = LocalContext.current + val clipboard = LocalClipboardManager.current val tint = when { s.unsupported -> pal.error s.supported -> pal.good else -> AttentionAmber } - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp), + var open by rememberSaveable { mutableStateOf(false) } + var details by remember { mutableStateOf?>(null) } + var copied by remember { mutableStateOf(false) } + LaunchedEffect(open) { + if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } + } + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val toggle = { open = !open; copied = false } + Column( + Modifier.fillMaxWidth().clip(Shape14).background(colors.surface) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) + .glideBorder(hot, Shape14, pal.signal, pal.line), ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) { - Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().paneItem("gpu-device") + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) + .controllerConfirm(onClick = toggle) + .padding(14.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) { + Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + } + Column(Modifier.weight(1f)) { + Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) + Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2) + } + Icon( + if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show), + tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp), + ) } - Column(Modifier.weight(1f)) { - Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) - Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant) + AnimatedVisibility(open) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) { + HorizontalDivider(color = pal.line) + val sections = details + if (sections == null) { + Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant) + LinearProgressIndicator(Modifier.fillMaxWidth()) + } else { + for (section in sections) DeviceInfoSection(section) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint), + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f), + ) + SecondaryButton(stringResource(R.string.device_info_copy), compact = true) { + clipboard.setText(AnnotatedString(DeviceInfo.text(sections))) + copied = true + } + } + } + } } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) - Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2) + } +} + +/** One part of the device details: its title, then label/value rows. Focusable, so the d-pad walks the list. */ +@Composable +private fun DeviceInfoSection(section: DeviceInfo.Section) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Column( + verticalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.fillMaxWidth().clip(Shape12) + .glideBorder(hot, Shape12, pal.signal) + .focusable(interactionSource = src).padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant) + for ((label, value) in section.rows) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp)) + Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f)) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3594e894..30372589a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1044,4 +1044,10 @@ %1$s Sets scaling to %2$s. Match screen (%1$d×%2$d) Steam + Show device details + Hide device details + Reading the device… + Copy + Copy these details into a bug report. + Copied. diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/GpuModelFallbackTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/GpuModelFallbackTest.kt new file mode 100644 index 000000000..10a0fd971 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/GpuModelFallbackTest.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.gpu + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GpuModelFallbackTest { + @Test fun aPlatformCodeNameGivesItsAdreno() { + // AYANEO's Pocket FIT: KGSL says "Adreno33v2", the platform says pineapple. + assertEquals(750, GpuInfo.platformModel("pineapple")) + assertEquals(740, GpuInfo.platformModel("KALAMA")) + assertEquals(830, GpuInfo.platformModel("sun")) + assertEquals(0, GpuInfo.platformModel("somethingelse")) + assertEquals(GpuInfo.Family.A7XX, GpuInfo.familyOf(true, GpuInfo.platformModel("pineapple"))) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SocNamesTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SocNamesTest.kt new file mode 100644 index 000000000..d90e261d0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SocNamesTest.kt @@ -0,0 +1,25 @@ +package com.droiddeck.launcher.gpu + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SocNamesTest { + private fun props(vararg pairs: Pair): (String) -> String = { pairs.toMap()[it].orEmpty() } + + @Test fun theModelIsFoundWhereVendorsPutIt() { + // AYANEO Pocket FIT: ro.soc.model blank, the vendor property set, ro.fota.platform a branch name. + assertEquals("SG8350P", SocNames.model(props("ro.vendor.qti.soc_model" to "SG8350P", "ro.fota.platform" to "MSM_13.0"))) + assertEquals("QCS8550", SocNames.model(props("ro.soc.model" to "QCS8550", "ro.vendor.qti.soc_model" to "other"))) + assertEquals("", SocNames.model(props("ro.fota.platform" to "MSM_13.0"))) + } + + @Test fun knownModelsAndPlatformsGetTheirNames() { + assertEquals("Snapdragon 8 Gen 2", SocNames.name("QCS8550", "")) + assertEquals("Snapdragon 8 Gen 3 family", SocNames.name("SG8350P", "pineapple")) + assertNull(SocNames.name("XYZ123", "nowhere")) + assertEquals("Snapdragon 8 Gen 2 (QCS8550)", SocNames.label("QCS8550", "")) + assertEquals("Snapdragon 8 Gen 3 family (SG8350P)", SocNames.label("SG8350P", "pineapple")) + assertEquals("XYZ123", SocNames.label("XYZ123", "")) + } +}