fix: resolve MTK decoder crash on unsupported resolution and rotation

- 新增 DecoderCapabilities,创建 decoder 前预检本机解码器分辨率支持
This commit is contained in:
Miuzarte
2026-07-12 19:00:52 +08:00
parent 90a188f355
commit b918f18e9e
7 changed files with 249 additions and 7 deletions

View File

@@ -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"
}

View File

@@ -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
}
}
}

View File

@@ -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),

View File

@@ -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

View File

@@ -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),

View File

@@ -168,6 +168,8 @@
<string name="section_screen_mirroring">投屏</string>
<string name="pref_title_low_latency_audio">低延迟音频(实验性)</string>
<string name="pref_summary_low_latency_audio">启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用</string>
<string name="pref_title_downsize_on_decode_error">解码出错时自动降级</string>
<string name="pref_summary_downsize_on_decode_error">当本机解码器不支持当前视频分辨率时,自动以较低的 max_size 重启会话,关闭后仅提示错误</string>
<string name="pref_title_debug_info">启用调试信息</string>
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
@@ -574,6 +576,9 @@
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string>
<string name="vm_session_disconnected">Scrcpy 连接已断开</string>
<string name="vm_decoder_unsupported_size">设备解码器不支持 %1$dx%2$d正在降级为 max_size=%3$d 并重启</string>
<string name="vm_decoder_init_failed">解码器初始化失败:%1$s</string>
<string name="vm_decoder_error_restarting">解码器错误,正在重启会话</string>
<string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string>
<string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string>
<string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string>

View File

@@ -169,6 +169,8 @@
<string name="section_screen_mirroring">Screen mirroring</string>
<string name="pref_title_low_latency_audio">Low latency audio (experimental)</string>
<string name="pref_summary_low_latency_audio">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</string>
<string name="pref_title_downsize_on_decode_error">Downscale on decoder error</string>
<string name="pref_summary_downsize_on_decode_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</string>
<string name="pref_title_debug_info">Enable debug info</string>
<string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string>
<string name="pref_title_hide_simple_settings">Hide simple settings on device page</string>
@@ -372,14 +374,14 @@
<string name="password_no_lock_screen_warn">Continuing will allow saving and filling lockscreen passwords without authentication protection</string>
<string name="password_agree">Agree</string>
<string name="password_auth_lost_warn">Passwords will lose protection after disabling authentication</string>
<string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned.</string>
<string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned</string>
<string name="password_continue_disable">Continue to disable</string>
<string name="password_delete_confirm">Delete password</string>
<string name="password_delete_msg">Will delete %1$s</string>
<string name="password_default_name">Password %1$d</string>
<string name="password_cannot_be_empty">Password cannot be empty</string>
<string name="password_require_auth">Require authentication when filling passwords</string>
<string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned.</string>
<string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned</string>
<string name="password_no_auth_capability">Current device has no authentication capability</string>
<string name="password_or_menu_hint">Or in the top-right menu</string>
<string name="password_status_invalidated">Invalidated</string>
@@ -388,7 +390,7 @@
<string name="password_status_burned">Authenticated when created (burned)</string>
<string name="password_rename">Rename password</string>
<string name="password_lockscreen_label">Lockscreen password</string>
<string name="password_disclaimer">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.</string>
<string name="password_disclaimer">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</string>
<!-- File Manager -->
<string name="fm_cd_parent">Parent directory</string>
@@ -526,14 +528,14 @@
<string name="scrcpyopt_no_vd_destroy_content">Retain content when closing virtual display</string>
<string name="scrcpyopt_no_vd_decorations">Disable virtual display system decorations</string>
<string name="scrcpyopt_no_downsize_on_error">Disable auto-downscale on MediaCodec error</string>
<string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior.</string>
<string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior</string>
<string name="scrcpyopt_legacy_paste">Legacy paste (ASCII/English characters only)</string>
<string name="scrcpyopt_key_inject_mode">Keyboard injection mode</string>
<string name="scrcpyopt_no_key_repeat">Do not forward repeated key events when a key is held down</string>
<string name="scrcpyopt_no_clipboard_autosync">Disable automatic clipboard synchronization</string>
<string name="scrcpyopt_no_mouse_hover">Do not forward mouse hover events</string>
<string name="scrcpyopt_no_cleanup">Disable cleanup on exit</string>
<string name="scrcpyopt_no_cleanup_desc">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.</string>
<string name="scrcpyopt_no_cleanup_desc">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</string>
<string name="scrcpyopt_flex_display">Automatically resize to match the screen</string>
<string name="scrcpyopt_start_app">Open app after scrcpy starts</string>
<string name="scrcpyopt_native">Native</string>
@@ -575,6 +577,9 @@
<string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string>
<string name="vm_clipboard_paste_failed">Local clipboard paste failed</string>
<string name="vm_session_disconnected">Scrcpy session disconnected</string>
<string name="vm_decoder_unsupported_size">Device decoder does not support %1$dx%2$d, downgrading to max_size=%3$d and restarting</string>
<string name="vm_decoder_init_failed">Decoder initialization failed: %1$s</string>
<string name="vm_decoder_error_restarting">Decoder error, restarting session</string>
<string name="vm_ime_text_failed">IME text submission failed: %1$s</string>
<string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string>
<string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string>