fix(android): use OpenGLES on 32-bit TCL TVs

This commit is contained in:
edde746
2026-07-24 12:22:58 +02:00
parent 40833e65ec
commit 4cc9a35b2e
3 changed files with 128 additions and 28 deletions
@@ -0,0 +1,45 @@
package com.edde746.plezy
internal enum class FlutterRenderer(
val diagnosticName: String,
val shellArgument: String?
) {
SKIA("Skia", "--enable-impeller=false"),
IMPELLER("Impeller", null),
IMPELLER_OPEN_GLES("Impeller (OpenGLES)", "--impeller-backend=opengles")
}
/** Selects the Flutter UI renderer before the engine starts. */
internal object FlutterRendererPolicy {
private const val ANDROID_12_API = 31
fun select(
isEWaste: Boolean,
manufacturer: String,
isAndroidTv: Boolean,
sdkInt: Int,
supportsVulkan11: Boolean,
is64Bit: Boolean
): FlutterRenderer {
if (isEWaste) return FlutterRenderer.SKIA
if (manufacturer.equals("NVIDIA", ignoreCase = true)) return FlutterRenderer.SKIA
if (manufacturer.equals("Huawei", ignoreCase = true) ||
manufacturer.equals("HONOR", ignoreCase = true)
) {
return FlutterRenderer.SKIA
}
if (!isAndroidTv) return FlutterRenderer.IMPELLER
if (sdkInt < ANDROID_12_API || manufacturer.equals("Amazon", ignoreCase = true) || !supportsVulkan11) {
return FlutterRenderer.SKIA
}
// PowerVR BXE-4-32 drivers in TCL's 32-bit Android 12 TV platform leave
// stale Vulkan frames on screen while scrolling (#1658, flutter/flutter#189767).
// Impeller's OpenGLES backend renders correctly on the same hardware.
if (!is64Bit && manufacturer.equals("TCL", ignoreCase = true)) {
return FlutterRenderer.IMPELLER_OPEN_GLES
}
return FlutterRenderer.IMPELLER
}
}
@@ -65,7 +65,7 @@ class MainActivity : FlutterActivity() {
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
private const val LOW_MEM_THRESHOLD_BYTES = 2252L shl 20
var usingSkia = false
private var selectedFlutterRenderer = FlutterRenderer.IMPELLER
}
private val PIP_CHANNEL = "com.plezy/pip"
@@ -469,43 +469,38 @@ class MainActivity : FlutterActivity() {
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
usingSkia = shouldDisableImpeller()
if (usingSkia) args.add("--enable-impeller=false")
selectedFlutterRenderer = selectFlutterRenderer()
selectedFlutterRenderer.shellArgument?.let { args.add(it) }
if (isLowRamClass()) {
// Bound the memory pools Dart can't reach: Skia's GPU resource cache
// is sized from the surface area (hundreds of MB on a 4K-composited
// TV) and the Dart old gen defaults to a large fraction of physical
// RAM. Both drive LMK kills on 2GB boxes (#1349).
if (usingSkia) args.add("--resource-cache-max-bytes-threshold=50331648")
if (selectedFlutterRenderer == FlutterRenderer.SKIA) {
args.add("--resource-cache-max-bytes-threshold=50331648")
}
args.add("--old-gen-heap-size=256")
Log.i(TAG, "Low-RAM device: capped engine caches (skia=$usingSkia, oldGen=256MB)")
Log.i(
TAG,
"Low-RAM device: capped engine caches " +
"(renderer=${selectedFlutterRenderer.diagnosticName}, oldGen=256MB)"
)
}
return args
}
private fun shouldDisableImpeller(): Boolean {
if (DeviceQuirks.isEWaste) return true
// NVIDIA Tegra (Shield TV)
if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true
// Huawei/HONOR Kirin SoCs use Mali GPUs
if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) ||
Build.MANUFACTURER.equals("HONOR", ignoreCase = true)
) {
return true
}
if (isAndroidTvDevice()) return !tvSupportsImpeller()
return false
}
// Impeller froze API 30 Fire TV hardware (#749) and Flutter's Vulkan → GLES
// fallback still miscompiles gradients/SVGs, so only TV devices on Android 12+
// with a Vulkan 1.1 driver leave the Skia path.
private fun tvSupportsImpeller(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
// Fire OS reports modern API levels on GPUs whose drivers can't back it up
if (Build.MANUFACTURER.equals("Amazon", ignoreCase = true)) return false
private fun selectFlutterRenderer(): FlutterRenderer {
val isAndroidTv = isAndroidTvDevice()
val vulkan11 = 0x401000 // FEATURE_VULKAN_HARDWARE_VERSION encodes 1.1.0 as 0x401000
return packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, vulkan11)
return FlutterRendererPolicy.select(
isEWaste = DeviceQuirks.isEWaste,
manufacturer = Build.MANUFACTURER,
isAndroidTv = isAndroidTv,
sdkInt = Build.VERSION.SDK_INT,
supportsVulkan11 = isAndroidTv &&
packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, vulkan11),
is64Bit = Process.is64Bit()
)
}
override fun getRenderMode(): RenderMode {
@@ -628,7 +623,7 @@ class MainActivity : FlutterActivity() {
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller")
"getRenderer" -> result.success(selectedFlutterRenderer.diagnosticName)
"setSplashTheme" -> {
val mode = call.argument<String>("mode")
@@ -0,0 +1,60 @@
package com.edde746.plezy
import org.junit.Assert.assertEquals
import org.junit.Test
class FlutterRendererPolicyTest {
@Test
fun affected32BitTclTvUsesImpellerOpenGles() {
val renderer = select(manufacturer = "TCL", is64Bit = false)
assertEquals(FlutterRenderer.IMPELLER_OPEN_GLES, renderer)
assertEquals("--impeller-backend=opengles", renderer.shellArgument)
assertEquals("Impeller (OpenGLES)", renderer.diagnosticName)
}
@Test
fun capable64BitTclTvKeepsAutomaticImpellerBackend() {
assertEquals(FlutterRenderer.IMPELLER, select(manufacturer = "TCL", is64Bit = true))
}
@Test
fun unsupportedTvsStayOnSkia() {
assertEquals(FlutterRenderer.SKIA, select(sdkInt = 30))
assertEquals(FlutterRenderer.SKIA, select(supportsVulkan11 = false))
assertEquals(FlutterRenderer.SKIA, select(manufacturer = "Amazon"))
}
@Test
fun existingDeviceDenylistStillTakesPrecedence() {
assertEquals(FlutterRenderer.SKIA, select(isEWaste = true, isAndroidTv = false))
assertEquals(FlutterRenderer.SKIA, select(manufacturer = "NVIDIA", isAndroidTv = false))
assertEquals(FlutterRenderer.SKIA, select(manufacturer = "Huawei", isAndroidTv = false))
assertEquals(FlutterRenderer.SKIA, select(manufacturer = "HONOR", isAndroidTv = false))
}
@Test
fun ordinaryAndroidDevicesKeepAutomaticImpellerBackend() {
assertEquals(
FlutterRenderer.IMPELLER,
select(manufacturer = "Samsung", isAndroidTv = false, sdkInt = 28, supportsVulkan11 = false)
)
}
private fun select(
isEWaste: Boolean = false,
manufacturer: String = "Google",
isAndroidTv: Boolean = true,
sdkInt: Int = 31,
supportsVulkan11: Boolean = true,
is64Bit: Boolean = true
): FlutterRenderer = FlutterRendererPolicy.select(
isEWaste = isEWaste,
manufacturer = manufacturer,
isAndroidTv = isAndroidTv,
sdkInt = sdkInt,
supportsVulkan11 = supportsVulkan11,
is64Bit = is64Bit
)
}