fix(android): reconnect Flutter texture after TV standby

This commit is contained in:
edde746
2026-07-11 01:01:08 +02:00
parent 03651d6c73
commit 02a032d287
3 changed files with 193 additions and 35 deletions
@@ -0,0 +1,46 @@
package com.edde746.plezy
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.view.TextureView
internal class DeferredSurfaceTextureListener(
private val delegate: TextureView.SurfaceTextureListener,
private val handler: Handler = Handler(Looper.getMainLooper()),
private val onSurfaceAvailable: () -> Unit = {},
private val isCurrentSurface: (SurfaceTexture) -> Boolean
) : TextureView.SurfaceTextureListener {
private var pendingResize: Runnable? = null
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
delegate.onSurfaceTextureAvailable(surface, width, height)
onSurfaceAvailable()
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
pendingResize?.let(handler::removeCallbacks)
pendingResize = Runnable {
pendingResize = null
if (isCurrentSurface(surface)) {
delegate.onSurfaceTextureSizeChanged(surface, width, height)
}
}
handler.postDelayed(pendingResize!!, RESIZE_DELAY_MILLIS)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
delegate.onSurfaceTextureUpdated(surface)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
pendingResize?.let(handler::removeCallbacks)
pendingResize = null
return delegate.onSurfaceTextureDestroyed(surface)
}
private companion object {
const val RESIZE_DELAY_MILLIS = 100L
}
}
@@ -9,20 +9,16 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.SurfaceTexture
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.provider.Settings
import android.util.Log
import android.util.Rational
import android.view.InputDevice
import android.view.KeyEvent
import android.view.TextureView
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
@@ -93,6 +89,9 @@ class MainActivity : FlutterActivity() {
private var nativeTextInputFocused = false
private var pendingExternalPlayerResult: MethodChannel.Result? = null
private var originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null
private var flutterSurfaceReconnectPending = false
private var activityStarted = false
private inline fun logTextInputDiag(message: () -> String) {
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
@@ -383,6 +382,9 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
pendingExternalPlayerResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null)
pendingExternalPlayerResult = null
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
super.onDestroy()
}
@@ -448,41 +450,49 @@ class MainActivity : FlutterActivity() {
}
override fun onFlutterTextureViewCreated(flutterTextureView: FlutterTextureView) {
this.flutterTextureView = flutterTextureView
val original = flutterTextureView.surfaceTextureListener ?: return
val handler = Handler(Looper.getMainLooper())
var pendingResize: Runnable? = null
var lastWidth = 0
var lastHeight = 0
flutterTextureView.surfaceTextureListener =
DeferredSurfaceTextureListener(
delegate = original,
onSurfaceAvailable = ::tryReconnectFlutterSurface
) { surface ->
flutterTextureView.isAvailable && flutterTextureView.surfaceTexture === surface
}
}
flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
original.onSurfaceTextureAvailable(surface, width, height)
override fun onStop() {
activityStarted = false
if (isAndroidTvDevice()) flutterSurfaceReconnectPending = true
super.onStop()
}
override fun onStart() {
super.onStart()
activityStarted = true
tryReconnectFlutterSurface()
}
private fun tryReconnectFlutterSurface() {
if (!activityStarted || !flutterSurfaceReconnectPending) return
val textureView = flutterTextureView ?: return
textureView.post {
if (!activityStarted ||
!flutterSurfaceReconnectPending ||
textureView !== flutterTextureView ||
!textureView.isAttachedToWindow ||
!textureView.isAvailable
) {
return@post
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
if (width == lastWidth && height == lastHeight) return
lastWidth = width
lastHeight = height
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = Runnable {
if (flutterTextureView.isAvailable) {
original.onSurfaceTextureSizeChanged(surface, width, height)
}
}
handler.postDelayed(pendingResize!!, 100)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
original.onSurfaceTextureUpdated(surface)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = null
lastWidth = 0
lastHeight = 0
return original.onSurfaceTextureDestroyed(surface)
}
// Some TV firmware retains an available TextureView while invalidating
// its compositor surface during standby. Force Flutter's supported
// surface-swap path so rendering resumes without restarting the engine.
Log.i(TAG, "Reconnecting Flutter texture surface after Android TV standby")
textureView.pause()
textureView.resume()
flutterSurfaceReconnectPending = false
}
}
@@ -0,0 +1,102 @@
package com.edde746.plezy
import android.graphics.SurfaceTexture
import android.os.Looper
import android.view.TextureView
import java.time.Duration
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@LooperMode(LooperMode.Mode.PAUSED)
class DeferredSurfaceTextureListenerTest {
private lateinit var surface: SurfaceTexture
private lateinit var delegate: RecordingListener
private lateinit var listener: DeferredSurfaceTextureListener
private var currentSurface: SurfaceTexture? = null
@Before
fun setUp() {
surface = SurfaceTexture(0)
currentSurface = surface
delegate = RecordingListener()
listener = DeferredSurfaceTextureListener(delegate) { candidate -> candidate === currentSurface }
}
@After
fun tearDown() {
surface.release()
}
@Test
fun forwardsRepeatedSameSizeCallbacks() {
listener.onSurfaceTextureSizeChanged(surface, 1920, 1080)
idlePastResizeDelay()
listener.onSurfaceTextureSizeChanged(surface, 1920, 1080)
idlePastResizeDelay()
assertEquals(listOf(1920 to 1080, 1920 to 1080), delegate.sizes)
}
@Test
fun coalescesPendingCallbacksToLatestSize() {
listener.onSurfaceTextureSizeChanged(surface, 1280, 720)
listener.onSurfaceTextureSizeChanged(surface, 1920, 1080)
idlePastResizeDelay()
assertEquals(listOf(1920 to 1080), delegate.sizes)
}
@Test
fun ignoresCallbackForObsoleteSurface() {
val replacement = SurfaceTexture(0)
try {
listener.onSurfaceTextureSizeChanged(surface, 1920, 1080)
currentSurface = replacement
idlePastResizeDelay()
assertEquals(emptyList<Pair<Int, Int>>(), delegate.sizes)
} finally {
replacement.release()
}
}
@Test
fun destroyingSurfaceCancelsPendingCallback() {
listener.onSurfaceTextureSizeChanged(surface, 1920, 1080)
listener.onSurfaceTextureDestroyed(surface)
idlePastResizeDelay()
assertEquals(emptyList<Pair<Int, Int>>(), delegate.sizes)
assertEquals(1, delegate.destroyedCount)
}
private fun idlePastResizeDelay() {
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(101))
}
private class RecordingListener : TextureView.SurfaceTextureListener {
val sizes = mutableListOf<Pair<Int, Int>>()
var destroyedCount = 0
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) = Unit
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
sizes += width to height
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
destroyedCount++
return true
}
}
}