From b918f18e9e7dd65e78b2cfceff4ce7beedf2b6f6 Mon Sep 17 00:00:00 2001
From: Miuzarte <982809597@qq.com>
Date: Sun, 12 Jul 2026 19:00:52 +0800
Subject: [PATCH] fix: resolve MTK decoder crash on unsupported resolution and
rotation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 DecoderCapabilities,创建 decoder 前预检本机解码器分辨率支持
---
.../scrcpyforandroid/NativeCoreFacade.kt | 125 +++++++++++++++++-
.../nativecore/DecoderCapabilities.kt | 92 +++++++++++++
.../scrcpyforandroid/pages/SettingsScreen.kt | 10 ++
.../scrcpyforandroid/scrcpy/Scrcpy.kt | 2 +-
.../scrcpyforandroid/storage/AppSettings.kt | 7 +
app/src/main/res/values-zh/strings.xml | 5 +
app/src/main/res/values/strings.xml | 15 ++-
7 files changed, 249 insertions(+), 7 deletions(-)
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DecoderCapabilities.kt
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt
index 9c827dd..594a469 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt
@@ -2,9 +2,14 @@ package io.github.miuzarte.scrcpyforandroid
import android.util.Log
import android.view.Surface
+import io.github.miuzarte.scrcpyforandroid.nativecore.DecoderCapabilities
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
import io.github.miuzarte.scrcpyforandroid.nativecore.VideoDecoderController
+import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
+import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
+import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
+import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -20,6 +25,13 @@ import kotlinx.coroutines.sync.withLock
*
* The facade owns the session lifecycle mutex and coordinates surface attach/detach
* with decoder creation. It also publishes video size / FPS to listeners.
+ *
+ * Error recovery:
+ * - When the local decoder cannot handle the video resolution (e.g. MTK AVC caps at
+ * 2048 but scrcpy sends 2400), the facade can automatically restart the session with
+ * a lower `max_size` (controlled by the "downsize on decode error" app setting).
+ * - When the decoder enters an unrecoverable error state at runtime (e.g. MTK OMX
+ * buffer conflict after rotation), the facade restarts the session.
*/
object NativeCoreFacade {
private val sessionLifecycleMutex = Mutex()
@@ -40,6 +52,14 @@ object NativeCoreFacade {
@Volatile
private var packetCount: Long = 0
+ // Cached ClientOptions for session restart (downgrade / error recovery)
+ @Volatile
+ private var cachedClientOptions: ClientOptions? = null
+
+ // Guards against recursive restarts (restart triggers onScrcpySessionStopped/Started)
+ @Volatile
+ private var isRestarting = false
+
suspend fun close() {
sessionLifecycleMutex.withLock {
controller.releaseAll()
@@ -144,13 +164,19 @@ object NativeCoreFacade {
/**
* Called by Scrcpy.kt when a session starts.
* Sets up video decoders for registered surfaces.
+ *
+ * [options] is cached so the facade can restart the session (with a modified max_size)
+ * when the decoder fails.
*/
suspend fun onScrcpySessionStarted(
session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session,
scrcpy: Scrcpy,
+ options: ClientOptions,
) = sessionLifecycleMutex.withLock {
scrcpyRef = scrcpy
+ cachedClientOptions = options
+ isRestarting = false
controller.releaseAll()
controller.resetBootstrap()
if (activeSurfaceId != null || recordingSurfaceAttached) {
@@ -171,13 +197,29 @@ object NativeCoreFacade {
/**
* Called by Scrcpy when a video session packet arrives with new dimensions.
* Launches a coroutine to create or rebuild the decoder under the lifecycle mutex.
+ *
+ * Before creating the decoder, checks whether the local hardware decoder supports the
+ * target resolution. If not, triggers a downgrade restart (when enabled) or shows an
+ * error snackbar.
*/
fun onVideoSizeChanged(width: Int, height: Int) {
lifecycleScope.launch {
sessionLifecycleMutex.withLock {
+ if (isRestarting) return@withLock
val scrcpy = scrcpyRef ?: return@withLock
val info = scrcpy.currentSessionState.value ?: return@withLock
if (info.width <= 0 || info.height <= 0) return@withLock
+
+ val mime = mimeForCodec(info.codec)
+ if (!DecoderCapabilities.isSizeSupported(mime, info.width, info.height)) {
+ Log.w(
+ TAG,
+ "onVideoSizeChanged(): decoder does not support ${info.width}x${info.height} for $mime",
+ )
+ handleUnsupportedSize(info.width, info.height, mime)
+ return@withLock
+ }
+
controller.rebuildDecoderForSize(info)
}
}
@@ -202,10 +244,91 @@ object NativeCoreFacade {
)
}
val usable = controller.feed(packet)
- if (!usable) {
+ if (!usable && !isRestarting) {
Log.e(TAG, "videoFeed(): decoder became unusable after feed, packets=$packetCount")
+ handleDecoderError()
}
}
+ /**
+ * Handle the case where the local decoder does not support the target resolution.
+ *
+ * When "downsize on decode error" is enabled (default), restarts the session with a
+ * lower `max_size` derived from [DecoderCapabilities.maxSupportedSize]. Otherwise,
+ * shows an error snackbar and leaves the decoder uncreated (black screen, no crash).
+ */
+ private suspend fun handleUnsupportedSize(width: Int, height: Int, mime: String) {
+ val downsizeEnabled = Storage.appSettings.bundleState.value.downsizeOnDecodeError
+ if (!downsizeEnabled) {
+ AppRuntime.snackbar(R.string.vm_decoder_init_failed, "${width}x${height}")
+ return
+ }
+ val maxSize = DecoderCapabilities.maxSupportedSize(mime)
+ Log.i(TAG, "handleUnsupportedSize(): downgrading to max_size=$maxSize")
+ AppRuntime.snackbar(R.string.vm_decoder_unsupported_size, width, height, maxSize)
+ downgradeAndRestart(maxSize)
+ }
+
+ /**
+ * Handle a runtime decoder error (codec entered unrecoverable state).
+ *
+ * Restarts the scrcpy session so a fresh decoder is created from scratch.
+ */
+ private fun handleDecoderError() {
+ if (isRestarting) return
+ isRestarting = true
+ Log.i(TAG, "handleDecoderError(): restarting session due to decoder error")
+ AppRuntime.snackbar(R.string.vm_decoder_error_restarting)
+ lifecycleScope.launch {
+ sessionLifecycleMutex.withLock {
+ restartSessionWith(cachedClientOptions)
+ }
+ }
+ }
+
+ /**
+ * Restart the scrcpy session with a modified `max_size`.
+ *
+ * Persists the new max_size to [Storage.scrcpyOptions] so the user sees the change
+ * in the UI, then stops and restarts the session.
+ */
+ private suspend fun downgradeAndRestart(maxSize: Int) {
+ if (isRestarting) return
+ isRestarting = true
+ val options = cachedClientOptions ?: return
+ val newOptions = options.copy(maxSize = maxSize.toUShort()).fix().validate()
+
+ // Persist the new max_size so the UI reflects the downgrade.
+ runCatching {
+ val soBundle = Storage.scrcpyOptions.bundleState.value
+ Storage.scrcpyOptions.saveBundle(soBundle.copy(maxSize = maxSize))
+ }
+
+ restartSessionWith(newOptions)
+ }
+
+ /**
+ * Stop the current session and start a new one with [options].
+ *
+ * Must be called while holding [sessionLifecycleMutex]. The [isRestarting] flag
+ * prevents recursive restarts — it is cleared in [onScrcpySessionStarted] when the
+ * new session comes up.
+ */
+ private suspend fun restartSessionWith(options: ClientOptions?) {
+ val scrcpy = scrcpyRef ?: return
+ if (options == null) return
+ Log.i(TAG, "restartSessionWith(): stopping current session")
+ runCatching { scrcpy.stop(Scrcpy.StopReason.USER) }
+ Log.i(TAG, "restartSessionWith(): starting new session with max_size=${options.maxSize}")
+ runCatching { scrcpy.start(options) }
+ }
+
+ private fun mimeForCodec(codec: Codec?): String = when (codec) {
+ Codec.H264 -> "video/avc"
+ Codec.H265 -> "video/hevc"
+ Codec.AV1 -> "video/av01"
+ else -> "video/avc"
+ }
+
private const val TAG = "NativeCoreFacade"
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DecoderCapabilities.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DecoderCapabilities.kt
new file mode 100644
index 0000000..ecffe71
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DecoderCapabilities.kt
@@ -0,0 +1,92 @@
+package io.github.miuzarte.scrcpyforandroid.nativecore
+
+import android.media.MediaCodecInfo
+import android.media.MediaCodecList
+import android.util.Log
+
+/**
+ * Queries the local device's hardware decoder capabilities to determine whether a given
+ * video size is supported before attempting to create a [MediaCodec].
+ *
+ * This is primarily needed for chips like MTK AC8257 whose AVC decoder caps at 2048x1088,
+ * while scrcpy may send 1080x2400 (long edge 2400 > 2048), causing
+ * [MediaCodec.configure] to throw [IllegalArgumentException].
+ */
+object DecoderCapabilities {
+
+ private const val TAG = "DecoderCapabilities"
+
+ /**
+ * The conservative fallback max size returned by [maxSupportedSize] when the codec list
+ * query fails or returns no usable information. 1920 is a safe upper bound for virtually
+ * all hardware decoders on Android 10+.
+ */
+ private const val FALLBACK_MAX_SIZE = 1920
+
+ /**
+ * Check whether any hardware decoder on this device supports decoding [width]x[height]
+ * for the given [mime] type.
+ *
+ * - Only decoders (not encoders) are considered.
+ * - Software-only decoders are included as a last resort (some devices only have a
+ * software decoder for AV1), but hardware decoders are preferred.
+ * - When [MediaCodecInfo.VideoCapabilities.areSizeSupported] is unavailable or throws,
+ * returns true (optimistic) so the caller can still attempt creation and rely on
+ * [DecoderException] for the error path.
+ */
+ fun isSizeSupported(mime: String, width: Int, height: Int): Boolean {
+ if (width <= 0 || height <= 0) return true
+ return try {
+ val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
+ codecList.codecInfos.any { info ->
+ info.isEncoder.not() &&
+ isCodecCapable(info, mime, width, height)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "isSizeSupported(): query failed for $mime ${width}x$height, assuming supported", e)
+ true
+ }
+ }
+
+ /**
+ * Returns the largest "long edge" size (i.e. `max(width, height)`) that any decoder on
+ * this device supports for [mime].
+ *
+ * Used by the downgrade logic to pick a new `max_size` value that the decoder can handle.
+ * The probe tries common sizes from 1080 up to 4096; if none are supported (unlikely),
+ * returns [FALLBACK_MAX_SIZE].
+ */
+ fun maxSupportedSize(mime: String): Int {
+ return try {
+ val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
+ val decoders = codecList.codecInfos.filter { !it.isEncoder }
+
+ // Try from largest to smallest; return the first that works for any decoder.
+ // This avoids probing every integer — a handful of common breakpoints is enough.
+ for (size in intArrayOf(3840, 2560, 2048, 1920, 1600, 1280, 1080)) {
+ if (decoders.any { isCodecCapable(it, mime, size, size) }) {
+ return size
+ }
+ }
+ FALLBACK_MAX_SIZE
+ } catch (e: Exception) {
+ Log.w(TAG, "maxSupportedSize(): query failed for $mime, returning fallback $FALLBACK_MAX_SIZE", e)
+ FALLBACK_MAX_SIZE
+ }
+ }
+
+ private fun isCodecCapable(
+ info: MediaCodecInfo,
+ mime: String,
+ width: Int,
+ height: Int,
+ ): Boolean {
+ return try {
+ val caps = info.getCapabilitiesForType(mime) ?: return false
+ caps.videoCapabilities?.isSizeSupported(width, height) == true
+ } catch (e: Exception) {
+ // Some codecs throw for unsupported mime types — that's fine, just skip.
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
index b3c24ec..f58407c 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
@@ -467,6 +467,16 @@ fun SettingsPage(
)
},
)
+ SwitchPreference(
+ title = stringResource(R.string.pref_title_downsize_on_decode_error),
+ summary = stringResource(R.string.pref_summary_downsize_on_decode_error),
+ checked = asBundle.downsizeOnDecodeError,
+ onCheckedChange = {
+ asBundle = asBundle.copy(
+ downsizeOnDecodeError = it,
+ )
+ },
+ )
SwitchPreference(
title = stringResource(R.string.pref_title_debug_info),
summary = stringResource(R.string.pref_summary_debug_info),
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt
index 60c31bf..9d9ae77 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt
@@ -202,7 +202,7 @@ class Scrcpy(
// Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) {
- NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy)
+ NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy, options)
}
// Setup audio player
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
index 43d3954..55426cf 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
@@ -127,6 +127,10 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
booleanPreferencesKey("low_latency"),
false,
)
+ val DOWNSIZE_ON_DECODE_ERROR = Pair(
+ booleanPreferencesKey("downsize_on_decode_error"),
+ true,
+ )
val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"),
false,
@@ -318,6 +322,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy
val lowLatency: Boolean,
+ val downsizeOnDecodeError: Boolean,
val fullscreenDebugInfo: Boolean,
val hideSimpleConfigItems: Boolean,
val previewCardOnTop: Boolean,
@@ -383,6 +388,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy
bundleField(LOW_LATENCY) { it.lowLatency },
+ bundleField(DOWNSIZE_ON_DECODE_ERROR) { it.downsizeOnDecodeError },
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop },
@@ -449,6 +455,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy
lowLatency = preferences.read(LOW_LATENCY),
+ downsizeOnDecodeError = preferences.read(DOWNSIZE_ON_DECODE_ERROR),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP),
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 03b4b93..3287fc4 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -168,6 +168,8 @@
投屏
低延迟音频(实验性)
启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用
+ 解码出错时自动降级
+ 当本机解码器不支持当前视频分辨率时,自动以较低的 max_size 重启会话,关闭后仅提示错误
启用调试信息
在全屏界面悬浮显示分辨率、帧率和触点信息
设备页隐藏简单设置项
@@ -574,6 +576,9 @@
已同步本机剪贴板到设备并触发粘贴
本机剪贴板粘贴失败
Scrcpy 连接已断开
+ 设备解码器不支持 %1$dx%2$d,正在降级为 max_size=%3$d 并重启
+ 解码器初始化失败:%1$s
+ 解码器错误,正在重启会话
输入法文本提交失败:%1$s
ADB 已断开:%1$s
mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 76145d3..be74265 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -169,6 +169,8 @@
Screen mirroring
Low latency audio (experimental)
When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app
+ Downscale on decoder error
+ When the local decoder cannot handle the video resolution, automatically restart the session with a lower max_size. Disable to show an error instead
Enable debug info
Display resolution, frame rate, and touch info as overlay in fullscreen
Hide simple settings on device page
@@ -372,14 +374,14 @@
Continuing will allow saving and filling lockscreen passwords without authentication protection
Agree
Passwords will lose protection after disabling authentication
- After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned.
+ After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned
Continue to disable
Delete password
Will delete %1$s
Password %1$d
Password cannot be empty
Require authentication when filling passwords
- After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned.
+ After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned
Current device has no authentication capability
Or in the top-right menu
Invalidated
@@ -388,7 +390,7 @@
Authenticated when created (burned)
Rename password
Lockscreen password
- Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully.
+ Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully
Parent directory
@@ -526,14 +528,14 @@
Retain content when closing virtual display
Disable virtual display system decorations
Disable auto-downscale on MediaCodec error
- By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior.
+ By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior
Legacy paste (ASCII/English characters only)
Keyboard injection mode
Do not forward repeated key events when a key is held down
Disable automatic clipboard synchronization
Do not forward mouse hover events
Disable cleanup on exit
- By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation.
+ By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation
Automatically resize to match the screen
Open app after scrcpy starts
Native
@@ -575,6 +577,9 @@
Synced local clipboard to device and triggered paste
Local clipboard paste failed
Scrcpy session disconnected
+ Device decoder does not support %1$dx%2$d, downgrading to max_size=%3$d and restarting
+ Decoder initialization failed: %1$s
+ Decoder error, restarting session
IME text submission failed: %1$s
ADB disconnected: %1$s
mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s