diff --git a/android/app/src/main/kotlin/com/edde746/plezy/FlutterRendererPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/FlutterRendererPolicy.kt new file mode 100644 index 00000000..18d96b02 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/FlutterRendererPolicy.kt @@ -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 + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 198dcd1f..6dc4d995 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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("mode") diff --git a/android/app/src/test/kotlin/com/edde746/plezy/FlutterRendererPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/FlutterRendererPolicyTest.kt new file mode 100644 index 00000000..5ff2bfc2 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/FlutterRendererPolicyTest.kt @@ -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 + ) +}