upstream: scrcpy v4.1 (#59)
编译期下载 URL/版本/SHA256 更新至 v4.1 支持 VP8 与 VP9 解码 支持 ignoreVideoEncoderConstraints 参数 AnnexBDecoder.kt 重命名至 MediaCodecVideoDecoder.kt,避免在使用 vp8/vp9 时的语义问题
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Change Log
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- 依赖: scrcpy 升级至 v4.1
|
||||
- 新增: vp8/vp9 解码
|
||||
|
||||
## 0.4.5
|
||||
|
||||
- 重构: 改了好多不知道怎么写,总之修复了一些已知问题
|
||||
|
||||
@@ -59,8 +59,8 @@ android {
|
||||
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 35
|
||||
versionName = "0.4.5_pre1"
|
||||
versionCode = 36
|
||||
versionName = "0.5.0"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
@@ -168,9 +168,9 @@ dependencies {
|
||||
}
|
||||
|
||||
val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin"
|
||||
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.0"
|
||||
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.0/scrcpy-server-v4.0"
|
||||
val scrcpyServerSha256 = "84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a"
|
||||
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.1"
|
||||
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-server-v4.1"
|
||||
val scrcpyServerSha256 = "deacb991ed2509715160ffdc7907e47b4160eb30d1566217e9047fd5b8850cae"
|
||||
|
||||
val downloadScrcpyServer by tasks.registering {
|
||||
description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch"
|
||||
@@ -203,7 +203,7 @@ val downloadScrcpyServer by tasks.registering {
|
||||
val needsDownload = !file.exists() || computeSha256(file) != expectedSha
|
||||
|
||||
if (needsDownload) {
|
||||
logger.lifecycle("Downloading scrcpy-server-v4.0 from GitHub releases...")
|
||||
logger.lifecycle("Downloading scrcpy-server-v4.1 from GitHub releases...")
|
||||
try {
|
||||
URI(url).toURL().openStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
@@ -212,7 +212,7 @@ val downloadScrcpyServer by tasks.registering {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw GradleException(
|
||||
"Failed to download scrcpy-server-v4.0 from GitHub releases.\n" +
|
||||
"Failed to download scrcpy-server-v4.1 from GitHub releases.\n" +
|
||||
" URL: $url\n" +
|
||||
" You may download it manually and place it at: ${file.absolutePath}\n" +
|
||||
" If you are behind a proxy, check your Gradle proxy settings\n" +
|
||||
@@ -223,14 +223,14 @@ val downloadScrcpyServer by tasks.registering {
|
||||
|
||||
val actualSha = computeSha256(file)
|
||||
require(actualSha == expectedSha) {
|
||||
"SHA256 mismatch for scrcpy-server-v4.0!\n" +
|
||||
"SHA256 mismatch for scrcpy-server-v4.1!\n" +
|
||||
" Expected: $expectedSha\n" +
|
||||
" Got: $actualSha\n" +
|
||||
" Delete ${file.absolutePath} to retry download."
|
||||
}
|
||||
logger.lifecycle("scrcpy-server-v4.0 downloaded and verified.")
|
||||
logger.lifecycle("scrcpy-server-v4.1 downloaded and verified.")
|
||||
} else {
|
||||
logger.lifecycle("scrcpy-server-v4.0 exists with correct SHA256, skip download.")
|
||||
logger.lifecycle("scrcpy-server-v4.1 exists with correct SHA256, skip download.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ object NativeCoreFacade {
|
||||
val info = scrcpy.currentSessionState.value ?: return@withLock
|
||||
if (info.width <= 0 || info.height <= 0) return@withLock
|
||||
|
||||
val mime = mimeForCodec(info.codec)
|
||||
val mime = info.codec?.mime ?: "video/avc"
|
||||
if (!DecoderCapabilities.isSizeSupported(mime, info.width, info.height)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
@@ -323,12 +323,5 @@ object NativeCoreFacade {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -6,19 +6,21 @@ import android.util.Log
|
||||
import android.view.Surface
|
||||
|
||||
/**
|
||||
* AnnexBDecoder
|
||||
* MediaCodecVideoDecoder
|
||||
*
|
||||
* Purpose:
|
||||
* - Wraps Android MediaCodec for Annex-B framed codecs (H.264/H.265/AV1).
|
||||
* - Wraps Android MediaCodec for all video codecs (H.264/H.265/AV1/VP8/VP9).
|
||||
* - Handles critical startup packets (config/keyframes) and provides callbacks
|
||||
* for output size changes and FPS updates.
|
||||
* - Codecs without Annex-B config packets (VP8/VP9) work natively: no CSD is
|
||||
* required for MediaCodec initialization.
|
||||
*
|
||||
* Threading / safety:
|
||||
* - Public methods are synchronized to allow calls from multiple threads
|
||||
* (packet producer vs. teardown). Internally, MediaCodec callbacks and
|
||||
* buffer queues are used on the calling thread.
|
||||
*/
|
||||
class AnnexBDecoder(
|
||||
class MediaCodecVideoDecoder(
|
||||
width: Int,
|
||||
height: Int,
|
||||
outputSurface: Surface,
|
||||
@@ -232,7 +234,7 @@ class AnnexBDecoder(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AnnexBDecoder"
|
||||
private const val TAG = "MediaCodecVideoDecoder"
|
||||
private const val MIME_AVC = "video/avc"
|
||||
private const val INPUT_TIMEOUT_US = 10_000L
|
||||
private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L
|
||||
@@ -13,7 +13,7 @@ import java.util.ArrayDeque
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
/**
|
||||
* Owns the video [AnnexBDecoder] lifecycle and all state surrounding it:
|
||||
* Owns the video [MediaCodecVideoDecoder] lifecycle and all state surrounding it:
|
||||
*
|
||||
* - Creating / rebuilding / releasing the decoder bound to the persistent renderer surface.
|
||||
* - Caching and replaying bootstrap packets (config + keyframe + following frames) so a
|
||||
@@ -27,7 +27,7 @@ import java.util.concurrent.CopyOnWriteArraySet
|
||||
* - All public methods are safe to call from any thread; mutations are guarded by
|
||||
* [controllerLock] for synchronous methods and by the facade's session mutex for
|
||||
* suspend methods.
|
||||
* - The decoder itself is single-threaded (AnnexBDecoder is @Synchronized).
|
||||
* - The decoder itself is single-threaded (MediaCodecVideoDecoder is @Synchronized).
|
||||
*
|
||||
* This class does **not** decide session restart policy; it reports errors and lets
|
||||
* [NativeCoreFacade] decide whether to restart the scrcpy session.
|
||||
@@ -38,7 +38,7 @@ internal class VideoDecoderController(
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val controllerLock = Any()
|
||||
private var decoder: AnnexBDecoder? = null
|
||||
private var decoder: MediaCodecVideoDecoder? = null
|
||||
|
||||
@Volatile
|
||||
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
|
||||
@@ -239,7 +239,7 @@ internal class VideoDecoderController(
|
||||
}
|
||||
|
||||
/**
|
||||
* Core method: construct a new [AnnexBDecoder] and replay bootstrap packets into it.
|
||||
* Core method: construct a new [MediaCodecVideoDecoder] and replay bootstrap packets into it.
|
||||
*
|
||||
* Caller must have already released the old decoder (if any) and, when needed, recreated
|
||||
* the persistent surface. Must be called while holding [controllerLock].
|
||||
@@ -255,12 +255,7 @@ internal class VideoDecoderController(
|
||||
}
|
||||
|
||||
val surface = renderer.getDecoderSurface()
|
||||
val mime = when (session.codec) {
|
||||
Codec.H264 -> "video/avc"
|
||||
Codec.H265 -> "video/hevc"
|
||||
Codec.AV1 -> "video/av01"
|
||||
else -> "video/avc"
|
||||
}
|
||||
val mime = session.codec?.mime ?: "video/avc"
|
||||
Log.i(
|
||||
TAG,
|
||||
"createOrReplaceDecoder(): codec=${session.codec?.string ?: "null"}, " +
|
||||
@@ -269,7 +264,7 @@ internal class VideoDecoderController(
|
||||
"persistent=true",
|
||||
)
|
||||
val newDecoder = try {
|
||||
AnnexBDecoder(
|
||||
MediaCodecVideoDecoder(
|
||||
width = session.width,
|
||||
height = session.height,
|
||||
outputSurface = surface,
|
||||
@@ -277,7 +272,7 @@ internal class VideoDecoderController(
|
||||
onOutputSizeChanged = { width, height ->
|
||||
val current = currentSessionInfo
|
||||
if (current == null || (current.width == width && current.height == height)) {
|
||||
return@AnnexBDecoder
|
||||
return@MediaCodecVideoDecoder
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
@@ -340,7 +335,7 @@ internal class VideoDecoderController(
|
||||
|
||||
// ---------- Bootstrap packet cache ----------
|
||||
|
||||
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
|
||||
private fun replayBootstrapPackets(decoder: MediaCodecVideoDecoder) {
|
||||
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
|
||||
if (snapshot.isEmpty()) {
|
||||
return
|
||||
|
||||
@@ -1779,6 +1779,16 @@ internal fun ScrcpyAllOptionsPage(
|
||||
)
|
||||
},
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(R.string.scrcpyopt_ignore_video_encoder_constraints),
|
||||
summary = "--ignore-video-encoder-constraints",
|
||||
checked = soBundle.ignoreVideoEncoderConstraints,
|
||||
onCheckedChange = {
|
||||
soBundle = soBundle.copy(
|
||||
ignoreVideoEncoderConstraints = it,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,12 @@ data class ClientOptions(
|
||||
|
||||
// --flex-display
|
||||
var flexDisplay: Boolean = false, // to server
|
||||
|
||||
// --ignore-video-encoder-constraints
|
||||
var ignoreVideoEncoderConstraints: Boolean = false, // to server
|
||||
|
||||
// --no-terminal-title
|
||||
// var updateTerminalTitle: Boolean = true,
|
||||
) {
|
||||
enum class KeyInjectMode(val string: String) {
|
||||
MIXED("mixed"),
|
||||
@@ -633,6 +639,7 @@ data class ClientOptions(
|
||||
vdSystemDecorations = vdSystemDecorations,
|
||||
keepActive = keepActive,
|
||||
flexDisplay = flexDisplay,
|
||||
ignoreVideoEncoderConstraints = ignoreVideoEncoderConstraints,
|
||||
list = list,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ class Scrcpy(
|
||||
companion object {
|
||||
private const val TAG = "Scrcpy"
|
||||
|
||||
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.0"
|
||||
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.0"
|
||||
const val DEFAULT_SERVER_VERSION = "4.0"
|
||||
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.1"
|
||||
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.1"
|
||||
const val DEFAULT_SERVER_VERSION = "4.1"
|
||||
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
|
||||
|
||||
// Regex patterns for parsing server output
|
||||
@@ -389,6 +389,10 @@ class Scrcpy(
|
||||
session.setClipboard(text, paste)
|
||||
}
|
||||
|
||||
suspend fun scanFile(path: String) = withContext(Dispatchers.IO) {
|
||||
session.scanFile(path)
|
||||
}
|
||||
|
||||
suspend fun injectTouch(
|
||||
action: Int,
|
||||
pointerId: Long,
|
||||
@@ -1317,6 +1321,10 @@ class Scrcpy(
|
||||
withControlWriter("setClipboard") { setClipboard(text, paste) }
|
||||
}
|
||||
|
||||
suspend fun scanFile(path: String) = mutex.withLock {
|
||||
withControlWriter("scanFile") { scanFile(path) }
|
||||
}
|
||||
|
||||
suspend fun injectTouch(
|
||||
action: Int,
|
||||
pointerId: Long,
|
||||
@@ -1775,6 +1783,16 @@ class Scrcpy(
|
||||
output.flush()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun scanFile(path: String) {
|
||||
val bytes = path.toByteArray(Charsets.UTF_8)
|
||||
require(bytes.size <= 256) { "scan file path is too long (max 256 bytes)" }
|
||||
output.writeByte(TYPE_SCAN_FILE)
|
||||
output.writeInt(bytes.size)
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
|
||||
output.writeInt(x)
|
||||
output.writeInt(y)
|
||||
@@ -1836,6 +1854,7 @@ class Scrcpy(
|
||||
private const val TYPE_CAMERA_ZOOM_IN = 19
|
||||
private const val TYPE_CAMERA_ZOOM_OUT = 20
|
||||
private const val TYPE_RESIZE_DISPLAY = 21
|
||||
private const val TYPE_SCAN_FILE = 22
|
||||
|
||||
private const val SEQUENCE_INVALID = 0L
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ data class ServerParams(
|
||||
val vdSystemDecorations: Boolean,
|
||||
val keepActive: Boolean,
|
||||
val flexDisplay: Boolean,
|
||||
val ignoreVideoEncoderConstraints: Boolean,
|
||||
|
||||
val list: ListOptions,
|
||||
) {
|
||||
@@ -268,6 +269,9 @@ data class ServerParams(
|
||||
if (flexDisplay) {
|
||||
cmd.add("flex_display=true")
|
||||
}
|
||||
if (ignoreVideoEncoderConstraints) {
|
||||
cmd.add("ignore_video_encoder_constraints=true")
|
||||
}
|
||||
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
|
||||
cmd.add("display_ime_policy=${displayImePolicy.string}")
|
||||
}
|
||||
|
||||
@@ -82,18 +82,21 @@ class Shared {
|
||||
enum class Codec(
|
||||
val string: String,
|
||||
val displayName: String,
|
||||
val mime: String,
|
||||
val type: Type,
|
||||
val id: Int,
|
||||
val isLossless: Boolean,
|
||||
) {
|
||||
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing
|
||||
H265("h265", "H.265", Type.VIDEO, 0x68323635, false),
|
||||
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false),
|
||||
H264("h264", "H.264", "video/avc", Type.VIDEO, 0x68323634, false), // default, ignore when passing
|
||||
H265("h265", "H.265", "video/hevc", Type.VIDEO, 0x68323635, false),
|
||||
AV1("av1", "AV1", "video/av01", Type.VIDEO, 0x00617631, false),
|
||||
VP8("vp8", "VP8", "video/x-vnd.on2.vp8", Type.VIDEO, 0x00767038, false),
|
||||
VP9("vp9", "VP9", "video/x-vnd.on2.vp9", Type.VIDEO, 0x00767039, false),
|
||||
|
||||
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
|
||||
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false),
|
||||
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true),
|
||||
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw
|
||||
OPUS("opus", "Opus", "audio/opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
|
||||
AAC("aac", "AAC", "audio/mp4a-latm", Type.AUDIO, 0x00616163, false),
|
||||
FLAC("flac", "FLAC", "audio/flac", Type.AUDIO, 0x666c6163, true),
|
||||
RAW("raw", "RAW", "audio/raw", Type.AUDIO, 0x00726177, true); // wav raw
|
||||
|
||||
enum class Type { VIDEO, AUDIO }
|
||||
|
||||
|
||||
@@ -283,6 +283,10 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
||||
booleanPreferencesKey("flex_display"),
|
||||
false,
|
||||
)
|
||||
val IGNORE_VIDEO_ENCODER_CONSTRAINTS = Pair(
|
||||
booleanPreferencesKey("ignore_video_encoder_constraints"),
|
||||
false,
|
||||
)
|
||||
|
||||
fun defaultBundle() = Bundle(
|
||||
crop = CROP.defaultValue,
|
||||
@@ -351,6 +355,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
||||
cameraTorch = CAMERA_TORCH.defaultValue,
|
||||
keepActive = KEEP_ACTIVE.defaultValue,
|
||||
flexDisplay = FLEX_DISPLAY.defaultValue,
|
||||
ignoreVideoEncoderConstraints = IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,6 +427,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
||||
val cameraTorch: Boolean,
|
||||
val keepActive: Boolean,
|
||||
val flexDisplay: Boolean,
|
||||
val ignoreVideoEncoderConstraints: Boolean,
|
||||
): Parcelable {
|
||||
}
|
||||
|
||||
@@ -491,6 +497,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
||||
bundleField(CAMERA_TORCH) { it.cameraTorch },
|
||||
bundleField(KEEP_ACTIVE) { it.keepActive },
|
||||
bundleField(FLEX_DISPLAY) { it.flexDisplay },
|
||||
bundleField(IGNORE_VIDEO_ENCODER_CONSTRAINTS) { it.ignoreVideoEncoderConstraints },
|
||||
)
|
||||
|
||||
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||
@@ -562,6 +569,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
||||
cameraTorch = preferences.read(CAMERA_TORCH),
|
||||
keepActive = preferences.read(KEEP_ACTIVE),
|
||||
flexDisplay = preferences.read(FLEX_DISPLAY),
|
||||
ignoreVideoEncoderConstraints = preferences.read(IGNORE_VIDEO_ENCODER_CONSTRAINTS),
|
||||
)
|
||||
|
||||
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||
@@ -645,6 +653,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
||||
cameraTorch = bundle.cameraTorch,
|
||||
keepActive = bundle.keepActive,
|
||||
flexDisplay = bundle.flexDisplay,
|
||||
ignoreVideoEncoderConstraints = bundle.ignoreVideoEncoderConstraints,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -716,6 +725,7 @@ internal fun encodeBundleToJson(bundle: ScrcpyOptions.Bundle): JSONObject =
|
||||
.put("cameraTorch", bundle.cameraTorch)
|
||||
.put("keepActive", bundle.keepActive)
|
||||
.put("flexDisplay", bundle.flexDisplay)
|
||||
.put("ignoreVideoEncoderConstraints", bundle.ignoreVideoEncoderConstraints)
|
||||
|
||||
internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
|
||||
val json = bundleJson ?: return ScrcpyOptions.defaultBundle()
|
||||
@@ -984,6 +994,10 @@ internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle
|
||||
"flexDisplay",
|
||||
ScrcpyOptions.FLEX_DISPLAY.defaultValue,
|
||||
),
|
||||
ignoreVideoEncoderConstraints = json.optBooleanOrDefault(
|
||||
"ignoreVideoEncoderConstraints",
|
||||
ScrcpyOptions.IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -536,6 +536,7 @@
|
||||
<string name="scrcpyopt_no_cleanup">禁用结束后清理</string>
|
||||
<string name="scrcpyopt_no_cleanup_desc">默认情况下,scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string>
|
||||
<string name="scrcpyopt_flex_display">自动调整尺寸以匹配界面</string>
|
||||
<string name="scrcpyopt_ignore_video_encoder_constraints">忽略视频编码器约束</string>
|
||||
<string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string>
|
||||
<string name="scrcpyopt_native">本机</string>
|
||||
<string name="scrcpyopt_log_level">日志等级</string>
|
||||
|
||||
@@ -537,6 +537,7 @@
|
||||
<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_flex_display">Automatically resize to match the screen</string>
|
||||
<string name="scrcpyopt_ignore_video_encoder_constraints">Ignore video encoder constraints</string>
|
||||
<string name="scrcpyopt_start_app">Open app after scrcpy starts</string>
|
||||
<string name="scrcpyopt_native">Native</string>
|
||||
<string name="scrcpyopt_log_level">Log level</string>
|
||||
|
||||
Reference in New Issue
Block a user