refactor: implement I18N

- fix IPv6 parsing
- Domain support
- ADB key pair manually importing
This commit is contained in:
Miuzarte
2026-04-30 19:12:36 +08:00
parent a3ba7fc918
commit f3a5cc53f6
53 changed files with 3184 additions and 1153 deletions

View File

@@ -2,11 +2,15 @@
## 0.3.0
该版本主要进行代码重构,引入 ViewModel 以优化代码结构
- 重构: 引入 ViewModel
- 重构: 部分自行实现的组件迁移至 miuix
- 引入子模块以在本地编译 miuix 的最新提交
- 新增: 所有参数页中的五个列表展开即触发懒加载
- 新增: 多语言支持
- 修复: IPv6 输入
- 新增: 域名解析
- 新增: 手动导入 ADB 密钥对
- 修复: snackbar 跨 activity
## 0.2.9

View File

@@ -56,8 +56,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26
targetSdk = 37
versionCode = 25
versionName = "0.2.9"
versionCode = 26
versionName = "0.3.0"
externalNativeBuild {
cmake {

View File

@@ -37,7 +37,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -50,6 +52,7 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings
@@ -106,6 +109,10 @@ class LockscreenPasswordActivity : FragmentActivity() {
val snackbarController = remember(scope, hostState) {
SnackbarController(scope = scope, hostState = hostState)
}
DisposableEffect(hostState) {
val unregister = AppRuntime.registerSnackbarHostState(hostState)
onDispose(unregister)
}
val themeController = remember(
asBundle.themeBaseIndex,
asBundle.monet,
@@ -148,7 +155,6 @@ private fun LockscreenPasswordScreen(
activity: LockscreenPasswordActivity,
hostState: SnackbarHostState,
) {
val snackbar = LocalSnackbarController.current
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scrollBehavior = MiuixScrollBehavior()
@@ -158,19 +164,19 @@ private fun LockscreenPasswordScreen(
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
if (asBundle != asBundleShared)
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
if (asBundle != asBundleSharedLatest)
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch { appSettings.saveBundle(asBundleLatest) }
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
@@ -197,15 +203,18 @@ private fun LockscreenPasswordScreen(
onDispose { activity.lifecycle.removeObserver(observer) }
}
val textCreate = stringResource(R.string.password_authenticate_create)
val textSubtitle = stringResource(R.string.password_authenticate_subtitle)
LaunchedEffect(pendingCreate, showMenu) {
if (!pendingCreate || showMenu) return@LaunchedEffect
if (asBundle.passwordRequireAuth) {
val ok = BiometricGate.authenticate(
activity = activity,
title = "验证以创建新密码",
title = textCreate,
subtitle = textSubtitle,
)
if (!ok) {
snackbar.show("认证失败")
AppRuntime.snackbar(R.string.password_auth_failed)
pendingCreate = false
return@LaunchedEffect
}
@@ -221,7 +230,7 @@ private fun LockscreenPasswordScreen(
Scaffold(
topBar = {
TopAppBar(
title = "锁屏密码自动填充",
title = stringResource(R.string.password_autofill_title),
modifier =
if (blurBackdrop != null) Modifier.layerBackdrop(blurBackdrop)
else Modifier,
@@ -230,7 +239,10 @@ private fun LockscreenPasswordScreen(
else colorScheme.surface,
navigationIcon = {
IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回")
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.cd_back)
)
}
},
actions = {
@@ -240,9 +252,10 @@ private fun LockscreenPasswordScreen(
) {
Icon(
imageVector = MiuixIcons.More,
contentDescription = "更多",
contentDescription = stringResource(R.string.cd_more),
)
}
val textCreateNew = stringResource(R.string.password_create_new)
OverlayListPopup(
show = showMenu,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
@@ -251,7 +264,7 @@ private fun LockscreenPasswordScreen(
) {
ListPopupColumn {
SpinnerItemImpl(
entry = SpinnerEntry(title = "创建新密码"),
entry = SpinnerEntry(title = textCreateNew),
entryCount = 1,
isSelected = false,
index = 0,
@@ -310,19 +323,19 @@ private fun LockscreenPasswordScreen(
if (showRiskDialog) {
OverlayDialog(
show = true,
title = "当前设备未设置锁屏保护",
summary = "继续使用将允许在无认证保护的情况下保存和填充锁屏密码",
title = stringResource(R.string.password_no_lock_screen),
summary = stringResource(R.string.password_no_lock_screen_warn),
defaultWindowInsetsPadding = false,
onDismissRequest = activity::finish,
) {
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = activity::finish,
modifier = Modifier.weight(1f),
)
TextButton(
text = "同意",
text = stringResource(R.string.password_agree),
onClick = { showRiskDialog = false },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
@@ -334,32 +347,31 @@ private fun LockscreenPasswordScreen(
if (showDisableDialog) {
OverlayDialog(
show = true,
title = "关闭验证后密码将失去保护",
summary =
"""
关闭后每次填充密码时将不再强制认证
同时会熔断当前经认证创建的密码
""".trimIndent(),
title = stringResource(R.string.password_auth_lost_warn),
summary = stringResource(R.string.password_auth_lost_detail),
defaultWindowInsetsPadding = false,
onDismissRequest = { showDisableDialog = false },
) {
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = { showDisableDialog = false },
modifier = Modifier.weight(1f),
)
val textAuthToDisable = stringResource(R.string.password_auth_to_disable)
val textAuthSubtitle = stringResource(R.string.password_auth_subtitle)
TextButton(
text = "继续关闭",
text = stringResource(R.string.password_continue_disable),
onClick = {
scope.launch {
if (entries.any { it.cipherText != null }) {
val ok = BiometricGate.authenticate(
activity = activity,
title = "验证以禁用“填充密码时需要验证”",
title = textAuthToDisable,
subtitle = textAuthSubtitle,
)
if (!ok) {
snackbar.show("认证失败")
AppRuntime.snackbar(R.string.password_auth_failed)
showDisableDialog = false
return@launch
}
@@ -390,19 +402,19 @@ private fun LockscreenPasswordScreen(
if (target != null) {
OverlayDialog(
show = true,
title = "删除密码",
summary = "将删除 ${target.name}",
title = stringResource(R.string.password_delete_confirm),
summary = stringResource(R.string.password_delete_msg, target.name),
defaultWindowInsetsPadding = false,
onDismissRequest = { pendingDeleteId = null },
) {
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = { pendingDeleteId = null },
modifier = Modifier.weight(1f),
)
TextButton(
text = "删除",
text = stringResource(R.string.button_delete),
onClick = {
PasswordRepository.delete(target.id)
pendingDeleteId = null
@@ -415,6 +427,7 @@ private fun LockscreenPasswordScreen(
}
}
val textDefaultName = stringResource(R.string.password_default_name, entries.size + 1)
PasswordEditorSheet(
show = dialogMode != null,
mode = dialogMode ?: PasswordDialogMode.Create,
@@ -426,23 +439,21 @@ private fun LockscreenPasswordScreen(
},
onConfirm = { nameInput, passwordInput ->
val sanitizedName = PasswordSanitizer.filterName(nameInput)
val resolvedName = sanitizedName.ifBlank { "密码 ${entries.size + 1}" }
val resolvedName = sanitizedName.ifBlank { textDefaultName }
when (dialogMode) {
PasswordDialogMode.Create -> {
val sanitizedPassword = PasswordSanitizer.filterPassword(passwordInput)
val passwordChars = sanitizedPassword.toCharArray()
if (passwordChars.isEmpty()) {
scope.launch { snackbar.show("密码不能为空") }
AppRuntime.snackbar(R.string.password_cannot_be_empty)
return@PasswordEditorSheet
}
PasswordRepository.create(
name = resolvedName,
cipherText = passwordChars,
createdWithAuth = if (asBundle.passwordRequireAuth) {
PasswordCreatedState.AuthenticatedCreated
} else {
PasswordCreatedState.UnauthenticatedCreated
},
createdWithAuth =
if (asBundle.passwordRequireAuth) PasswordCreatedState.AuthenticatedCreated
else PasswordCreatedState.UnauthenticatedCreated,
)
}
@@ -484,14 +495,11 @@ private fun LockscreenPasswordPage(
item {
Card {
SwitchPreference(
title = "填充密码时需要验证",
summary =
if (canAuthenticate)
"""
关闭后将允许直接填充锁屏密码
同时会熔断当前经认证创建的密码
""".trimIndent()
else "当前设备无认证认证能力",
title = stringResource(R.string.password_require_auth),
summary = stringResource(
if (canAuthenticate) R.string.password_require_auth_detail
else R.string.password_no_auth_capability
),
checked = requireAuth,
enabled = canAuthenticate || requireAuth,
onCheckedChange = onToggleRequireAuth,
@@ -504,13 +512,19 @@ private fun LockscreenPasswordPage(
if (entries.isEmpty()) item {
Card {
ArrowPreference(
title = "创建新密码",
summary = "或在右上角菜单中",
title = stringResource(R.string.password_create_new),
summary = stringResource(R.string.password_or_menu_hint),
onClick = onCreate,
)
}
}
else item {
val textInvalidated = stringResource(R.string.password_status_invalidated)
val textAuthenticated = stringResource(R.string.password_status_authenticated)
val textUnauthenticated = stringResource(R.string.password_status_unauthenticated)
val textBurned = stringResource(R.string.password_status_burned)
val textEdit = stringResource(R.string.cd_edit)
val textConfirm = stringResource(R.string.password_delete_confirm)
ReorderableList(
itemsProvider = {
entries.map { entry ->
@@ -521,22 +535,22 @@ private fun LockscreenPasswordPage(
else Icons.Rounded.Password,
title = entry.name,
subtitle =
if (entry.cipherText == null) "已失效"
if (entry.cipherText == null) textInvalidated
else when (entry.createdWithAuth) {
PasswordCreatedState.AuthenticatedCreated -> "创建时已验证"
PasswordCreatedState.UnauthenticatedCreated -> "创建时未经验证"
PasswordCreatedState.AuthenticatedCreatedModified -> "创建时已验证(熔断)"
PasswordCreatedState.AuthenticatedCreated -> textAuthenticated
PasswordCreatedState.UnauthenticatedCreated -> textUnauthenticated
PasswordCreatedState.AuthenticatedCreatedModified -> textBurned
},
onClick = { if (entry.cipherText != null) onRename(entry) },
endActions = listOf(
ReorderableList.EndAction.Icon(
icon = Icons.Rounded.Edit,
contentDescription = "编辑名称",
contentDescription = textEdit,
onClick = { if (entry.cipherText != null) onRename(entry) },
),
ReorderableList.EndAction.Icon(
icon = Icons.Rounded.DeleteOutline,
contentDescription = "删除密码",
contentDescription = textConfirm,
onClick = { onDelete(entry) },
),
),
@@ -549,15 +563,7 @@ private fun LockscreenPasswordPage(
item {
Text(
text =
"""
免责声明
0. 无法保证没有 bug
1. 本功能的防护边界仅包括加密存储、按需认证和使用后内存清理,不构成绝对安全保证
2. 在 root / posed / hook / 调试器 / 恶意输入法 等环境下,密码仍可能泄露
3. 本功能不会绕过系统锁屏认证,仅用于你已合法授权控制的设备
4. 关闭“填充密码时需要验证”会显著降低安全性,请谨慎选择
""".trimIndent(),
text = stringResource(R.string.password_disclaimer),
fontSize = textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier
@@ -580,19 +586,29 @@ private fun PasswordEditorSheet(
val focusManager = LocalFocusManager.current
var nameBuffer by rememberSaveable(mode, show, initialName) { mutableStateOf(initialName) }
var passwordBuffer by rememberSaveable(mode, show) { mutableStateOf("") }
OverlayBottomSheet(
show = show,
title = if (mode == PasswordDialogMode.Create) "创建新密码" else "重命名密码",
title = stringResource(
if (mode == PasswordDialogMode.Create) R.string.password_create_new
else R.string.password_rename
),
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
startAction = {
IconButton(onClick = onDismissRequest) {
Icon(MiuixIcons.Close, contentDescription = "关闭")
Icon(
imageVector = MiuixIcons.Close,
contentDescription = stringResource(R.string.cd_close),
)
}
},
endAction = {
IconButton(onClick = { onConfirm(nameBuffer, passwordBuffer) }) {
Icon(MiuixIcons.Ok, contentDescription = "保存")
Icon(
imageVector = MiuixIcons.Ok,
contentDescription = stringResource(R.string.cd_save),
)
}
},
) {
@@ -603,7 +619,7 @@ private fun PasswordEditorSheet(
TextField(
value = nameBuffer,
onValueChange = { nameBuffer = it },
label = "名称",
label = stringResource(R.string.label_name),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier
@@ -614,7 +630,7 @@ private fun PasswordEditorSheet(
TextField(
value = passwordBuffer,
onValueChange = { passwordBuffer = it },
label = "锁屏密码",
label = stringResource(R.string.password_lockscreen_label),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(

View File

@@ -21,6 +21,7 @@ class MainActivity : FragmentActivity() {
super.onCreate(savedInstanceState)
applyMainOrientationPolicy()
// no logEvent before context init
AppRuntime.init(applicationContext)
AppScreenOn.register(window)

View File

@@ -33,8 +33,8 @@ class StreamActivity : FragmentActivity() {
val pipStopAction: RemoteAction by lazy {
RemoteAction(
Icon.createWithResource(this, drawable.ic_menu_close_clear_cancel),
"停止投屏",
"停止投屏",
getString(R.string.password_stop_mirroring),
getString(R.string.password_stop_mirroring),
PictureInPictureActionReceiver.createPendingIntent(this),
)
}

View File

@@ -1,16 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.constants
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
object ThemeModes {
data class Option(
val label: String,
val mode: ColorSchemeMode,
)
val baseOptions = listOf(
Option("跟随系统", ColorSchemeMode.System),
Option("浅色", ColorSchemeMode.Light),
Option("深色", ColorSchemeMode.Dark),
)
}

View File

@@ -260,29 +260,38 @@ data class ConnectionTarget(
val host: String,
val port: Int = Defaults.ADB_PORT,
) : Parcelable {
override fun toString(): String = "$host:$port"
override fun toString(): String =
if (':' in host) "[$host]:$port"
else "$host:$port"
companion object {
fun unmarshalFrom(s: String): ConnectionTarget? {
val parts = s.split(":", limit = 2)
return when (parts.size) {
2 -> ConnectionTarget(
host = parts[0].trim(),
port = parts[1].trim().toIntOrNull() ?: Defaults.ADB_PORT,
)
1 -> ConnectionTarget(
host = parts[0].trim(),
port = Defaults.ADB_PORT,
)
0 -> ConnectionTarget(
host = s.trim(),
port = Defaults.ADB_PORT,
)
else -> null
val host: String
val port: Int
if (s.startsWith('[')) {
val closeBracket = s.indexOf(']')
if (closeBracket < 1) return null
host = s.substring(1, closeBracket)
port = if (s.length > closeBracket + 1 && s[closeBracket + 1] == ':')
s.substring(closeBracket + 2).toIntOrNull() ?: Defaults.ADB_PORT
else
Defaults.ADB_PORT
} else {
val input = s.trim()
val colonCount = input.count { it == ':' }
if (colonCount > 1) {
host = input
port = Defaults.ADB_PORT
} else {
val parts = input.split(":", limit = 2)
host = parts[0].trim()
port = if (parts.size >= 2)
parts[1].trim().toIntOrNull() ?: Defaults.ADB_PORT
else
Defaults.ADB_PORT
}
}
return ConnectionTarget(host = host, port = port)
}
}
}

View File

@@ -23,9 +23,12 @@ import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.PublicKey
import java.security.Signature
import java.security.interfaces.RSAPrivateCrtKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPrivateCrtKeySpec
import java.security.spec.RSAPublicKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.concurrent.ConcurrentHashMap
@@ -46,10 +49,18 @@ import kotlin.concurrent.thread
*/
internal object DirectAdbTransport {
private val keys: Pair<PrivateKey, ByteArray> by lazy { runBlocking { loadOrCreate() } }
private val keyLock = Any()
val privateKey: PrivateKey get() = keys.first
val publicKeyX509: ByteArray get() = keys.second
@Volatile
private var cachedKeys: Pair<PrivateKey, ByteArray>? = null
private fun keys(): Pair<PrivateKey, ByteArray> =
cachedKeys ?: synchronized(keyLock) {
cachedKeys ?: runBlocking { loadOrCreate() }.also { cachedKeys = it }
}
val privateKey: PrivateKey get() = keys().first
val publicKeyX509: ByteArray get() = keys().second
@Volatile
var keyName: String = AppSettings.ADB_KEY_NAME.defaultValue
@@ -100,6 +111,94 @@ internal object DirectAdbTransport {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) null
else AdbMdnsDiscoverer.discoverConnectService(timeoutMs, includeLanDevices)
data class ImportedKeyInfo(
val fingerprint: String,
)
data class KeyResetInfo(
val fingerprint: String,
val removedImportedKey: Boolean,
)
suspend fun importPrivateKey(content: String, fileName: String): ImportedKeyInfo {
val privateKey = parsePrivateKey(content)
validatePrivateKey(privateKey)
val publicKeyX509 = derivePublicX509(privateKey)
validatePublicKey(publicKeyX509)
Storage.adbClientData.saveBundle(
Storage.adbClientData.bundleState.value.copy(
importedPrivateKey = Base64.encodeToString(privateKey.encoded, Base64.NO_WRAP),
importedPrivateKeyFileName = fileName,
importedPublicKeyX509 = Base64.encodeToString(publicKeyX509, Base64.NO_WRAP),
importedPublicKeyFileName = "",
),
)
synchronized(keyLock) {
cachedKeys = Pair(privateKey, publicKeyX509)
}
return ImportedKeyInfo(fingerprint(publicKeyX509))
}
suspend fun importPublicKey(content: String, fileName: String): ImportedKeyInfo {
val privateKey = loadActivePrivateKey()
?: throw IllegalArgumentException("Import private key first")
val importedPublicKey = parsePublicKey(content)
val importedRsa = importedPublicKey as? RSAPublicKey
?: throw IllegalArgumentException("Public key is not RSA")
val privateRsa = privateKey as? RSAPrivateCrtKey
?: throw IllegalArgumentException("Stored private key cannot derive an RSA public key")
require(importedRsa.modulus == privateRsa.modulus) { "Public key does not match private key" }
require(importedRsa.publicExponent == privateRsa.publicExponent) {
"Public key does not match private key"
}
val publicKeyX509 = importedPublicKey.encoded
validatePublicKey(publicKeyX509)
Storage.adbClientData.saveBundle(
Storage.adbClientData.bundleState.value.copy(
importedPublicKeyX509 = Base64.encodeToString(publicKeyX509, Base64.NO_WRAP),
importedPublicKeyFileName = fileName,
),
)
synchronized(keyLock) {
cachedKeys = Pair(privateKey, publicKeyX509)
}
return ImportedKeyInfo(fingerprint(publicKeyX509))
}
fun reloadKeys() {
synchronized(keyLock) {
cachedKeys = runBlocking { loadOrCreate() }
}
}
fun resetKeys(): KeyResetInfo {
val data = Storage.adbClientData.bundleState.value
val hasImportedKey = data.importedPrivateKey.isNotBlank() || data.importedPublicKeyX509.isNotBlank()
val keys = if (hasImportedKey) {
synchronized(keyLock) {
runBlocking {
Storage.adbClientData.saveBundle(
data.copy(
importedPrivateKey = "",
importedPrivateKeyFileName = "",
importedPublicKeyX509 = "",
importedPublicKeyFileName = "",
)
)
loadOrCreate()
}.also { cachedKeys = it }
}
} else {
synchronized(keyLock) {
runBlocking { loadOrCreate(forceNew = true) }.also { cachedKeys = it }
}
}
return KeyResetInfo(fingerprint(keys.second), removedImportedKey = hasImportedKey)
}
/**
* Load persisted RSA keypair from DataStore, or generate a new one.
* Returns (privateKey, publicX509Bytes).
@@ -109,20 +208,33 @@ internal object DirectAdbTransport {
): Pair<PrivateKey, ByteArray> {
val adbClientData = Storage.adbClientData
val privB64 = adbClientData.rsaPrivateKey.get()
val importedPrivB64 = adbClientData.importedPrivateKey.get()
val generatedPrivB64 = adbClientData.rsaPrivateKey.get()
val privB64 = importedPrivB64.ifBlank { generatedPrivB64 }
if (privB64.isNotBlank() && !forceNew) {
try {
val kf = KeyFactory.getInstance("RSA")
val priv = kf.generatePrivate(
PKCS8EncodedKeySpec(
Base64.decode(privB64, Base64.DEFAULT)
)
)
val pubB64 = adbClientData.rsaPublicKeyX509.get()
val priv = generatePkcs8PrivateKey(Base64.decode(privB64, Base64.DEFAULT))
val pubB64 = adbClientData.importedPublicKeyX509.get()
.ifBlank { adbClientData.rsaPublicKeyX509.get() }
val pub =
if (pubB64.isNotBlank()) Base64.decode(pubB64, Base64.DEFAULT)
else derivePublicX509(priv)
else derivePublicX509(priv).also { derivedPublicKey ->
val encoded = Base64.encodeToString(derivedPublicKey, Base64.NO_WRAP)
val current = adbClientData.bundleState.value
adbClientData.saveBundle(
if (importedPrivB64.isNotBlank()) {
current.copy(
importedPublicKeyX509 = encoded,
importedPublicKeyFileName = "",
)
} else {
current.copy(
rsaPublicKeyX509 = encoded,
)
}
)
}
Log.i(
TAG,
@@ -148,6 +260,10 @@ internal object DirectAdbTransport {
AdbClientData.Bundle(
rsaPrivateKey = privateKeyB64,
rsaPublicKeyX509 = publicKeyB64,
importedPrivateKey = "",
importedPrivateKeyFileName = "",
importedPublicKeyX509 = "",
importedPublicKeyFileName = "",
)
)
@@ -158,6 +274,85 @@ internal object DirectAdbTransport {
return Pair(kp.private, kp.public.encoded)
}
private suspend fun loadActivePrivateKey(): PrivateKey? {
val data = Storage.adbClientData
val privateKeyB64 = data.importedPrivateKey.get().ifBlank {
data.rsaPrivateKey.get()
}
if (privateKeyB64.isBlank()) return null
return generatePkcs8PrivateKey(Base64.decode(privateKeyB64, Base64.DEFAULT))
}
private fun parsePrivateKey(content: String): PrivateKey {
val pem = readPem(content)
val der = when {
pem != null -> {
require(pem.label != "ENCRYPTED PRIVATE KEY") {
"Encrypted private keys are not supported"
}
pem.bytes
}
else -> Base64.decode(content.filterNot(Char::isWhitespace), Base64.DEFAULT)
}
val kf = KeyFactory.getInstance("RSA")
return when (pem?.label) {
"RSA PRIVATE KEY" -> kf.generatePrivate(parsePkcs1PrivateKey(der))
"PRIVATE KEY", null -> generatePkcs8PrivateKey(der)
else -> throw IllegalArgumentException("Unsupported private key format: ${pem.label}")
}
}
private fun generatePkcs8PrivateKey(der: ByteArray): PrivateKey {
val kf = KeyFactory.getInstance("RSA")
val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull()
return if (key is RSAPrivateCrtKey) key
else kf.generatePrivate(parsePkcs8PrivateKey(der))
}
private fun parsePublicKey(content: String): PublicKey {
val pem = readPem(content)
val normalized = content.trim()
val kf = KeyFactory.getInstance("RSA")
val der = when {
pem != null -> pem.bytes
normalized.contains(" ") -> Base64.decode(normalized.substringBefore(' '), Base64.DEFAULT)
else -> Base64.decode(normalized.filterNot(Char::isWhitespace), Base64.DEFAULT)
}
return when {
pem?.label == "RSA PUBLIC KEY" -> kf.generatePublic(parsePkcs1PublicKey(der))
pem?.label == "PUBLIC KEY" || pem == null && !looksLikeAdbPublicKey(der) ->
kf.generatePublic(X509EncodedKeySpec(der))
pem == null -> parseAdbPublicKey(der)
else -> throw IllegalArgumentException("Unsupported public key format: ${pem.label}")
}
}
private fun validatePrivateKey(privateKey: PrivateKey) {
val rsa = privateKey as? RSAPrivateCrtKey
?: throw IllegalArgumentException("Private key must be an RSA CRT key")
require(rsa.modulus.bitLength() == 2048) { "ADB RSA key must be 2048-bit" }
require(rsa.publicExponent.signum() > 0 && rsa.publicExponent.bitLength() <= 31) {
"Unsupported RSA public exponent"
}
Signature.getInstance("SHA1withRSA").apply {
initSign(privateKey)
update("scrcpy-adb-key-check".toByteArray())
sign()
}
}
private fun validatePublicKey(publicX509: ByteArray) {
val publicKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(publicX509))
val rsa = publicKey as? RSAPublicKey
?: throw IllegalArgumentException("Public key must be RSA")
require(rsa.modulus.bitLength() == 2048) { "ADB RSA key must be 2048-bit" }
require(rsa.publicExponent.signum() > 0 && rsa.publicExponent.bitLength() <= 31) {
"Unsupported RSA public exponent"
}
}
private fun derivePublicX509(privateKey: PrivateKey): ByteArray {
val rsa = privateKey as? RSAPrivateCrtKey
?: throw IllegalStateException("Expected RSAPrivateCrtKey but was ${privateKey.javaClass.name}")
@@ -166,39 +361,157 @@ internal object DirectAdbTransport {
return public.encoded
}
private data class PemBlock(
val label: String,
val bytes: ByteArray,
)
private fun readPem(content: String): PemBlock? {
val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null
val label = begin.groupValues[1]
val endMarker = "-----END $label-----"
val end = content.indexOf(endMarker, begin.range.last + 1)
require(end >= 0) { "Invalid PEM: missing END $label" }
val body = content.substring(begin.range.last + 1, end)
return PemBlock(label, Base64.decode(body.filterNot(Char::isWhitespace), Base64.DEFAULT))
}
private fun parsePkcs1PrivateKey(der: ByteArray): RSAPrivateCrtKeySpec {
val reader = DerReader(der).readSequence()
reader.readInteger() // version
val modulus = reader.readInteger()
val publicExponent = reader.readInteger()
val privateExponent = reader.readInteger()
val primeP = reader.readInteger()
val primeQ = reader.readInteger()
val primeExponentP = reader.readInteger()
val primeExponentQ = reader.readInteger()
val crtCoefficient = reader.readInteger()
return RSAPrivateCrtKeySpec(
modulus,
publicExponent,
privateExponent,
primeP,
primeQ,
primeExponentP,
primeExponentQ,
crtCoefficient,
)
}
private fun parsePkcs8PrivateKey(der: ByteArray): RSAPrivateCrtKeySpec {
val reader = DerReader(der).readSequence()
reader.readInteger() // version
reader.readElement() // privateKeyAlgorithm
return parsePkcs1PrivateKey(reader.readOctetString())
}
private fun parsePkcs1PublicKey(der: ByteArray): RSAPublicKeySpec {
val reader = DerReader(der).readSequence()
return RSAPublicKeySpec(reader.readInteger(), reader.readInteger())
}
private fun looksLikeAdbPublicKey(bytes: ByteArray): Boolean =
bytes.size == ADB_PUBLIC_KEY_BYTES &&
ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).int == ADB_PUBLIC_KEY_WORDS
private fun parseAdbPublicKey(bytes: ByteArray): PublicKey {
require(looksLikeAdbPublicKey(bytes)) { "Invalid ADB public key" }
val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val words = buf.int
buf.int // n0inv
val modulusLE = ByteArray(words * 4)
buf.get(modulusLE)
val rrLE = ByteArray(words * 4)
buf.get(rrLE)
val exponent = buf.int.toLong() and 0xffffffffL
val modulus = BigInteger(1, modulusLE.reversedArray())
return KeyFactory.getInstance("RSA").generatePublic(
RSAPublicKeySpec(modulus, BigInteger.valueOf(exponent))
)
}
private class DerReader(
private val bytes: ByteArray,
) {
private var offset = 0
fun readSequence(): DerReader {
val content = readTag(0x30)
return DerReader(content)
}
fun readInteger(): BigInteger =
BigInteger(readTag(0x02))
fun readOctetString(): ByteArray =
readTag(0x04)
fun readElement(): ByteArray {
require(offset < bytes.size) { "Invalid ASN.1 DER" }
val start = offset
offset++
val length = readLength()
require(offset + length <= bytes.size) { "Invalid ASN.1 DER length" }
offset += length
return bytes.copyOfRange(start, offset)
}
private fun readTag(expectedTag: Int): ByteArray {
require(offset < bytes.size) { "Invalid ASN.1 DER" }
val tag = bytes[offset++].toInt() and 0xff
require(tag == expectedTag) { "Unsupported ASN.1 DER key format" }
val length = readLength()
require(offset + length <= bytes.size) { "Invalid ASN.1 DER length" }
return bytes.copyOfRange(offset, offset + length).also {
offset += length
}
}
private fun readLength(): Int {
val first = bytes[offset++].toInt() and 0xff
if (first < 0x80) return first
val lengthBytes = first and 0x7f
require(lengthBytes in 1..4) { "Unsupported ASN.1 DER length" }
var length = 0
repeat(lengthBytes) {
length = (length shl 8) or (bytes[offset++].toInt() and 0xff)
}
return length
}
}
private fun fingerprint(publicX509: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(publicX509)
return digest.joinToString(":") { b -> "%02x".format(b) }
}
private fun encodeAdbPublicKey(modulus: BigInteger, exponent: Int): ByteArray {
val words = 64
val bytes = 256
val two32 = BigInteger.ONE.shiftLeft(32)
val mask32 = two32.subtract(BigInteger.ONE)
fun toBigEndianPadded(n: BigInteger): ByteArray {
val raw = n.toByteArray()
val arr = ByteArray(bytes)
val arr = ByteArray(ADB_PUBLIC_KEY_MODULUS_BYTES)
val src = if (raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw
src.copyInto(arr, destinationOffset = bytes - src.size)
src.copyInto(arr, destinationOffset = ADB_PUBLIC_KEY_MODULUS_BYTES - src.size)
return arr
}
val modBE = toBigEndianPadded(modulus)
val n0 = modulus.and(mask32)
val n0inv = n0.modInverse(two32).negate().mod(two32).toInt()
val r = BigInteger.ONE.shiftLeft(bytes * 8)
val r = BigInteger.ONE.shiftLeft(ADB_PUBLIC_KEY_MODULUS_BYTES * 8)
val rrBE = toBigEndianPadded(r.multiply(r).mod(modulus))
val buf = ByteBuffer.allocate(4 + 4 + bytes + bytes + 4).order(ByteOrder.LITTLE_ENDIAN)
buf.putInt(words)
val buf = ByteBuffer.allocate(ADB_PUBLIC_KEY_BYTES).order(ByteOrder.LITTLE_ENDIAN)
buf.putInt(ADB_PUBLIC_KEY_WORDS)
buf.putInt(n0inv)
for (i in words - 1 downTo 0) {
for (i in ADB_PUBLIC_KEY_WORDS - 1 downTo 0) {
val o = i * 4
buf.put(modBE[o + 3]); buf.put(modBE[o + 2]); buf.put(modBE[o + 1]); buf.put(modBE[o])
}
for (i in words - 1 downTo 0) {
for (i in ADB_PUBLIC_KEY_WORDS - 1 downTo 0) {
val o = i * 4
buf.put(rrBE[o + 3]); buf.put(rrBE[o + 2]); buf.put(rrBE[o + 1]); buf.put(rrBE[o])
}
@@ -207,6 +520,10 @@ internal object DirectAdbTransport {
}
private const val TAG = "DirectAdbTransport"
private const val ADB_PUBLIC_KEY_WORDS = 64
private const val ADB_PUBLIC_KEY_MODULUS_BYTES = 256
private const val ADB_PUBLIC_KEY_BYTES = 4 + 4 + ADB_PUBLIC_KEY_MODULUS_BYTES +
ADB_PUBLIC_KEY_MODULUS_BYTES + 4
}
/**

View File

@@ -1,5 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.content.Intent
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
@@ -39,6 +40,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
@@ -47,6 +49,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.pages.effect.BgEffectBackground
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
@@ -102,7 +105,7 @@ internal fun AboutScreen() {
Scaffold(
topBar = {
SmallTopAppBar(
title = "关于",
title = stringResource(R.string.about_title),
scrollBehavior = topAppBarScrollBehavior,
modifier =
if (blurBackdrop != null) Modifier.layerBackdrop(blurBackdrop)
@@ -116,7 +119,7 @@ internal fun AboutScreen() {
IconButton(onClick = navigator.pop) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "返回",
contentDescription = stringResource(R.string.cd_back),
)
}
},
@@ -160,12 +163,13 @@ private fun AboutContent(
var versionCodeProgress by remember { mutableFloatStateOf(0f) }
var initialLogoAreaY by remember { mutableFloatStateOf(0f) }
val (releaseStatusText, releasesUrl) = remember(updateState) {
val textAboutError = stringResource(R.string.about_error)
val (releaseStatusText, releasesUrl) = remember(textAboutError, updateState) {
when (val state = updateState) {
AppUpdateChecker.State.Idle -> null to AppUpdateChecker.RELEASES_URL
AppUpdateChecker.State.Checking -> "..." to AppUpdateChecker.RELEASES_URL
is AppUpdateChecker.State.Ready -> state.release.latestVersion to state.release.htmlUrl
is AppUpdateChecker.State.Error -> "错误" to AppUpdateChecker.RELEASES_URL
is AppUpdateChecker.State.Error -> textAboutError to AppUpdateChecker.RELEASES_URL
}
}
@@ -374,7 +378,7 @@ private fun AboutContent(
) {
AboutCard {
ArrowPreference(
title = "项目仓库",
title = stringResource(R.string.about_project_repo),
endActions = {
Text(
text = "GitHub",
@@ -389,7 +393,7 @@ private fun AboutContent(
},
)
ArrowPreference(
title = "版本发布",
title = stringResource(R.string.about_releases),
endActions = {
releaseStatusText?.let {
Text(

View File

@@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
@@ -43,6 +44,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
@@ -50,6 +52,7 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.ConnectionController
import io.github.miuzarte.scrcpyforandroid.services.ConnectionStateStore
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbAutoReconnectManager
@@ -135,9 +138,10 @@ internal fun DeviceTabScreen(
) {
Icon(
imageVector = Icons.Rounded.SwapHoriz,
contentDescription =
if (configPanelOnLeft) "配置显示在右侧"
else "配置显示在左侧"
contentDescription = stringResource(
if (configPanelOnLeft) R.string.device_cd_config_right
else R.string.device_cd_config_left
),
)
}
}
@@ -148,7 +152,7 @@ internal fun DeviceTabScreen(
) {
Icon(
imageVector = MiuixIcons.More,
contentDescription = "更多",
contentDescription = stringResource(R.string.cd_more),
)
}
OverlayListPopup(
@@ -159,7 +163,7 @@ internal fun DeviceTabScreen(
) {
ListPopupColumn {
DropdownImpl(
text = "快速设备排序",
text = stringResource(R.string.device_menu_quick_sort),
optionSize = 3,
isSelected = false,
index = 0,
@@ -169,7 +173,7 @@ internal fun DeviceTabScreen(
},
)
DropdownImpl(
text = "虚拟按钮排序",
text = stringResource(R.string.device_menu_virtual_button_sort),
optionSize = 3,
isSelected = false,
index = 1,
@@ -179,7 +183,7 @@ internal fun DeviceTabScreen(
},
)
DropdownImpl(
text = "清空日志",
text = stringResource(R.string.device_menu_clear_logs),
optionSize = 3,
isSelected = false,
index = 2,
@@ -194,12 +198,12 @@ internal fun DeviceTabScreen(
}
}
if (useCompactTopAppBar) SmallTopAppBar(
title = "设备",
title = stringResource(R.string.device_title),
color = topAppBarColor,
actions = topAppBarActions
)
else TopAppBar(
title = "设备",
title = stringResource(R.string.device_title),
color = topAppBarColor,
actions = topAppBarActions,
scrollBehavior = scrollBehavior
@@ -271,7 +275,6 @@ internal fun DeviceTabPage(
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
val navigator = LocalRootNavigator.current
val snackbar = LocalSnackbarController.current
val lifecycleOwner = LocalLifecycleOwner.current
val apps = remember(listingsRefreshVersion) { viewModel.scrcpyListings.apps }
@@ -321,10 +324,6 @@ internal fun DeviceTabPage(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(Unit) {
viewModel.snackbarEvents.collect { message -> snackbar.show(message) }
}
LaunchedEffect(Unit) {
viewModel.fullscreenRequests.collect { openFullscreenControl() }
}
@@ -393,14 +392,14 @@ internal fun DeviceTabPage(
editingDeviceId = editingDeviceId,
onClick = { device ->
if (editingDeviceId != device.id)
snackbar.show("长按可编辑")
AppRuntime.snackbar(R.string.device_hint_long_press_edit)
},
onLongClick = { device ->
val connected = adbConnected
&& currentTarget?.host == device.host
&& currentTarget?.port == device.port
if (connected) {
snackbar.show("无法修改已连接的设备")
AppRuntime.snackbar(R.string.device_cannot_modify_connected)
} else {
viewModel.setEditingDeviceId(
if (editingDeviceId != device.id) device.id
@@ -443,7 +442,7 @@ internal fun DeviceTabPage(
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
?: return@QuickConnectCard
viewModel.upsertShortcut(DeviceShortcut(host = target.host, port = target.port))
snackbar.show("已添加设备: ${target.host}:${target.port}")
AppRuntime.snackbar(R.string.device_added, target.host, target.port)
},
onConnect = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
@@ -455,7 +454,7 @@ internal fun DeviceTabPage(
@Composable
fun PairingSection() {
SectionSmallTitle("无线配对")
SectionSmallTitle(stringResource(R.string.device_section_wireless_pairing))
PairingCard(
busy = busy,
autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen,
@@ -480,7 +479,7 @@ internal fun DeviceTabPage(
allAppsEndActionText = when {
listingsRefreshBusy -> "..."
apps.isNotEmpty() -> apps.size.toString()
else -> ""
else -> stringResource(R.string.text_none)
},
onOpenAllApps = {
viewModel.showAllApps()
@@ -492,7 +491,7 @@ internal fun DeviceTabPage(
recentTasksEndActionText = when {
listingsRefreshBusy -> "..."
recentTasks.isNotEmpty() -> recentTasks.size.toString()
else -> ""
else -> stringResource(R.string.text_none)
},
onOpenRecentTasks = {
viewModel.showRecentTasks()
@@ -513,7 +512,10 @@ internal fun DeviceTabPage(
}
@Composable
fun PreviewSection(modifier: Modifier = Modifier, directControlEnabled: Boolean = false) {
fun PreviewSection(
modifier: Modifier = Modifier,
directControlEnabled: Boolean = false,
) {
PreviewCard(
modifier = modifier,
sessionInfo = sessionInfo,
@@ -589,7 +591,7 @@ internal fun DeviceTabPage(
allAppsEndActionText = when {
listingsRefreshBusy -> "..."
apps.isNotEmpty() -> apps.size.toString()
else -> ""
else -> stringResource(R.string.text_none)
},
onOpenAllApps = {
viewModel.showAllApps()
@@ -601,7 +603,7 @@ internal fun DeviceTabPage(
recentTasksEndActionText = when {
listingsRefreshBusy -> "..."
recentTasks.isNotEmpty() -> recentTasks.size.toString()
else -> ""
else -> stringResource(R.string.text_none)
},
onOpenRecentTasks = {
viewModel.showRecentTasks()
@@ -641,10 +643,13 @@ internal fun DeviceTabPage(
@Composable
fun LogsSection() {
if (!asBundle.hideDeviceLogs && EventLogger.hasLogs()) {
SectionSmallTitle("日志")
val context = LocalContext.current
SectionSmallTitle(stringResource(R.string.device_section_logs))
Card {
TextField(
value = EventLogger.eventLog.joinToString(separator = "\n"),
value = EventLogger.eventLog.joinToString(separator = "\n") {
it.render(context)
},
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
@@ -800,9 +805,9 @@ internal fun DeviceTabPage(
AppListBottomSheet(
show = showRecentTasksSheet,
title = "最近任务",
loadingText = "最近任务加载中",
emptyText = "没有可用的最近任务",
title = stringResource(R.string.bottomsheet_recent_tasks),
loadingText = stringResource(R.string.bottomsheet_loading_tasks),
emptyText = stringResource(R.string.bottomsheet_no_tasks),
entries = recentTasks.map { task ->
val app = viewModel.findCachedApp(task.packageName)
AppListEntry(
@@ -828,9 +833,9 @@ internal fun DeviceTabPage(
AppListBottomSheet(
show = showAllAppsSheet,
title = "所有应用",
loadingText = "应用列表加载中",
emptyText = "没有可用的应用列表",
title = stringResource(R.string.bottomsheet_all_apps),
loadingText = stringResource(R.string.bottomsheet_loading_apps),
emptyText = stringResource(R.string.bottomsheet_no_apps),
entries = apps.map { app ->
AppListEntry(
key = app.packageName,

View File

@@ -1,19 +1,23 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
import io.github.miuzarte.scrcpyforandroid.services.DisconnectCause
import io.github.miuzarte.scrcpyforandroid.services.EventLogMessage
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.services.render
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Settings
@@ -189,19 +193,20 @@ internal class DeviceTabViewModel(
false,
)
val connectedScrcpyProfileName: StateFlow<String> = combine(
connectedScrcpyProfileId,
scrcpyProfilesState,
) { profileId, profiles ->
profiles.profiles
.firstOrNull { it.id == profileId }
?.name
?: ScrcpyOptions.GLOBAL_PROFILE_NAME
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
ScrcpyOptions.GLOBAL_PROFILE_NAME,
)
val connectedScrcpyProfileName: StateFlow<String> =
combine(
connectedScrcpyProfileId,
scrcpyProfilesState,
) { profileId, profiles ->
profiles.profiles
.firstOrNull { it.id == profileId }
?.name
?: AppRuntime.stringResource(ScrcpyOptions.GLOBAL_PROFILE_NAME_RES_ID)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
AppRuntime.stringResource(ScrcpyOptions.GLOBAL_PROFILE_NAME_RES_ID),
)
val canShowPreviewControls: StateFlow<Boolean> = combine(
adbConnected,
@@ -228,9 +233,6 @@ internal class DeviceTabViewModel(
),
)
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
val snackbarEvents: Flow<String> = _snackbarEvents.receiveAsFlow()
private val _fullscreenRequests = Channel<Unit>(Channel.BUFFERED)
val fullscreenRequests: Flow<Unit> = _fullscreenRequests.receiveAsFlow()
@@ -391,7 +393,12 @@ internal class DeviceTabViewModel(
else -> {
val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") {
runBusy(
EventLogMessage.Resource(
R.string.vm_send_action,
listOf(EventLogMessage.Resource(action.titleResId)),
)
) {
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
}
@@ -399,35 +406,31 @@ internal class DeviceTabViewModel(
}
}
private fun snackbar(message: String) {
_snackbarEvents.trySend(message)
fun startScrcpy() = runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) {
startScrcpySession()
}
fun startScrcpy() = runBusy("启动 scrcpy") { startScrcpySession() }
fun stopScrcpy() = runBusy(EventLogMessage.Resource(R.string.vm_stop_scrcpy)) {
stopScrcpySession()
}
fun stopScrcpy() = runBusy("停止 scrcpy") { stopScrcpySession() }
fun startScrcpy(packageName: String) = runBusy("启动 scrcpy") {
fun startScrcpy(packageName: String) = runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) {
startScrcpySession(startAppOverride = packageName)
}
fun launchAppWithFallback(packageName: String) = runBusy("启动应用") {
fun launchAppWithFallback(packageName: String) = runBusy(EventLogMessage.Resource(R.string.vm_launch_app)) {
runCatching { scrcpy.startApp(packageName) }
.onSuccess { logEvent("已在当前显示启动应用: $packageName") }
.onSuccess { logEvent(R.string.vm_app_started_on_display, packageName) }
.onFailure { error ->
snackbar("通过 scrcpy 控制通道启动应用失败,回退 ADB")
logEvent(
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN, error,
)
AppRuntime.snackbar(R.string.vm_start_app_fallback_adb)
logEvent(R.string.vm_start_app_fallback_adb, level = Log.WARN, error = error)
adbCoordinator.startApp(packageName = packageName)
logEvent("已通过 ADB 启动应用: $packageName")
logEvent(R.string.vm_app_started_via_adb, packageName)
}
}
private fun runBusy(
label: String,
label: EventLogMessage,
onFinished: (() -> Unit)? = null,
block: suspend () -> Unit
) {
@@ -437,14 +440,14 @@ internal class DeviceTabViewModel(
try {
block()
} catch (_: TimeoutCancellationException) {
logEvent("$label 超时", Log.WARN)
logEvent(R.string.vm_label_timeout, label, level = Log.WARN)
} catch (e: IllegalArgumentException) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 参数错误: $detail", Log.WARN, e)
snackbar("$label 参数错误: $detail")
logEvent(R.string.vm_label_param_error, label, detail, level = Log.WARN, error = e)
AppRuntime.snackbar(R.string.vm_label_param_error, label.render(AppRuntime.context), detail)
} catch (e: Exception) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 失败: $detail", Log.ERROR, e)
logEvent(R.string.vm_label_failed, label, detail, level = Log.ERROR, error = e)
} finally {
_busy.value = false
onFinished?.invoke()
@@ -453,7 +456,7 @@ internal class DeviceTabViewModel(
}
private fun runAdbConnect(
label: String,
label: EventLogMessage,
onStarted: (() -> Unit)? = null,
onFinished: (() -> Unit)? = null,
block: suspend () -> Unit,
@@ -465,14 +468,14 @@ internal class DeviceTabViewModel(
try {
block()
} catch (_: TimeoutCancellationException) {
logEvent("$label 超时", Log.WARN)
logEvent(R.string.vm_label_timeout, label, level = Log.WARN)
} catch (e: IllegalArgumentException) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 参数错误: $detail", Log.WARN, e)
snackbar("$label 参数错误: $detail")
logEvent(R.string.vm_label_param_error, label, detail, level = Log.WARN, error = e)
AppRuntime.snackbar(R.string.vm_label_param_error, label.render(AppRuntime.context), detail)
} catch (e: Exception) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 失败: $detail", Log.ERROR, e)
logEvent(R.string.vm_label_failed, label, detail, level = Log.ERROR, error = e)
} finally {
_adbConnecting.value = false
onFinished?.invoke()
@@ -483,9 +486,8 @@ internal class DeviceTabViewModel(
suspend fun disconnectAdbConnection(
clearQuickOnlineForTarget: ConnectionTarget? = currentTarget.value,
logMessage: String? = null,
showSnackMessage: String? = null,
cause: DisconnectCause = DisconnectCause.User,
statusLine: String = "未连接",
statusLine: String = "Disconnected",
) {
val result = connectionController.disconnectAdbConnection(
clearQuickOnlineForTarget,
@@ -497,7 +499,6 @@ internal class DeviceTabViewModel(
_savedShortcuts.update { it.update(host = target.host, port = target.port) }
}
logMessage?.let { logEvent(it) }
if (!showSnackMessage.isNullOrBlank()) snackbar(showSnackMessage)
}
suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) {
@@ -517,7 +518,8 @@ internal class DeviceTabViewModel(
}
suspend fun handleAdbConnected(
host: String, port: Int,
host: String,
port: Int,
autoStartScrcpy: Boolean = false,
autoEnterFullScreen: Boolean = false,
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
@@ -538,27 +540,28 @@ internal class DeviceTabViewModel(
}
logEvent(
"ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, " +
"ADB connected: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, " +
"manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, " +
"device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}"
)
snackbar("ADB 已连接")
AppRuntime.snackbar(R.string.vm_adb_connected)
if (_asBundle.value.adbAutoLoadAppListOnConnect) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { scrcpy.listings.getApps(forceRefresh = true) }
.onFailure { error ->
logEvent(
"获取应用列表失败: ${error.message}",
Log.WARN,
error
R.string.vm_failed_app_list_msg,
error.message ?: error.javaClass.simpleName,
level = Log.WARN,
error = error,
)
}
}
}
if (autoStartScrcpy && sessionInfo.value == null) {
runBusy("启动 scrcpy") {
runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) {
startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen)
}
}
@@ -579,11 +582,15 @@ internal class DeviceTabViewModel(
if (resolvedOptions.startApp.isNotBlank() && resolvedOptions.control) {
runCatching { scrcpy.startApp(resolvedOptions.startApp) }
.onSuccess { logEvent("已请求 scrcpy 启动应用: ${resolvedOptions.startApp}") }
.onSuccess {
logEvent(R.string.vm_scrcpy_requested_app, resolvedOptions.startApp)
}
.onFailure { error ->
logEvent(
"通过 scrcpy 控制通道启动应用失败: ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN, error
R.string.vm_scrcpy_start_app_failed,
error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName,
level = Log.WARN,
error = error,
)
}
}
@@ -596,26 +603,28 @@ internal class DeviceTabViewModel(
if (resolvedOptions.disableScreensaver) AppScreenOn.acquire()
connectionController.markScrcpyStarted()
@SuppressLint("DefaultLocale")
val videoDetail = if (!resolvedOptions.video) "off"
else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} @${
String.format(
"%.1f",
activeBundle.videoBitRate / 1_000_000f
)
}Mbps"
val videoDetail =
if (!resolvedOptions.video) "off"
else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " +
"@%.1fMbps".format(activeBundle.videoBitRate / 1_000_000f)
val audioDetail = if (!activeBundle.audio) "off"
else if (activeBundle.audioBitRate <= 0) "${resolvedOptions.audioCodec} default source=${resolvedOptions.audioSource}"
else "${resolvedOptions.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${resolvedOptions.audioSource}${if (!resolvedOptions.audioPlayback) "(no-playback)" else ""}"
val audioDetail =
if (!activeBundle.audio) "off"
else if (activeBundle.audioBitRate <= 0) "${resolvedOptions.audioCodec} default source=${resolvedOptions.audioSource}"
else "${resolvedOptions.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps" +
" source=${resolvedOptions.audioSource}" +
if (!resolvedOptions.audioPlayback) "(no-playback)" else ""
logEvent(
"scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, " +
"control=${resolvedOptions.control}, turnScreenOff=${resolvedOptions.turnScreenOff}, " +
"maxSize=${resolvedOptions.maxSize}, maxFps=${resolvedOptions.maxFps}"
)
snackbar("scrcpy 已启动" + if (resolvedOptions.recordFilename.isNotBlank()) "并开始录制" else "")
AppRuntime.snackbar(
if (resolvedOptions.recordFilename.isNotBlank()) R.string.vm_scrcpy_started_recording
else R.string.vm_scrcpy_started
)
}
suspend fun stopScrcpySession() {
@@ -628,12 +637,12 @@ internal class DeviceTabViewModel(
if (target.host.isNotBlank())
_savedShortcuts.update { it.update(host = target.host, port = target.port) }
}
logEvent("scrcpy 已停止ADB 已断开")
snackbar("scrcpy 已停止ADB 已断开")
logEvent(R.string.vm_scrcpy_stopped_adb_disconnected_log)
AppRuntime.snackbar(R.string.vm_scrcpy_stopped_adb_disconnected)
} else {
connectionController.stopScrcpySession(killAdbOnClose = false)
logEvent("scrcpy 已停止")
snackbar("scrcpy 已停止")
logEvent(R.string.vm_scrcpy_stopped)
AppRuntime.snackbar(R.string.vm_scrcpy_stopped)
}
}
@@ -646,25 +655,36 @@ internal class DeviceTabViewModel(
suspend fun refreshApps() {
runCatching { scrcpy.listings.getApps(forceRefresh = true) }
.onFailure { error ->
logEvent("获取应用列表失败: ${error.message}", Log.WARN, error)
withContext(Dispatchers.Main) { snackbar("获取应用列表失败") }
val detail = error.message ?: error.javaClass.simpleName
logEvent(R.string.vm_failed_app_list_msg, detail, level = Log.WARN, error = error)
withContext(Dispatchers.Main) {
AppRuntime.snackbar(
R.string.vm_failed_app_list_msg,
detail,
)
}
}
}
suspend fun refreshRecentTasks() {
runCatching { scrcpy.listings.getRecentTasks(forceRefresh = true) }
.onFailure { error ->
logEvent("获取最近任务失败: ${error.message}", Log.WARN, error)
withContext(Dispatchers.Main) { snackbar("获取最近任务失败") }
val detail = error.message ?: error.javaClass.simpleName
logEvent(R.string.vm_failed_recent_tasks_msg, detail, level = Log.WARN, error = error)
withContext(Dispatchers.Main) {
AppRuntime.snackbar(
R.string.vm_failed_recent_tasks_msg,
detail,
)
}
}
}
suspend fun pasteLocalClipboard(context: Context) {
val text =
io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(context)
?.takeIf { it.isNotBlank() }
val text = LocalInputService.getClipboardText(context)
?.takeIf { it.isNotBlank() }
if (text == null) {
snackbar("本机剪贴板为空或不是文本")
AppRuntime.snackbar(R.string.vm_clipboard_paste_failed)
return
}
val useLegacyPaste = connectedScrcpyBundle.value.legacyPaste
@@ -673,10 +693,16 @@ internal class DeviceTabViewModel(
if (useLegacyPaste) scrcpy.injectText(text)
else scrcpy.setClipboard(text, paste = true)
}
logEvent(if (useLegacyPaste) "已使用 legacy paste 注入本机剪贴板文本" else "已同步本机剪贴板到设备并触发粘贴")
logEvent(
if (useLegacyPaste) R.string.vm_legacy_paste_injected
else R.string.vm_clipboard_synced_paste
)
}.onFailure { error ->
logEvent("本机剪贴板粘贴失败: ${error.message}", Log.WARN, error)
snackbar(if (useLegacyPaste) "legacy 粘贴失败" else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste")
logEvent(R.string.vm_clipboard_paste_failed, level = Log.WARN, error = error)
AppRuntime.snackbar(
if (useLegacyPaste) R.string.fullscreen_legacy_paste_failed
else R.string.fullscreen_clipboard_sync_failed
)
}
}
@@ -685,63 +711,75 @@ internal class DeviceTabViewModel(
scrcpy = scrcpy, text = text,
keyInjectMode = scrcpyOptions.toClientOptions(connectedScrcpyBundle.value).keyInjectMode,
) { error, useClipboardPaste ->
logEvent("输入法文本提交失败: ${error.message}", Log.WARN, error)
snackbar(if (useClipboardPaste) "非 ASCII 文本粘贴失败" else "文本输入失败")
logEvent(
R.string.vm_ime_text_failed,
error.message ?: error.javaClass.simpleName,
level = Log.WARN,
error = error,
)
AppRuntime.snackbar(
if (useClipboardPaste) R.string.fullscreen_paste_non_ascii
else R.string.fullscreen_text_input_failed
)
}
}
fun onDeviceAction(device: DeviceShortcut) {
val connected = adbConnected.value &&
currentTarget.value?.host == device.host && currentTarget.value?.port == device.port
val connected = adbConnected.value
&& currentTarget.value?.host == device.host
&& currentTarget.value?.port == device.port
if (!connected) {
runAdbConnect(
"连接 ADB",
{ _activeDeviceActionId.value = device.id },
{ _activeDeviceActionId.value = null }) {
label = EventLogMessage.Resource(R.string.vm_connect_adb),
onStarted = { _activeDeviceActionId.value = device.id },
onFinished = { _activeDeviceActionId.value = null },
) {
disconnectCurrentTargetBeforeConnecting(device.host, device.port)
try {
connectWithTimeout(device.host, device.port)
handleAdbConnected(
device.host,
device.port,
device.startScrcpyOnConnect,
device.startScrcpyOnConnect && device.openFullscreenOnStart,
device.scrcpyProfileId
host = device.host,
port = device.port,
autoStartScrcpy = device.startScrcpyOnConnect,
autoEnterFullScreen = device.startScrcpyOnConnect && device.openFullscreenOnStart,
scrcpyProfileId = device.scrcpyProfileId
)
connectionController.updateQuickConnected(false)
} catch (error: Exception) {
connectionController.markConnectionFailed(error)
logEvent("ADB 连接失败: ${error.message}", Log.ERROR)
snackbar("ADB 连接失败")
logEvent(R.string.vm_adb_connection_failed, level = Log.ERROR, error = error)
AppRuntime.snackbar(R.string.vm_adb_connection_failed)
}
}
return
}
runAdbConnect(
"断开 ADB",
{ _activeDeviceActionId.value = device.id },
{ _activeDeviceActionId.value = null }) {
label = EventLogMessage.Resource(R.string.vm_disconnect_adb),
onStarted = { _activeDeviceActionId.value = device.id },
onFinished = { _activeDeviceActionId.value = null },
) {
sessionReconnectBlacklistHosts += device.host
disconnectAdbConnection(
ConnectionTarget(device.host, device.port),
"ADB 已断开: ${device.name}",
"ADB 已断开"
logMessage = "ADB disconnected: ${device.name}",
)
}
}
fun onQuickConnect(target: ConnectionTarget) {
runAdbConnect(
"连接 ADB",
{ _activeDeviceActionId.value = target.toString() },
{ _activeDeviceActionId.value = null }) {
label = EventLogMessage.Resource(R.string.vm_connect_adb),
onStarted = { _activeDeviceActionId.value = target.toString() },
onFinished = { _activeDeviceActionId.value = null },
) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try {
connectWithTimeout(target.host, target.port)
handleAdbConnected(
target.host, target.port,
host = target.host,
port = target.port,
autoStartScrcpy = false,
autoEnterFullScreen = false,
scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID,
@@ -749,29 +787,35 @@ internal class DeviceTabViewModel(
connectionController.updateQuickConnected(true)
} catch (error: Exception) {
connectionController.markConnectionFailed(error)
logEvent("ADB 连接失败: ${error.message}", Log.ERROR)
snackbar("ADB 连接失败")
logEvent(R.string.vm_adb_connection_failed, level = Log.ERROR, error = error)
AppRuntime.snackbar(R.string.vm_adb_connection_failed)
}
}
}
fun onDisconnectCurrent(target: ConnectionTarget?) {
runAdbConnect("断开 ADB") {
runAdbConnect(EventLogMessage.Resource(R.string.vm_disconnect_adb)) {
target?.let {
sessionReconnectBlacklistHosts += it.host
disconnectAdbConnection(it, "ADB 已断开", "ADB 已断开")
disconnectAdbConnection(it, logMessage = "ADB disconnected")
}
}
}
fun onPair(host: String, port: String, code: String) {
runBusy("执行配对") {
runBusy(EventLogMessage.Resource(R.string.vm_execute_pairing)) {
val h = host.trim()
val p = port.trim().toIntOrNull() ?: return@runBusy
val c = code.trim()
val ok = adbCoordinator.pair(h, p, c)
logEvent(if (ok) "配对成功" else "配对失败", if (ok) Log.INFO else Log.ERROR)
snackbar(if (ok) "配对成功" else "配对失败")
logEvent(
if (ok) R.string.vm_pairing_succeeded else R.string.vm_pairing_failed,
level = if (ok) Log.INFO else Log.ERROR,
)
AppRuntime.snackbar(
if (ok) R.string.vm_pairing_succeeded
else R.string.vm_pairing_failed
)
}
}
@@ -779,6 +823,7 @@ internal class DeviceTabViewModel(
return adbCoordinator.discoverPairingService(includeLanDevices = _asBundle.value.adbMdnsLanDiscovery)
}
// TODO: unused
fun blacklistHost(host: String) {
sessionReconnectBlacklistHosts += host
}
@@ -794,18 +839,18 @@ internal class DeviceTabViewModel(
connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS,
keepAliveTimeoutMs = ADB_KEEPALIVE_TIMEOUT_MS,
onReconnectSuccess = { host, port ->
logEvent("ADB 自动重连成功: $host:$port")
snackbar("ADB 自动重连成功")
logEvent(R.string.vm_quick_probe_success, host, port)
AppRuntime.snackbar(R.string.vm_auto_reconnect_succeeded)
},
onReconnectFailure = { error ->
viewModelScope.launch {
disconnectAdbConnection(
cause = DisconnectCause.KeepAliveFailed,
statusLine = "ADB 连接断开"
statusLine = "ADB disconnected"
)
}
logEvent("ADB 自动重连失败: $error", Log.ERROR)
snackbar("ADB 自动重连失败")
logEvent(R.string.vm_auto_reconnect_failed, level = Log.ERROR, error = error)
AppRuntime.snackbar(R.string.vm_auto_reconnect_failed)
},
)
} finally {
@@ -833,7 +878,7 @@ internal class DeviceTabViewModel(
discoverConnectService = {
adbCoordinator.discoverConnectService(
ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
_asBundle.value.adbMdnsLanDiscovery
_asBundle.value.adbMdnsLanDiscovery,
)
},
onMdnsPortChanged = { host, oldPort, newPort ->
@@ -841,18 +886,18 @@ internal class DeviceTabViewModel(
it.update(
host = host,
port = oldPort,
newPort = newPort
newPort = newPort,
)
}
logEvent("mDNS 发现新端口,已更新快速设备: $host:$oldPort -> $host:$newPort")
logEvent(R.string.vm_mdns_updated, host, oldPort, newPort)
},
onKnownDeviceReconnected = { target ->
_savedShortcuts.update { it.update(host = target.host, port = target.port) }
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
logEvent(R.string.vm_quick_probe_success, target.host, target.port)
},
onDiscoveredDeviceReconnected = { host, port, _ ->
_savedShortcuts.update { it.update(host = host, port = port) }
logEvent("ADB 自动重连成功: $host:$port")
logEvent(R.string.vm_quick_probe_success, host, port)
},
retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS,
)
@@ -873,7 +918,7 @@ internal class DeviceTabViewModel(
?.scrcpyProfileId ?: ScrcpyOptions.GLOBAL_PROFILE_ID
if (boundProfileId != connectionState.value.adbSession.connectedScrcpyProfileId) {
connectionController.syncConnectedScrcpyProfileId(boundProfileId)
logEvent("当前连接设备已切换为配置: $boundProfileId")
logEvent(R.string.vm_device_switched_profile, boundProfileId)
}
}
}
@@ -893,9 +938,10 @@ internal class DeviceTabViewModel(
runCatching { scrcpy.listings.getRecentTasks(forceRefresh = true) }
.onFailure { error ->
logEvent(
"获取最近任务失败: ${error.message}",
Log.WARN,
error
R.string.vm_failed_recent_tasks_msg,
error.message ?: error.javaClass.simpleName,
level = Log.WARN,
error = error,
)
}
}
@@ -910,9 +956,15 @@ internal class DeviceTabViewModel(
}
suspend fun injectTouch(
action: Int, pointerId: Long, x: Int, y: Int,
screenWidth: Int, screenHeight: Int, pressure: Float,
actionButton: Int = 0, buttons: Int = 0,
action: Int,
pointerId: Long,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
actionButton: Int = 0,
buttons: Int = 0,
) {
scrcpy.injectTouch(
action,

View File

@@ -40,14 +40,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.DirectoryDownloadSnapshot
import io.github.miuzarte.scrcpyforandroid.services.FileManagerService
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
@@ -91,7 +94,6 @@ fun FileManagerScreen(
) {
val viewModel: FileManagerViewModel = viewModel()
val context = LocalContext.current
val snackbar = LocalSnackbarController.current
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
val blurActive = blurBackdrop != null
val pullToRefreshState = rememberPullToRefreshState()
@@ -136,9 +138,6 @@ fun FileManagerScreen(
if (uri != null) viewModel.downloadToTree(context, uri)
}
LaunchedEffect(Unit) {
viewModel.snackbarEvents.collect { snackbar.show(it) }
}
LaunchedEffect(currentPath) {
viewModel.reloadCurrentDirectory(force = false)
@@ -176,7 +175,7 @@ fun FileManagerScreen(
topBar = {
BlurredBar(backdrop = blurBackdrop) {
SmallTopAppBar(
title = "文件",
title = stringResource(R.string.main_tab_files),
color =
if (blurActive) Color.Transparent
else colorScheme.surface,
@@ -187,7 +186,7 @@ fun FileManagerScreen(
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "上一层",
contentDescription = stringResource(R.string.fm_cd_parent),
)
}
},
@@ -224,7 +223,7 @@ fun FileManagerScreen(
) {
Icon(
imageVector = MiuixIcons.Tune,
contentDescription = "排序",
contentDescription = stringResource(R.string.fm_cd_sort),
)
}
OverlayListPopup(
@@ -233,7 +232,12 @@ fun FileManagerScreen(
onDismissRequest = { showSortMenu = false },
) {
ListPopupColumn {
val sortOptions = listOf("文件名", "大小", "时间", "扩展名")
val sortOptions = listOf(
stringResource(R.string.fm_sort_name),
stringResource(R.string.fm_sort_size),
stringResource(R.string.fm_sort_time),
stringResource(R.string.fm_sort_extension),
)
val sortFieldIdx = when (sortField) {
FileManagerSortField.NAME -> 0
FileManagerSortField.SIZE -> 1
@@ -259,7 +263,10 @@ fun FileManagerScreen(
)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp))
val dirOptions = listOf("正序", "倒序")
val dirOptions = listOf(
stringResource(R.string.fm_sort_asc),
stringResource(R.string.fm_sort_desc),
)
val dirIdx = if (sortDescending) 1 else 0
dirOptions.forEachIndexed { i, option ->
DropdownImpl(
@@ -284,7 +291,7 @@ fun FileManagerScreen(
) {
Icon(
imageVector = MiuixIcons.More,
contentDescription = "更多",
contentDescription = stringResource(R.string.cd_more),
)
}
OverlayListPopup(
@@ -294,7 +301,7 @@ fun FileManagerScreen(
) {
ListPopupColumn {
DropdownImpl(
text = "创建文件夹",
text = stringResource(R.string.fm_menu_create_folder),
optionSize = 2,
isSelected = false,
index = 0,
@@ -305,7 +312,7 @@ fun FileManagerScreen(
},
)
DropdownImpl(
text = "上传文件到该目录",
text = stringResource(R.string.fm_menu_upload),
optionSize = 2,
isSelected = false,
index = 1,
@@ -356,7 +363,7 @@ fun FileManagerScreen(
FileDetailsBottomSheet(
show = showDetailsSheet,
content = when {
detailLoading -> "正在加载详情"
detailLoading -> stringResource(R.string.fm_loading_details)
entry != null && selectedStat != null -> buildDetailsText(
stat = selectedStat!!,
targetStat = selectedTargetStat,
@@ -366,7 +373,7 @@ fun FileManagerScreen(
showRaw = showRawDetails,
)
else -> "暂无详情"
else -> stringResource(R.string.fm_no_details)
},
onDismissRequest = viewModel::dismissDetails,
onDismissFinished = viewModel::clearDetails,
@@ -429,31 +436,39 @@ private fun FileManagerPage(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
pullToRefreshState = pullToRefreshState,
refreshTexts = listOf("下拉刷新", "释放刷新", "正在刷新...", "刷新完成"),
refreshTexts = listOf(
stringResource(R.string.fm_pull_refresh),
stringResource(R.string.fm_release_refresh),
stringResource(R.string.fm_refreshing),
stringResource(R.string.fm_refresh_done),
),
contentPadding = PaddingValues(top = contentPadding.calculateTopPadding() + 12.dp),
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val availableListWidth =
(maxWidth - listHorizontalPadding).coerceAtLeast(fileCardMinWidth)
val availableListWidth = (maxWidth - listHorizontalPadding)
.coerceAtLeast(fileCardMinWidth)
val columns = ((availableListWidth.value + UiSpacing.PageItem.value) /
(fileCardMinWidth.value + UiSpacing.PageItem.value)).toInt().coerceAtLeast(1)
val fileRows = remember(displayedEntries, columns) { displayedEntries.chunked(columns) }
(fileCardMinWidth.value + UiSpacing.PageItem.value)).toInt()
.coerceAtLeast(1)
val fileRows = remember(displayedEntries, columns) {
displayedEntries.chunked(columns)
}
@Composable
fun FileStateContent() {
when {
loading -> FileManagerStatusCard(
message = "加载中",
message = stringResource(R.string.text_loading),
modifier = Modifier.fillMaxWidth()
)
errorText != null -> FileManagerStatusCard(
message = "加载失败: $errorText",
message = stringResource(R.string.fm_load_failed, errorText),
modifier = Modifier.fillMaxWidth()
)
displayedEntries.isEmpty() -> FileManagerStatusCard(
message = "空目录",
message = stringResource(R.string.fm_empty_dir),
modifier = Modifier.fillMaxWidth()
)
}
@@ -576,7 +591,7 @@ private fun FileDetailsBottomSheet(
) {
OverlayBottomSheet(
show = show,
title = "文件详情",
title = stringResource(R.string.fm_file_details),
onDismissRequest = onDismissRequest,
onDismissFinished = onDismissFinished,
startAction = {
@@ -587,9 +602,10 @@ private fun FileDetailsBottomSheet(
imageVector =
if (!showingRaw) Icons.Rounded.RawOff
else Icons.Rounded.RawOn,
contentDescription =
if (!showingRaw) "显示原文"
else "显示解析",
contentDescription = stringResource(
if (!showingRaw) R.string.fm_show_raw
else R.string.fm_show_parsed
),
)
}
},
@@ -600,7 +616,7 @@ private fun FileDetailsBottomSheet(
) {
Icon(
imageVector = Icons.Rounded.Download,
contentDescription = "下载",
contentDescription = stringResource(R.string.fm_cd_download),
)
}
},
@@ -633,7 +649,7 @@ private fun PathJumpDialog(
) {
OverlayDialog(
show = show,
title = "跳转路径",
title = stringResource(R.string.fm_goto_path),
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
) {
@@ -646,11 +662,11 @@ private fun PathJumpDialog(
)
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem)) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = onDismissRequest,
)
TextButton(
text = "确定",
text = stringResource(R.string.button_confirm),
onClick = onConfirm,
)
}
@@ -668,7 +684,7 @@ private fun CreateFolderDialog(
) {
OverlayDialog(
show = show,
title = "创建文件夹",
title = stringResource(R.string.fm_title_create_folder),
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
) {
@@ -676,16 +692,16 @@ private fun CreateFolderDialog(
TextField(
value = folderName,
onValueChange = onFolderNameChange,
label = "新建文件夹",
label = stringResource(R.string.fm_label_new_folder),
useLabelAsPlaceholder = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem)) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = onDismissRequest,
)
TextButton(
text = "创建",
text = stringResource(R.string.fm_button_create),
onClick = onConfirm,
)
}
@@ -711,7 +727,7 @@ private fun buildDetailsText(
else FileManagerService.formatStatDetails(stat, directorySnapshot)
)
if (targetStat != null) {
details.append("\n\n目标信息\n")
details.append("\n\n${AppRuntime.stringResource(R.string.fm_stat_target_info)}\n")
details.append(
if (showRaw) targetStat.rawOutput
else FileManagerService.formatStatDetails(targetStat)

View File

@@ -5,6 +5,8 @@ import android.content.Intent
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.DirectoryDownloadSnapshot
import io.github.miuzarte.scrcpyforandroid.services.DirectorySnapshotSession
import io.github.miuzarte.scrcpyforandroid.services.FileManagerService
@@ -14,15 +16,12 @@ import io.github.miuzarte.scrcpyforandroid.services.RemoteFileStat
import io.github.miuzarte.scrcpyforandroid.storage.BundleSyncDelegate
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -149,13 +148,6 @@ internal class FileManagerViewModel : ViewModel() {
private val _pendingTreeDownload = MutableStateFlow<PendingTreeDownload?>(null)
val pendingTreeDownload: StateFlow<PendingTreeDownload?> = _pendingTreeDownload.asStateFlow()
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
val snackbarEvents: Flow<String> = _snackbarEvents.receiveAsFlow()
private fun snackbar(msg: String) {
_snackbarEvents.trySend(msg)
}
init {
asBundleSync.start()
}
@@ -187,22 +179,25 @@ internal class FileManagerViewModel : ViewModel() {
viewModelScope.launch {
val targetPath = resolveLinkTarget(entry)
if (targetPath == null) {
snackbar("链接目标不可用,长按查看信息")
AppRuntime.snackbar(R.string.fm_snack_link_unavailable)
return@launch
}
val result = runCatching { FileManagerService.stat(targetPath) }
withContext(Dispatchers.Main) {
result.onSuccess { targetStat ->
if (isDirectoryStat(targetStat)) jumpToPath(targetPath)
else snackbar("链接目标不是文件夹,长按查看信息")
else AppRuntime.snackbar(R.string.fm_snack_not_dir)
}.onFailure { error ->
snackbar("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.fm_snack_link_read_failed,
error.message ?: error.javaClass.simpleName,
)
}
}
}
}
else -> snackbar("长按可查看文件详情")
else -> AppRuntime.snackbar(R.string.fm_snack_long_press_details)
}
}
@@ -226,7 +221,7 @@ internal class FileManagerViewModel : ViewModel() {
fun createFolder(folderName: String) {
val name = folderName.trim()
if (name.isBlank()) {
snackbar("文件夹名称不能为空")
AppRuntime.snackbar(R.string.fm_snack_folder_name_empty)
return
}
viewModelScope.launch {
@@ -235,11 +230,14 @@ internal class FileManagerViewModel : ViewModel() {
}
withContext(Dispatchers.Main) {
result.onSuccess {
snackbar("已创建文件夹")
AppRuntime.snackbar(R.string.fm_snack_folder_created)
invalidateCacheForCurrentDirectory()
_isRefreshing.value = true
}.onFailure { error ->
snackbar("创建失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.fm_snack_create_failed,
error.message ?: error.javaClass.simpleName,
)
}
}
}
@@ -254,11 +252,14 @@ internal class FileManagerViewModel : ViewModel() {
}
withContext(Dispatchers.Main) {
result.onSuccess {
snackbar("已上传到 ${currentPath.value}")
AppRuntime.snackbar(R.string.fm_snack_uploaded, currentPath.value)
invalidateCacheForCurrentDirectory()
_isRefreshing.value = true
}.onFailure { error ->
snackbar("上传失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.fm_snack_upload_failed,
error.message ?: error.javaClass.simpleName,
)
}
}
}
@@ -287,7 +288,10 @@ internal class FileManagerViewModel : ViewModel() {
statResult
.onSuccess { _selectedStat.value = it }
.onFailure { error ->
snackbar("读取详情失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.fm_snack_read_details_failed,
error.message ?: error.javaClass.simpleName,
)
if (_selectedEntry.value === entry) _selectedEntry.value = null
}
targetStatResult
@@ -295,7 +299,10 @@ internal class FileManagerViewModel : ViewModel() {
snapshotResult
?.onSuccess { _selectedSnapshot.value = it }
?.onFailure { error ->
snackbar("目录扫描失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.fm_snack_scan_failed,
error.message ?: error.javaClass.simpleName,
)
}
}
}
@@ -331,26 +338,26 @@ internal class FileManagerViewModel : ViewModel() {
fun requestDownload(entry: RemoteFileEntry) {
val snapshot = _selectedSnapshot.value
dismissDetails()
snackbar("开始下载")
AppRuntime.snackbar(R.string.fm_snack_download_starting)
viewModelScope.launch {
if (entry.isDirectory) {
if (snapshot == null) {
snackbar("目录信息仍在加载,请稍后重试")
AppRuntime.snackbar(R.string.fm_snack_dir_loading)
return@launch
}
val directSaved = FileManagerService.downloadDirectoryToPublicDownloads(snapshot)
if (directSaved)
snackbar("已下载到 Download/Scrcpy")
AppRuntime.snackbar(R.string.fm_snack_downloaded)
else _pendingTreeDownload.value = PendingTreeDownload.Directory(snapshot)
} else {
val directSaved =
FileManagerService.downloadFileToPublicDownloads(entry.fullPath, entry.name)
if (directSaved) snackbar("已下载到 Download/Scrcpy")
if (directSaved) AppRuntime.snackbar(R.string.fm_snack_downloaded)
else _pendingTreeDownload.value =
PendingTreeDownload.File(entry.fullPath, entry.name)
}
if (_pendingTreeDownload.value != null)
snackbar("无法直接写入 Download/Scrcpy请选择保存目录")
AppRuntime.snackbar(R.string.fm_snack_cannot_save)
}
}
@@ -402,8 +409,13 @@ internal class FileManagerViewModel : ViewModel() {
}
withContext(Dispatchers.Main) {
result
.onSuccess { snackbar("下载完成") }
.onFailure { error -> snackbar("下载失败: ${error.message ?: error.javaClass.simpleName}") }
.onSuccess { AppRuntime.snackbar(R.string.fm_snack_download_complete) }
.onFailure { error ->
AppRuntime.snackbar(
R.string.fm_snack_download_failed,
error.message ?: error.javaClass.simpleName,
)
}
}
}
}

View File

@@ -42,6 +42,7 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@@ -51,13 +52,15 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.FragmentActivity
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
@@ -88,11 +91,11 @@ fun FullscreenControlScreen(
) {
val activity = LocalActivity.current
val context = LocalContext.current
val snackbarController = LocalSnackbarController.current
val fragmentActivity = remember(activity) { activity as? FragmentActivity }
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val snackbar = LocalSnackbarController.current
val currentSession by scrcpy.currentSessionState.collectAsState()
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
@@ -291,7 +294,7 @@ fun FullscreenControlScreen(
scrcpy.listings.getApps(forceRefresh = true)
}
}.onFailure { error ->
snackbar.show("获取应用列表失败")
AppRuntime.snackbar(R.string.fullscreen_failed_app_list)
Log.w("FullscreenControlPage", "refreshApps failed", error)
}
}
@@ -302,7 +305,7 @@ fun FullscreenControlScreen(
scrcpy.listings.getRecentTasks(forceRefresh = true)
}
}.onFailure { error ->
snackbar.show("获取最近任务失败")
AppRuntime.snackbar(R.string.fullscreen_failed_tasks)
Log.w("FullscreenControlPage", "refreshRecentTasks failed", error)
}
}
@@ -314,9 +317,9 @@ fun FullscreenControlScreen(
keyInjectMode = currentSession?.keyInjectMode ?: ClientOptions.KeyInjectMode.MIXED,
) { error, useClipboardPaste ->
Log.w("FullscreenControlPage", "commitImeText failed", error)
snackbar.show(
if (useClipboardPaste) "非 ASCII 文本粘贴失败"
else "文本输入失败"
AppRuntime.snackbar(
if (useClipboardPaste) R.string.fullscreen_paste_non_ascii
else R.string.fullscreen_text_input_failed
)
}
}
@@ -350,7 +353,7 @@ fun FullscreenControlScreen(
val text = LocalInputService.getClipboardText(activity ?: return@launch)
?.takeIf { it.isNotBlank() }
if (text == null) {
snackbar.show("本机剪贴板为空或不是文本")
AppRuntime.snackbar(R.string.fullscreen_clipboard_empty)
return@launch
}
val useLegacyPaste = session.legacyPaste
@@ -361,7 +364,10 @@ fun FullscreenControlScreen(
}
}.onFailure { error ->
Log.w("FullscreenControl", "pasteLocalClipboard failed", error)
snackbar.show(if (useLegacyPaste) "legacy 粘贴失败" else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste")
AppRuntime.snackbar(
if (useLegacyPaste) R.string.fullscreen_legacy_paste_failed
else R.string.fullscreen_clipboard_sync_failed
)
}
}
@@ -390,12 +396,15 @@ fun FullscreenControlScreen(
scrcpy.startApp(packageName)
}
}.onFailure { error ->
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.fullscreen_start_app_failed,
error.message ?: error.javaClass.simpleName,
)
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbar.hostState) },
snackbarHost = { SnackbarHost(snackbarController.hostState) },
) { contentPadding ->
Box(
modifier = Modifier
@@ -467,9 +476,9 @@ fun FullscreenControlScreen(
AppListBottomSheet(
show = showRecentTasksSheet,
title = "最近任务",
loadingText = "最近任务加载中",
emptyText = "没有可用的最近任务",
title = stringResource(R.string.bottomsheet_recent_tasks),
loadingText = stringResource(R.string.bottomsheet_loading_tasks),
emptyText = stringResource(R.string.bottomsheet_no_tasks),
entries = recentTasks.map { task ->
val app = scrcpy.listings.findCachedApp(task.packageName)
AppListEntry(
@@ -495,9 +504,9 @@ fun FullscreenControlScreen(
AppListBottomSheet(
show = showAllAppsSheet,
title = "所有应用",
loadingText = "应用列表加载中",
emptyText = "没有可用的应用列表",
title = stringResource(R.string.bottomsheet_all_apps),
loadingText = stringResource(R.string.bottomsheet_loading_apps),
emptyText = stringResource(R.string.bottomsheet_no_apps),
entries = apps.map { app ->
AppListEntry(
key = app.packageName,
@@ -745,23 +754,32 @@ fun FullscreenControlPage(
.align(Alignment.TopStart)
.padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical)
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium),
.padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium)
) {
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
Text(
text = "分辨率: ${session.width}x${session.height}",
text = stringResource(
R.string.fullscreen_debug_resolution,
session.width,
session.height,
),
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
@SuppressLint("DefaultLocale")
Text(
text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}",
text = stringResource(
R.string.fullscreen_debug_fps,
currentFps.coerceAtLeast(0f),
),
color = Color.White,
fontSize = 13.sp,
)
Text(
text = "触点: $activeTouchCount",
text = stringResource(
R.string.fullscreen_debug_touches,
activeTouchCount,
),
color = Color.White,
fontSize = 13.sp,
)

View File

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
@@ -10,6 +11,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -46,8 +48,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation3.runtime.NavKey
@@ -58,6 +60,7 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
@@ -106,22 +109,22 @@ import top.yukonga.miuix.kmp.blur.layerBackdrop as miuixLayerBackdrop
private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
private fun terminalFontFile(context: android.content.Context): File {
private fun terminalFontFile(context: Context): File {
return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH)
}
private fun copyTerminalFontToPrivate(context: android.content.Context, uri: Uri) {
private fun copyTerminalFontToPrivate(context: Context, uri: Uri) {
val target = terminalFontFile(context)
target.parentFile?.mkdirs()
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "无法读取字体文件" }
requireNotNull(input) { context.getString(R.string.main_font_read_error) }
target.outputStream().use { output ->
input.copyTo(output)
}
}
}
private fun queryDisplayName(context: android.content.Context, uri: Uri): String? {
private fun queryDisplayName(context: Context, uri: Uri): String? {
return context.contentResolver
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
@@ -135,13 +138,13 @@ private fun queryDisplayName(context: android.content.Context, uri: Uri): String
}
private enum class MainBottomTabDestination(
val label: String,
@field:StringRes val labelResId: Int,
val icon: ImageVector,
) {
Devices(label = "设备", icon = Icons.Rounded.Devices),
Terminal(label = "终端", icon = Icons.Rounded.Terminal),
Files(label = "文件", icon = Icons.Rounded.Folder),
Settings(label = "设置", icon = Icons.Rounded.Settings);
Devices(labelResId = R.string.main_tab_devices, icon = Icons.Rounded.Devices),
Terminal(labelResId = R.string.main_tab_terminal, icon = Icons.Rounded.Terminal),
Files(labelResId = R.string.main_tab_files, icon = Icons.Rounded.Folder),
Settings(labelResId = R.string.main_tab_settings, icon = Icons.Rounded.Settings);
}
sealed interface RootScreen : NavKey {
@@ -173,6 +176,11 @@ fun MainScreen() {
)
}
DisposableEffect(snackHostState) {
val unregister = AppRuntime.registerSnackbarHostState(snackHostState)
onDispose(unregister)
}
// Root navigation and UI chrome state
val saveableStateHolder = rememberSaveableStateHolder()
val tabs = remember { MainBottomTabDestination.entries }
@@ -365,10 +373,11 @@ fun MainScreen() {
withContext(Dispatchers.Main) {
result.onSuccess { displayName ->
asBundle = asBundle.copy(terminalFontDisplayName = displayName)
snackbarController.show("终端字体导入成功")
AppRuntime.snackbar(R.string.main_terminal_font_imported)
}.onFailure { error ->
snackbarController.show(
"终端字体导入失败: ${error.message ?: error.javaClass.simpleName}"
AppRuntime.snackbar(
R.string.main_terminal_font_import_failed,
error.message ?: error.javaClass.simpleName,
)
}
}
@@ -434,6 +443,7 @@ fun MainScreen() {
}
}
val textMainPressBackAgain = stringResource(R.string.main_press_back_again)
fun handleBackNavigation() {
when {
rootBackStack.size > 1 -> rootNavigator.pop()
@@ -450,7 +460,11 @@ fun MainScreen() {
val now = SystemClock.elapsedRealtime()
if (now - lastExitBackPressAtMs > 2_000L) {
lastExitBackPressAtMs = now
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
Toast.makeText(
context,
textMainPressBackAgain,
Toast.LENGTH_SHORT,
).show()
return
}
lastExitBackPressAtMs = 0L
@@ -526,7 +540,7 @@ fun MainScreen() {
selected = currentTab == tab,
onClick = { navigateToTab(tab) },
icon = tab.icon,
label = tab.label,
label = stringResource(tab.labelResId),
)
}
}
@@ -535,11 +549,11 @@ fun MainScreen() {
},
snackbarHost = { SnackbarHost(snackHostState) },
) { contentPadding ->
val bottomInnerPadding: Dp = if (asBundle.floatingBottomBar) {
12.dp + 64.dp + contentPadding.calculateBottomPadding()
} else {
contentPadding.calculateBottomPadding()
}
val bottomInnerPadding =
if (asBundle.floatingBottomBar)
12.dp + 64.dp + contentPadding.calculateBottomPadding()
else
contentPadding.calculateBottomPadding()
Box(
modifier = Modifier.fillMaxSize(),
@@ -634,11 +648,11 @@ fun MainScreen() {
) {
Icon(
imageVector = tab.icon,
contentDescription = tab.label,
contentDescription = stringResource(tab.labelResId),
tint = colorScheme.onSurface,
)
Text(
text = tab.label,
text = stringResource(tab.labelResId),
fontSize = 11.sp,
lineHeight = 14.sp,
color = colorScheme.onSurface,

View File

@@ -27,16 +27,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.RecordFilenameTemplate
import io.github.miuzarte.scrcpyforandroid.services.RecordingFileResolver
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
@@ -74,25 +75,24 @@ internal fun RecordPreferencesScreen(
scrcpy: Scrcpy,
) {
val navigator = LocalRootNavigator.current
val snackbar = LocalSnackbarController.current
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
val blurActive = blurBackdrop != null
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbar.hostState) },
snackbarHost = { AppRuntime.snackbarHostState?.let { SnackbarHost(it) } },
topBar = {
BlurredBar(backdrop = blurBackdrop) {
TopAppBar(
title = "录制",
title = stringResource(R.string.record_title),
color =
if (blurActive) Color.Transparent
else colorScheme.surface,
navigationIcon = {
IconButton(onClick = navigator.pop) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "返回",
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
@@ -241,7 +241,7 @@ private fun RecordPreferencesPage(
TextField(
value = draftTemplate,
onValueChange = { draftTemplate = it.sanitizeRecordFilenameInput() },
label = "文件名,不为空时启用",
label = stringResource(R.string.record_filename_hint),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
@@ -249,7 +249,7 @@ private fun RecordPreferencesPage(
if (draftTemplate.text.isNotEmpty()) item {
Text(
text = "预览:\n$previewFilename",
text = stringResource(R.string.record_preview) + "\n$previewFilename",
color = colorScheme.onSurfaceVariantSummary,
fontSize = textStyles.body2.fontSize,
modifier = Modifier.padding(horizontal = UiSpacing.Large),
@@ -296,7 +296,9 @@ private fun RecordPreferencesPage(
fontSize = textStyles.body1.fontSize,
)
Text(
text = entry.description ?: "纯文本",
text = stringResource(
entry.descriptionResId ?: R.string.record_plain_text
),
color = colorScheme.onSurfaceVariantSummary,
fontSize = textStyles.body2.fontSize,
)
@@ -328,11 +330,10 @@ private suspend fun saveRecordBundleForProfile(
bundle: ScrcpyOptions.Bundle,
) {
val normalizedBundle = bundle.copy(recordFilename = bundle.recordFilename.trim())
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID)
scrcpyOptions.saveBundle(normalizedBundle)
} else {
else
scrcpyProfiles.updateBundle(profileId, normalizedBundle)
}
}
private fun TextFieldValue.replaceSelection(replacement: String): TextFieldValue {
@@ -352,9 +353,7 @@ private fun TextFieldValue.replaceSelection(replacement: String): TextFieldValue
private fun TextFieldValue.sanitizeRecordFilenameInput(): TextFieldValue {
val sanitizedText = RecordingFileResolver.sanitizeFileName(text)
if (sanitizedText == text) {
return this
}
if (sanitizedText == text) return this
val sanitizedSelection = TextRange(
start = selection.start.coerceIn(0, sanitizedText.length),
end = selection.end.coerceIn(0, sanitizedText.length),

View File

@@ -13,6 +13,8 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
@@ -70,7 +72,7 @@ fun ReorderDevicesScreen(
OverlayBottomSheet(
show = show,
title = "快速设备排序",
title = stringResource(R.string.reorder_devices_title),
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
) {

View File

@@ -1,7 +1,13 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -14,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -28,13 +35,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.nativecore.DirectAdbTransport
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
@@ -42,10 +51,10 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings.FullscreenVirtualButtonDock
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.adbClientData
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
@@ -91,6 +100,21 @@ suspend fun clearTerminalFont(context: Context) =
target.exists() && target.delete()
}
suspend fun readTextFromUri(context: Context, uri: Uri): String =
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
?: error("Cannot open selected file")
}
fun queryAdbKeyDisplayName(context: Context, uri: Uri): String? =
context.contentResolver
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex >= 0 && cursor.moveToFirst()) cursor.getString(columnIndex)
else null
}
@Composable
fun SettingsScreen(
scrollBehavior: ScrollBehavior,
@@ -104,7 +128,7 @@ fun SettingsScreen(
topBar = {
BlurredBar(backdrop = blurBackdrop) {
TopAppBar(
title = "设置",
title = stringResource(R.string.settings_title),
color =
if (blurActive) Color.Transparent
else colorScheme.surface,
@@ -141,26 +165,25 @@ fun SettingsPage(
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scope = rememberCoroutineScope()
val snackbar = LocalSnackbarController.current
val navigator = LocalRootNavigator.current
val serverPicker = LocalServerPicker.current
val terminalFontPicker = LocalTerminalFontPicker.current
val isScrcpyStreaming = AppRuntime.scrcpy?.isStarted() == true
val acBundle by adbClientData.bundleState.collectAsState()
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
if (asBundle != asBundleShared)
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
if (asBundle != asBundleSharedLatest)
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
@@ -170,7 +193,7 @@ fun SettingsPage(
}
}
val themeItems = rememberSaveable { ThemeModes.baseOptions.map { it.label } }
val themeItems = AppSettings.ThemeModes.baseOptions.map { stringResource(it.labelResId) }
val fullscreenVirtualButtonDock = remember(asBundle.fullscreenVirtualButtonDock) {
FullscreenVirtualButtonDock.fromStoredValue(asBundle.fullscreenVirtualButtonDock)
@@ -196,24 +219,65 @@ fun SettingsPage(
)
}
val updateSummary = remember(updateState) {
"当前版本 ${BuildConfig.VERSION_NAME}" + when (val state = updateState) {
AppUpdateChecker.State.Idle -> ""
AppUpdateChecker.State.Checking -> ",正在检查更新"
AppUpdateChecker.State.Error -> ",检查更新失败"
is AppUpdateChecker.State.Ready -> when {
state.release.hasUpdate ->
",发现新版本 ${state.release.latestVersion}"
state.release.currentVersion == state.release.latestVersion.removePrefix("v")
|| state.release.currentVersion == state.release.latestVersion ->
",已是最新版本"
else -> ",高于最新发布版本 ${state.release.latestVersion}"
val adbPrivateKeyPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
runCatching {
val fileName = queryAdbKeyDisplayName(context, uri)
?.takeIf { it.isNotBlank() }
?: uri.lastPathSegment.orEmpty()
DirectAdbTransport.importPrivateKey(readTextFromUri(context, uri), fileName)
}.onSuccess {
AppRuntime.snackbar(R.string.pref_adb_private_key_imported, it.fingerprint)
}.onFailure { e ->
AppRuntime.snackbar(
R.string.pref_adb_key_import_failed,
e.message ?: e.javaClass.simpleName,
)
}
}
}
val adbPublicKeyPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
runCatching {
val fileName = queryAdbKeyDisplayName(context, uri)
?.takeIf { it.isNotBlank() }
?: uri.lastPathSegment.orEmpty()
DirectAdbTransport.importPublicKey(readTextFromUri(context, uri), fileName)
}.onSuccess {
AppRuntime.snackbar(R.string.pref_adb_public_key_imported_snackbar, it.fingerprint)
}.onFailure { e ->
AppRuntime.snackbar(
R.string.pref_adb_key_import_failed,
e.message ?: e.javaClass.simpleName,
)
}
}
}
val updateSummary = stringResource(R.string.pref_update_current, BuildConfig.VERSION_NAME) +
when (val state = updateState) {
AppUpdateChecker.State.Idle -> ""
AppUpdateChecker.State.Checking -> stringResource(R.string.pref_update_checking)
AppUpdateChecker.State.Error -> stringResource(R.string.pref_update_failed)
is AppUpdateChecker.State.Ready -> when {
state.release.hasUpdate ->
stringResource(R.string.pref_update_found, state.release.latestVersion)
state.release.currentVersion == state.release.latestVersion.removePrefix("v")
|| state.release.currentVersion == state.release.latestVersion ->
stringResource(R.string.pref_update_latest)
else -> stringResource(R.string.pref_update_newer, state.release.latestVersion)
}
}
val listState = rememberSaveable(saver = LazyListState.Saver) {
LazyListState()
}
@@ -226,14 +290,14 @@ fun SettingsPage(
bottomInnerPadding = bottomInnerPadding,
) {
item {
SectionSmallTitle("主题")
SectionSmallTitle(stringResource(R.string.section_theme))
Card {
OverlayDropdownPreference(
title = "外观模式",
summary = "选择应用的外观模式",
title = stringResource(R.string.pref_title_appearance_mode),
summary = stringResource(R.string.pref_summary_appearance_mode),
items = themeItems,
selectedIndex = asBundle.themeBaseIndex
.coerceIn(0, ThemeModes.baseOptions.lastIndex),
.coerceIn(0, AppSettings.ThemeModes.baseOptions.lastIndex),
onSelectedIndexChange = {
asBundle = asBundle.copy(
themeBaseIndex = it
@@ -241,8 +305,8 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "Monet 颜色",
summary = "开启后使用 Monet 动态配色",
title = stringResource(R.string.pref_title_monet),
summary = stringResource(R.string.pref_summary_monet),
checked = asBundle.monet,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -253,8 +317,8 @@ fun SettingsPage(
AnimatedVisibility(asBundle.monet) {
Column {
OverlayDropdownPreference(
title = "Monet Key Color",
summary = "设置 Monet 强调色",
title = stringResource(R.string.pref_title_monet_key_color),
summary = stringResource(R.string.pref_summary_monet_key_color),
items = MonetKeyColorOptions,
selectedIndex = asBundle.monetSeedIndex
.coerceIn(0, MonetKeyColorOptions.lastIndex),
@@ -269,8 +333,8 @@ fun SettingsPage(
AnimatedVisibility(asBundle.monet && asBundle.monetSeedIndex > 0) {
Column {
OverlayDropdownPreference(
title = "Monet Palette Style",
summary = "设置 Monet 调色板风格",
title = stringResource(R.string.pref_title_monet_palette_style),
summary = stringResource(R.string.pref_summary_monet_palette_style),
items = monetPaletteStyleOptions,
selectedIndex = asBundle.monetPaletteStyle
.coerceIn(0, monetPaletteStyleOptions.lastIndex),
@@ -281,8 +345,8 @@ fun SettingsPage(
},
)
OverlayDropdownPreference(
title = "Monet Color Spec",
summary = "设置 Monet 色彩规格",
title = stringResource(R.string.pref_title_monet_color_spec),
summary = stringResource(R.string.pref_summary_monet_color_spec),
items = monetColorSpecOptions,
selectedIndex = asBundle.monetColorSpec
.coerceIn(0, monetColorSpecOptions.lastIndex),
@@ -295,8 +359,8 @@ fun SettingsPage(
}
}
SwitchPreference(
title = "模糊",
summary = "启用顶栏和底栏的模糊效果",
title = stringResource(R.string.pref_title_blur),
summary = stringResource(R.string.pref_summary_blur),
checked = asBundle.blur,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -305,8 +369,8 @@ fun SettingsPage(
}
)
SwitchPreference(
title = "悬浮底栏",
summary = "使用 Apple 风格的悬浮底栏",
title = stringResource(R.string.pref_title_floating_bottom_bar),
summary = stringResource(R.string.pref_summary_floating_bottom_bar),
checked = asBundle.floatingBottomBar,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -317,8 +381,8 @@ fun SettingsPage(
AnimatedVisibility(asBundle.floatingBottomBar && asBundle.blur) {
Column {
SwitchPreference(
title = "液态玻璃",
summary = "启用悬浮底栏的液态玻璃效果",
title = stringResource(R.string.pref_title_liquid_glass),
summary = stringResource(R.string.pref_summary_liquid_glass),
checked = asBundle.floatingBottomBar && asBundle.blur
&& asBundle.floatingBottomBarBlur,
onCheckedChange = {
@@ -330,8 +394,8 @@ fun SettingsPage(
}
}
SwitchPreference(
title = "平滑圆角",
summary = "启用全局平滑圆角效果",
title = stringResource(R.string.pref_title_smooth_corners),
summary = stringResource(R.string.pref_summary_smooth_corners),
checked = asBundle.smoothCorner,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -343,16 +407,11 @@ fun SettingsPage(
}
item {
SectionSmallTitle("投屏")
SectionSmallTitle(stringResource(R.string.section_screen_mirroring))
Card {
SwitchPreference(
title = "低延迟音频(实验性)",
summary =
"""
启用后将尝试使用低延迟音频路径
推荐配合 RAW PCM 编解码
修改后建议划卡重启应用
""".trimIndent(),
title = stringResource(R.string.pref_title_low_latency_audio),
summary = stringResource(R.string.pref_summary_low_latency_audio),
enabled = !isScrcpyStreaming,
checked = asBundle.lowLatency,
onCheckedChange = {
@@ -363,8 +422,8 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "启用调试信息",
summary = "在全屏界面悬浮显示分辨率、帧率和触点信息",
title = stringResource(R.string.pref_title_debug_info),
summary = stringResource(R.string.pref_summary_debug_info),
checked = asBundle.fullscreenDebugInfo,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -373,8 +432,8 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "设备页隐藏简单设置项",
summary = "启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮",
title = stringResource(R.string.pref_title_hide_simple_settings),
summary = stringResource(R.string.pref_summary_hide_simple_settings),
checked = asBundle.hideSimpleConfigItems,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -383,8 +442,8 @@ fun SettingsPage(
},
)
SuperSlider(
title = "预览卡高度",
summary = "设备页预览卡高度",
title = stringResource(R.string.pref_title_preview_card_height),
summary = stringResource(R.string.pref_summary_preview_card_height),
value = asBundle.devicePreviewCardHeightDp.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
@@ -408,30 +467,25 @@ fun SettingsPage(
},
)
ArrowPreference(
title = "快速设备排序",
summary = "手动排序设备页的快速设备",
title = stringResource(R.string.pref_title_quick_device_sort),
summary = stringResource(R.string.pref_summary_quick_device_sort),
onClick = onOpenReorderDevices,
)
ArrowPreference(
title = "虚拟按钮排序",
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
title = stringResource(R.string.pref_title_virtual_button_sort),
summary = stringResource(R.string.pref_summary_virtual_button_sort),
onClick = { navigator.push(RootScreen.VirtualButtonOrder) },
)
ArrowPreference(
title = "锁屏密码自动填充",
summary = "管理用于自动填充的锁屏密码",
title = stringResource(R.string.pref_title_password_autofill),
summary = stringResource(R.string.pref_summary_password_autofill),
onClick = {
context.startActivity(LockscreenPasswordActivity.createIntent(context))
},
)
SwitchPreference(
title = "实时同步剪贴板到受控机",
summary =
"""
本机剪贴板更新后会自动同步到受控机
禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容
MIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效
""".trimIndent(),
title = stringResource(R.string.pref_title_clipboard_sync),
summary = stringResource(R.string.pref_summary_clipboard_sync),
checked = asBundle.realtimeClipboardSyncToDevice,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -443,11 +497,11 @@ fun SettingsPage(
}
item {
SectionSmallTitle("全屏")
SectionSmallTitle(stringResource(R.string.section_fullscreen))
Card {
SwitchPreference(
title = "全屏时不跟随系统旋转锁定",
summary = "启用后使用传感器方向,忽略系统自动旋转锁定状态",
title = stringResource(R.string.pref_title_ignore_rotation_lock),
summary = stringResource(R.string.pref_summary_ignore_rotation_lock),
checked = asBundle.fullscreenControlIgnoreSystemRotationLock,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -456,12 +510,8 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "全屏时返回键发送到远程",
summary =
"""
启用后系统返回键会发送给设备,不再退出全屏控制页
此时退出全屏需要回到桌面通过图标重新进入应用
""".trimIndent(),
title = stringResource(R.string.pref_title_back_to_device),
summary = stringResource(R.string.pref_summary_back_to_device),
checked = asBundle.fullscreenControlBackToDevice,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -470,8 +520,8 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "全屏时显示虚拟按钮",
summary = "在全屏控制页中显示返回键、主页键等虚拟按钮",
title = stringResource(R.string.pref_title_show_virtual_buttons),
summary = stringResource(R.string.pref_summary_show_virtual_buttons),
checked = asBundle.showFullscreenVirtualButtons,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -485,7 +535,7 @@ fun SettingsPage(
entries = listOf(
DropdownEntry(
items = FullscreenVirtualButtonDock
.modeItems.map { DropdownItem(it) },
.modeItemsResIds.map { DropdownItem(stringResource(it)) },
selectedIndex = fullscreenVirtualButtonDock.modeIndex,
onSelectedIndexChange = { modeIndex ->
asBundle = asBundle.copy(
@@ -500,7 +550,7 @@ fun SettingsPage(
),
DropdownEntry(
items = FullscreenVirtualButtonDock
.directionItems.map { DropdownItem(it) },
.directionItemsResIds.map { DropdownItem(stringResource(it)) },
selectedIndex = fullscreenVirtualButtonDock.directionIndex,
onSelectedIndexChange = { directionIndex ->
asBundle = asBundle.copy(
@@ -514,11 +564,16 @@ fun SettingsPage(
},
),
),
title = "虚拟按钮方向",
summary = fullscreenVirtualButtonDock.summary,
title = stringResource(R.string.pref_title_virtual_button_direction),
summary = stringResource(
if (fullscreenVirtualButtonDock.isFixed) R.string.dock_fixed
else R.string.dock_follow
) +
stringResource(R.string.dock_display_on) +
stringResource(fullscreenVirtualButtonDock.directionLabelResId),
)
SuperSlider(
title = "虚拟按钮高度",
title = stringResource(R.string.pref_title_virtual_button_height),
value = asBundle.fullscreenVirtualButtonHeightDp.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
@@ -545,8 +600,8 @@ fun SettingsPage(
}
}
SwitchPreference(
title = "全屏时显示悬浮球",
summary = "在全屏控制页中显示可拖动的悬浮球,点击后弹出完整虚拟按键菜单",
title = stringResource(R.string.pref_title_show_floating_button),
summary = stringResource(R.string.pref_summary_show_floating_button),
checked = asBundle.showFullscreenFloatingButton,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -557,7 +612,7 @@ fun SettingsPage(
AnimatedVisibility(asBundle.showFullscreenFloatingButton) {
Column {
SuperSlider(
title = "悬浮球尺寸",
title = stringResource(R.string.pref_title_floating_button_size),
value = asBundle.fullscreenFloatingButtonSizeDp.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
@@ -582,7 +637,7 @@ fun SettingsPage(
},
)
SuperSlider(
title = "悬浮球背景透明度",
title = stringResource(R.string.pref_title_floating_button_bg_opacity),
value = asBundle.fullscreenFloatingButtonBackgroundAlphaPercent.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
@@ -607,7 +662,7 @@ fun SettingsPage(
},
)
SuperSlider(
title = "悬浮球白环透明度",
title = stringResource(R.string.pref_title_floating_button_ring_opacity),
value = asBundle.fullscreenFloatingButtonRingAlphaPercent.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
@@ -634,8 +689,8 @@ fun SettingsPage(
}
}
SwitchPreference(
title = "全屏兼容模式",
summary = "启用后全屏控制页不再跨 Activity会导致画中画不可用",
title = stringResource(R.string.pref_title_fullscreen_compat_mode),
summary = stringResource(R.string.pref_summary_fullscreen_compat_mode),
checked = asBundle.fullscreenCompatibilityMode,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -647,7 +702,7 @@ fun SettingsPage(
}
item {
SectionSmallTitle("scrcpy-server")
SectionSmallTitle(stringResource(R.string.section_scrcpy_server))
Card {
Column(
modifier = Modifier.padding(vertical = UiSpacing.Large),
@@ -658,7 +713,7 @@ fun SettingsPage(
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 binary",
text = stringResource(R.string.pref_title_custom_binary),
fontWeight = FontWeight.Medium,
)
TextField(
@@ -671,7 +726,7 @@ fun SettingsPage(
trailingIcon = {
Row(
modifier = Modifier
.padding(end = UiSpacing.Medium),
.padding(end = UiSpacing.Medium)
) {
if (asBundle.customServerUri.isNotBlank())
IconButton(
@@ -683,14 +738,14 @@ fun SettingsPage(
},
) {
Icon(
Icons.Rounded.Clear,
contentDescription = "清空",
imageVector = Icons.Rounded.Clear,
contentDescription = stringResource(R.string.cd_clear),
)
}
IconButton(onClick = serverPicker.pick) {
Icon(
Icons.Rounded.FileOpen,
contentDescription = "选择文件",
imageVector = Icons.Rounded.FileOpen,
contentDescription = stringResource(R.string.cd_select_file),
)
}
}
@@ -703,7 +758,7 @@ fun SettingsPage(
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 binary version",
text = stringResource(R.string.pref_title_custom_binary_version),
fontWeight = FontWeight.Medium,
)
SuperTextField(
@@ -754,33 +809,29 @@ fun SettingsPage(
}
item {
SectionSmallTitle("ADB")
SectionSmallTitle(stringResource(R.string.section_adb))
Card {
val textTitleAppInfo = stringResource(R.string.pref_title_app_info)
ArrowPreference(
title = "调整后台电池策略",
summary =
"""
解决 Scrcpy 切换到后台时无法联网导致 ADB 断连
应用的电池使用情况 -> 允许后台使用 -> 无限制
国产ROM魔改的电源设置一般都可在对应的魔改应用设置中找到
""".trimIndent(),
title = stringResource(R.string.pref_title_battery_optimization),
summary = stringResource(R.string.pref_summary_battery_optimization),
onClick = {
val appInfoArgs = android.os.Bundle().apply {
val appInfoArgs = Bundle().apply {
putString("package", context.packageName)
putInt("uid", context.applicationInfo.uid)
}
val appDetailsIntent = Intent(Intent.ACTION_MAIN).apply {
setClassName(
"com.android.settings",
"com.android.settings.SubSettings"
"com.android.settings.SubSettings",
)
putExtra(
":settings:show_fragment",
"com.android.settings.applications.appinfo.AppInfoDashboardFragment"
"com.android.settings.applications.appinfo.AppInfoDashboardFragment",
)
putExtra(
":settings:show_fragment_title",
"应用信息"
textTitleAppInfo,
)
putExtra(":settings:show_fragment_args", appInfoArgs)
putExtra("package", context.packageName)
@@ -797,7 +848,7 @@ fun SettingsPage(
runCatching { context.startActivity(appDetailsIntent) }
.recoverCatching { context.startActivity(requestIntent) }
.recoverCatching { context.startActivity(fallbackIntent) }
.onFailure { snackbar.show("无法打开设置") }
.onFailure { AppRuntime.snackbar(R.string.pref_cannot_open_settings) }
},
)
Column(
@@ -809,7 +860,7 @@ fun SettingsPage(
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 ADB 密钥名",
text = stringResource(R.string.pref_title_custom_adb_key),
fontWeight = FontWeight.Medium,
)
SuperTextField(
@@ -829,10 +880,128 @@ fun SettingsPage(
modifier = Modifier.fillMaxWidth(),
)
}
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
val hasImportedAdbKey =
acBundle.importedPrivateKey.isNotBlank() ||
acBundle.importedPublicKeyX509.isNotBlank()
Text(
text = stringResource(R.string.pref_title_adb_private_key),
fontWeight = FontWeight.Medium,
)
TextField(
value = acBundle.importedPrivateKeyFileName,
onValueChange = {},
readOnly = true,
label = stringResource(
when {
acBundle.importedPrivateKey.isBlank() &&
acBundle.rsaPrivateKey.isBlank() ->
R.string.pref_adb_key_not_imported
acBundle.importedPrivateKey.isNotBlank() ->
R.string.pref_adb_key_imported
else -> R.string.pref_adb_key_generated
}
),
useLabelAsPlaceholder = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.Medium)) {
IconButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
DirectAdbTransport.resetKeys()
}
}.onSuccess {
AppRuntime.snackbar(
if (it.removedImportedKey)
R.string.pref_adb_key_removed
else
R.string.pref_adb_key_reset,
it.fingerprint,
)
}.onFailure { e ->
AppRuntime.snackbar(
R.string.pref_adb_key_import_failed,
e.message ?: e.javaClass.simpleName,
)
}
}
},
) {
Icon(
imageVector =
if (hasImportedAdbKey) Icons.Rounded.Clear
else Icons.Rounded.Refresh,
contentDescription = stringResource(
if (hasImportedAdbKey) R.string.pref_adb_key_remove
else R.string.pref_adb_key_regenerate,
),
)
}
IconButton(
onClick = { adbPrivateKeyPicker.launch(arrayOf("*/*")) },
) {
Icon(
imageVector = Icons.Rounded.FileOpen,
contentDescription = stringResource(R.string.cd_select_file),
)
}
}
},
)
}
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
val publicKeyLabel = stringResource(
when {
acBundle.importedPublicKeyX509.isBlank() &&
acBundle.rsaPublicKeyX509.isBlank() ->
R.string.pref_adb_key_not_imported
acBundle.importedPublicKeyFileName.isNotBlank() ->
R.string.pref_adb_key_imported
else -> R.string.pref_adb_key_generated
}
)
Text(
text = stringResource(R.string.pref_title_adb_public_key),
fontWeight = FontWeight.Medium,
)
TextField(
value = acBundle.importedPublicKeyFileName,
onValueChange = {},
readOnly = true,
label = publicKeyLabel,
useLabelAsPlaceholder = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.Medium)) {
IconButton(
onClick = { adbPublicKeyPicker.launch(arrayOf("*/*")) },
) {
Icon(
imageVector = Icons.Rounded.FileOpen,
contentDescription = stringResource(R.string.cd_select_file),
)
}
}
},
)
}
}
SwitchPreference(
title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口",
title = stringResource(R.string.pref_title_auto_discovery),
summary = stringResource(R.string.pref_summary_auto_discovery),
checked = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -841,8 +1010,8 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
title = stringResource(R.string.pref_title_auto_reconnect),
summary = stringResource(R.string.pref_summary_auto_reconnect),
checked = asBundle.adbAutoReconnectPairedDevice,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -851,15 +1020,15 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "连接后自动获取应用列表",
summary = "ADB 连接成功后立刻执行 --list-apps用于补全最近任务列表应用名",
title = stringResource(R.string.pref_title_auto_load_apps),
summary = stringResource(R.string.pref_summary_auto_load_apps),
checked = asBundle.adbAutoLoadAppListOnConnect,
onCheckedChange = {
asBundle = asBundle.copy(
adbAutoLoadAppListOnConnect = it
)
if (it) snackbar.show(
"--list-apps 操作可能非常耗时(特别是在息屏状态下),启用后可能导致连接设备后阻塞过久!"
if (it) AppRuntime.snackbar(
R.string.pref_warning_list_apps
)
},
)
@@ -867,11 +1036,11 @@ fun SettingsPage(
}
item {
SectionSmallTitle("终端")
SectionSmallTitle(stringResource(R.string.section_terminal))
Card {
SuperSlider(
title = "终端字号",
summary = "也可以在终端上双指缩放调整",
title = stringResource(R.string.pref_title_terminal_font_size),
summary = stringResource(R.string.pref_summary_terminal_font_size),
value = asBundle.terminalFontSizeSp,
onValueChange = {
asBundle = asBundle.copy(
@@ -902,14 +1071,14 @@ fun SettingsPage(
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义终端字体",
text = stringResource(R.string.pref_title_custom_font),
fontWeight = FontWeight.Medium,
)
TextField(
value = asBundle.terminalFontDisplayName,
onValueChange = {},
readOnly = true,
label = "内置等宽字体",
label = stringResource(R.string.pref_hint_builtin_font),
useLabelAsPlaceholder = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
@@ -922,23 +1091,23 @@ fun SettingsPage(
asBundle = asBundle.copy(
terminalFontDisplayName = ""
)
snackbar.show(
if (cleared) "已恢复默认终端字体"
else "当前没有可清除的自定义字体"
AppRuntime.snackbar(
if (cleared) R.string.pref_font_restored
else R.string.pref_no_custom_font
)
}
},
) {
Icon(
Icons.Rounded.Clear,
contentDescription = "清空",
imageVector = Icons.Rounded.Clear,
contentDescription = stringResource(R.string.cd_clear),
)
}
}
IconButton(onClick = terminalFontPicker.pick) {
Icon(
Icons.Rounded.FileOpen,
contentDescription = "选择字体",
imageVector = Icons.Rounded.FileOpen,
contentDescription = stringResource(R.string.cd_select_font),
)
}
}
@@ -950,11 +1119,11 @@ fun SettingsPage(
}
item {
SectionSmallTitle("杂项")
SectionSmallTitle(stringResource(R.string.section_misc))
Card {
SwitchPreference(
title = "退出应用时清除日志",
summary = "双击返回退出应用时清除日志",
title = stringResource(R.string.pref_title_clear_logs_on_exit),
summary = stringResource(R.string.pref_summary_clear_logs_on_exit),
checked = asBundle.clearLogsOnExit,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -963,8 +1132,8 @@ fun SettingsPage(
},
)
SwitchPreference(
title = "隐藏设备页日志框",
summary = "隐藏设备页最下方的日志框",
title = stringResource(R.string.pref_title_hide_log_box),
summary = stringResource(R.string.pref_summary_hide_log_box),
checked = asBundle.hideDeviceLogs,
onCheckedChange = {
asBundle = asBundle.copy(
@@ -979,7 +1148,7 @@ fun SettingsPage(
SectionSmallTitle("")
Card {
ArrowPreference(
title = "关于",
title = stringResource(R.string.about_title),
summary = updateSummary,
onClick = { navigator.push(RootScreen.About) },
)

View File

@@ -94,6 +94,10 @@ fun StreamScreen(activity: StreamActivity) {
val snackbarController = remember(snackbarScope, snackbarHostState) {
SnackbarController(scope = snackbarScope, hostState = snackbarHostState)
}
DisposableEffect(snackbarHostState) {
val unregister = AppRuntime.registerSnackbarHostState(snackbarHostState)
onDispose(unregister)
}
MiuixTheme(
controller = themeController,

View File

@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -47,7 +48,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.termux.terminal.TerminalSession
import com.termux.view.TerminalView
import com.termux.view.TerminalViewClient
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
@@ -93,23 +96,20 @@ fun TerminalScreen(
) {
val viewModel: TerminalViewModel = viewModel()
val context = LocalContext.current
val snackbar = LocalSnackbarController.current
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
val blurActive = blurBackdrop != null
var showMenu by rememberSaveable { mutableStateOf(false) }
var showOutputSheet by rememberSaveable { mutableStateOf(false) }
var output by rememberSaveable { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.snackbarEvents.collect { snackbar.show(it) }
}
Scaffold(
topBar = {
BlurredBar(backdrop = blurBackdrop) {
SmallTopAppBar(
title = "终端",
color = if (blurActive) Color.Transparent else colorScheme.surface,
title = stringResource(R.string.terminal_title),
color =
if (blurActive) Color.Transparent
else colorScheme.surface,
actions = {
Box {
IconButton(
@@ -118,7 +118,7 @@ fun TerminalScreen(
) {
Icon(
imageVector = MiuixIcons.More,
contentDescription = "更多",
contentDescription = stringResource(R.string.cd_more),
)
}
OverlayListPopup(
@@ -128,7 +128,7 @@ fun TerminalScreen(
) {
ListPopupColumn {
DropdownImpl(
text = "自由复制",
text = stringResource(R.string.terminal_menu_free_copy),
optionSize = 2,
isSelected = false,
index = 0,
@@ -139,7 +139,7 @@ fun TerminalScreen(
},
)
DropdownImpl(
text = "清屏",
text = stringResource(R.string.terminal_menu_clear_screen),
optionSize = 2,
isSelected = false,
index = 1,
@@ -177,7 +177,7 @@ fun TerminalScreen(
onCopyAll = {
showMenu = false
LocalInputService.setClipboardText(context, output)
snackbar.show("已复制所有终端输出")
AppRuntime.snackbar(R.string.terminal_copied_all)
},
)
}
@@ -197,7 +197,6 @@ private fun TerminalPage(
) {
val context = LocalContext.current
val density = LocalDensity.current
val snackbar = LocalSnackbarController.current
val haptic = LocalHapticFeedback.current
val asBundle by viewModel.asBundle.collectAsState()
@@ -228,7 +227,7 @@ private fun TerminalPage(
},
onCopyTextToClipboardRequested = { text ->
LocalInputService.setClipboardText(context, text)
snackbar.show("已复制到剪贴板")
AppRuntime.snackbar(R.string.terminal_copied)
},
onPasteTextFromClipboardRequested = { viewModel.writeClipboardToShell(context) },
onBellRequested = {},
@@ -249,16 +248,21 @@ private fun TerminalPage(
viewModel.openShellSession(showKeyboardAfterConnect, ::requestTerminalFocus)
}
fun updateFontSize(newValue: Float) {
viewModel.updateTerminalFontSize(newValue) { clamped ->
fun updateFontSize(newValue: Float): Float {
return viewModel.updateTerminalFontSize(newValue) { clamped ->
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
}
}
fun showFontSizeSnackbar() {
fun adjustFontSize(delta: Float): Float {
return viewModel.adjustTerminalFontSize(delta) { clamped ->
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
}
}
fun showFontSizeSnackbar(fontSizeSp: Float) {
viewModel.launchFontSizeSnackbar(
fontSizeSp = terminalFontSizeSp,
hostState = snackbar.hostState,
fontSizeSp = fontSizeSp,
onReset = { clamped ->
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
},
@@ -318,14 +322,14 @@ private fun TerminalPage(
override fun onScale(scale: Float): Float {
when {
scale >= FONT_SCALE_STEP_THRESHOLD -> {
updateFontSize(terminalFontSizeSp + 1f)
showFontSizeSnackbar()
val applied = adjustFontSize(1f)
showFontSizeSnackbar(applied)
return 1f
}
scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> {
updateFontSize(terminalFontSizeSp - 1f)
showFontSizeSnackbar()
val applied = adjustFontSize(-1f)
showFontSizeSnackbar(applied)
return 1f
}
}
@@ -640,14 +644,14 @@ private fun TerminalOutputBottomSheet(
) {
OverlayBottomSheet(
show = show,
title = "自由复制",
title = stringResource(R.string.terminal_menu_free_copy),
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
endAction = {
IconButton(onClick = onCopyAll) {
Icon(
imageVector = Icons.Rounded.ContentCopy,
contentDescription = "复制全部",
contentDescription = stringResource(R.string.terminal_copy_all),
)
}
},
@@ -659,13 +663,13 @@ private fun TerminalOutputBottomSheet(
) {
item {
TextField(
value = output.ifBlank { "当前没有输出" },
value = output.ifBlank { stringResource(R.string.terminal_no_output) },
onValueChange = {},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = UiSpacing.PageVertical),
readOnly = true,
label = "终端输出",
label = stringResource(R.string.terminal_output_label),
useLabelAsPlaceholder = true,
)
}

View File

@@ -9,24 +9,22 @@ import androidx.lifecycle.viewModelScope
import com.termux.terminal.KeyHandler
import com.termux.terminal.TerminalSession
import com.termux.terminal.TextStyle
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.storage.BundleSyncDelegate
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.SnackbarResult
import java.nio.charset.StandardCharsets
import kotlin.math.roundToInt
@@ -46,12 +44,17 @@ internal class TerminalViewModel : ViewModel() {
private val _terminalFontSizeSp = MutableStateFlow(asBundle.value.terminalFontSizeSp)
val terminalFontSizeSp: StateFlow<Float> = _terminalFontSizeSp.asStateFlow()
fun updateTerminalFontSize(newValue: Float, onApplied: (Float) -> Unit) {
fun updateTerminalFontSize(newValue: Float, onApplied: (Float) -> Unit): Float {
val clamped = newValue.coerceIn(1f, 32f)
if (clamped == _terminalFontSizeSp.value) return
if (clamped == _terminalFontSizeSp.value) return clamped
_terminalFontSizeSp.value = clamped
asBundleSync.update { it.copy(terminalFontSizeSp = clamped) }
onApplied(clamped)
return clamped
}
fun adjustTerminalFontSize(delta: Float, onApplied: (Float) -> Unit): Float {
return updateTerminalFontSize(_terminalFontSizeSp.value + delta, onApplied)
}
fun resetFontSizeToDefault(onApplied: (Float) -> Unit) {
@@ -82,7 +85,10 @@ internal class TerminalViewModel : ViewModel() {
}
withContext(Dispatchers.Main) {
result.onFailure { error ->
snackbar("终端输入失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.terminal_snack_input_failed,
error.message ?: error.javaClass.simpleName,
)
}
}
}
@@ -174,7 +180,10 @@ internal class TerminalViewModel : ViewModel() {
withContext(Dispatchers.Main) {
_shellConnecting.value = false
_shellReady.value = false
snackbar("终端会话创建失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.terminal_snack_session_failed,
error.message ?: error.javaClass.simpleName,
)
}
return@launch
}
@@ -197,7 +206,10 @@ internal class TerminalViewModel : ViewModel() {
}
} catch (error: Throwable) {
withContext(Dispatchers.Main) {
snackbar("终端输出失败: ${error.message ?: error.javaClass.simpleName}")
AppRuntime.snackbar(
R.string.terminal_snack_output_failed,
error.message ?: error.javaClass.simpleName,
)
}
} finally {
runCatching { stream.close() }
@@ -227,29 +239,21 @@ internal class TerminalViewModel : ViewModel() {
_shellConnecting.value = false
}
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
val snackbarEvents: Flow<String> = _snackbarEvents.receiveAsFlow()
private fun snackbar(message: String) {
_snackbarEvents.trySend(message)
}
fun launchFontSizeSnackbar(
fontSizeSp: Float,
hostState: SnackbarHostState,
onReset: (Float) -> Unit,
) {
viewModelScope.launch {
hostState.newestSnackbarData()?.dismiss()
val result = hostState.showSnackbar(
message = "终端字号 ${fontSizeSp.roundToInt()}sp",
actionLabel = "恢复默认",
withDismissAction = true,
)
if (result == SnackbarResult.ActionPerformed) {
resetFontSizeToDefault(onReset)
}
}
AppRuntime.snackbar(
R.string.terminal_font_size_snackbar,
fontSizeSp.roundToInt(),
actionLabelResId = R.string.terminal_font_size_restore_default,
withDismissAction = true,
onResult = { result ->
if (result == SnackbarResult.ActionPerformed)
resetFontSizeToDefault(onReset)
},
dismissNewest = true,
)
}
init {

View File

@@ -19,6 +19,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
@@ -51,20 +54,21 @@ internal fun VirtualButtonOrderScreen(
val navigator = LocalRootNavigator.current
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
val blurActive = blurBackdrop != null
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BlurredBar(backdrop = blurBackdrop) {
TopAppBar(
title = "虚拟按钮排序",
title = stringResource(R.string.vb_order_title),
color =
if (blurActive) Color.Transparent
else colorScheme.surface,
navigationIcon = {
IconButton(onClick = navigator.pop) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "返回"
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
@@ -87,6 +91,7 @@ internal fun VirtualButtonOrderPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
) {
val context = LocalContext.current
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val asBundleShared by appSettings.bundleState.collectAsState()
@@ -94,15 +99,13 @@ internal fun VirtualButtonOrderPage(
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
if (asBundle != asBundleShared)
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
if (asBundle != asBundleSharedLatest)
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
@@ -124,8 +127,8 @@ internal fun VirtualButtonOrderPage(
item {
Card {
SwitchPreference(
title = "按钮显示文本",
summary = "超过3个建议关闭只对预览卡下方的虚拟按钮生效",
title = stringResource(R.string.vb_order_button_text),
summary = stringResource(R.string.vb_order_hint),
checked = asBundle.previewVirtualButtonShowText,
onCheckedChange = {
asBundle = asBundle.copy(previewVirtualButtonShowText = it)
@@ -137,19 +140,22 @@ internal fun VirtualButtonOrderPage(
item { Spacer(Modifier.height(UiSpacing.Medium)) }
item {
val textExternal = stringResource(R.string.vb_order_display_external)
val textMoreMenu = stringResource(R.string.vb_order_display_more_menu)
ReorderableList(
itemsProvider = {
buttonItems.map { item ->
val action = item.action
val actionTitle = context.getString(action.titleResId)
ReorderableList.Item(
id = action.id,
icon = action.icon,
title =
if (action.keycode == null) action.title
else "${action.title} (${action.keycode})",
if (action.keycode == null) actionTitle
else "$actionTitle (${action.keycode})",
subtitle =
if (item.showOutside) "显示在外部"
else "显示在更多菜单内",
if (item.showOutside) textExternal
else textMoreMenu,
endActions = listOf(
ReorderableList.EndAction.Checkbox(
checked = item.showOutside,

View File

@@ -33,8 +33,8 @@ object BiometricGate {
suspend fun authenticate(
activity: FragmentActivity,
title: String = "验证身份",
subtitle: String = "确认后继续",
title: String,
subtitle: String,
): Boolean = suspendCancellableCoroutine { continuation ->
val sessionId = sessionIds.incrementAndGet()
var resumed = false

View File

@@ -1,5 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.password
import android.annotation.SuppressLint
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -12,9 +13,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.fragment.app.FragmentActivity
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import kotlinx.coroutines.CoroutineScope
@@ -38,8 +43,10 @@ fun PasswordPickerPopupContent(onDismissRequest: () -> Unit) {
val entries by PasswordRepository.entriesState.collectAsState()
val appSettingsBundle by appSettings.bundleState.collectAsState()
val snackbar = LocalSnackbarController.current
val textInvalidated = stringResource(R.string.password_status_invalidated)
val textAuthenticated = stringResource(R.string.password_status_authenticated)
val textUnauthenticated = stringResource(R.string.password_status_unauthenticated)
val textBurned = stringResource(R.string.password_status_burned)
val spinnerEntries = remember(entries) {
entries.map { entry ->
SpinnerEntry(
@@ -54,16 +61,17 @@ fun PasswordPickerPopupContent(onDismissRequest: () -> Unit) {
},
title = entry.name,
summary =
if (entry.cipherText == null) "已失效"
if (entry.cipherText == null) textInvalidated
else when (entry.createdWithAuth) {
PasswordCreatedState.AuthenticatedCreated -> "创建时已验证"
PasswordCreatedState.UnauthenticatedCreated -> "创建时未经验证"
PasswordCreatedState.AuthenticatedCreatedModified -> "创建时已验证(熔断)"
PasswordCreatedState.AuthenticatedCreated -> textAuthenticated
PasswordCreatedState.UnauthenticatedCreated -> textUnauthenticated
PasswordCreatedState.AuthenticatedCreatedModified -> textBurned
},
)
}
}
val textAuthFillTitle = stringResource(R.string.password_auth_fill_title)
fun fillPassword(index: Int) {
val entry = entries[index]
scope.launch {
@@ -71,11 +79,15 @@ fun PasswordPickerPopupContent(onDismissRequest: () -> Unit) {
activity = fragActivity!!,
entry = entry,
globalRequiresAuth = appSettingsBundle.passwordRequireAuth,
authTitle = textAuthFillTitle,
).onSuccess { password ->
InjectionController.inject(password)
onDismissRequest()
}.onFailure {
taskScope.launch { snackbar.show(it.message ?: "密码填充失败") }
}.onFailure { e ->
AppRuntime.snackbar(
R.string.password_fill_failed,
e.message ?: e.javaClass.simpleName,
)
}
}
}
@@ -83,7 +95,7 @@ fun PasswordPickerPopupContent(onDismissRequest: () -> Unit) {
ListPopupColumn {
if (spinnerEntries.isEmpty()) {
Text(
text = "无可用密码",
text = stringResource(R.string.password_no_available),
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.PopupHorizontal),
@@ -95,7 +107,7 @@ fun PasswordPickerPopupContent(onDismissRequest: () -> Unit) {
if (fragActivity == null) {
Text(
text = "当前页面无法拉起验证",
text = stringResource(R.string.password_cannot_auth_here),
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.PopupHorizontal),

View File

@@ -1,31 +1,34 @@
package io.github.miuzarte.scrcpyforandroid.password
import androidx.fragment.app.FragmentActivity
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
class PasswordUseCase {
suspend fun preparePassword(
activity: FragmentActivity,
entry: PasswordEntry,
globalRequiresAuth: Boolean,
authTitle: String,
): Result<CharArray> {
val canAuthNow = BiometricGate.canAuthenticate()
if (globalRequiresAuth) {
val ok = BiometricGate.authenticate(
activity = activity,
title = "验证以填充锁屏密码",
subtitle = entry.name,
)
if (!ok) {
return Result.failure(IllegalStateException("认证失败"))
}
if (
!BiometricGate.authenticate(
activity = activity,
title = authTitle,
subtitle = entry.name,
)
) return Result.failure(IllegalStateException(AppRuntime.stringResource(R.string.password_auth_failed)))
} else if (entry.createdWithAuth.hasAuthenticatedOrigin && !canAuthNow) {
return Result.failure(IllegalStateException("设备安全状态已变更,请重新设置密码"))
return Result.failure(IllegalStateException(AppRuntime.stringResource(R.string.password_security_state_changed)))
}
val password = entry.cipherText ?: return Result.failure(
IllegalStateException("密码已失效,请重新设置")
)
val password = entry.cipherText
?: return Result.failure(
IllegalStateException(AppRuntime.stringResource(R.string.password_expired))
)
return Result.success(password.copyOf())
}
}

View File

@@ -1,10 +1,14 @@
package io.github.miuzarte.scrcpyforandroid.scaffolds
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.miuix.OverlaySpinnerPreference
import io.github.miuzarte.scrcpyforandroid.miuix.SpinnerEntry
import top.yukonga.miuix.kmp.basic.BasicComponentColors
@@ -18,7 +22,7 @@ import top.yukonga.miuix.kmp.basic.SpinnerDefaults
*
* When [dataLoaded] is false and [overrideEndActionValue] is set, the saved value is
* appended as a synthetic item so it remains visible before the list is fetched.
* When [dataLoading] is true, a "加载中..." entry with an [InfiniteProgressIndicator]
* When [dataLoading] is true, a stringResource(R.string.text_loading) entry with an [InfiniteProgressIndicator]
* icon is appended at the end.
*/
@Composable
@@ -46,6 +50,7 @@ fun OverlaySpinnerWithFallback(
) {
val fallbackActive = !dataLoaded && !overrideEndActionValue.isNullOrBlank()
val fallbackIdx = items.size
val textLoading = stringResource(R.string.text_loading)
val effectiveItems = remember(items, dataLoading, fallbackActive, overrideEndActionValue) {
buildList {
addAll(items)
@@ -61,7 +66,7 @@ fun OverlaySpinnerWithFallback(
add(
SpinnerEntry(
icon = { mod -> InfiniteProgressIndicator(mod) },
title = "加载中...",
title = textLoading,
enabled = false,
)
)

View File

@@ -19,10 +19,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.ui.confirm
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
@@ -148,8 +150,8 @@ class ReorderableList(
),
) {
Icon(
Icons.Rounded.DragIndicator,
contentDescription = "拖动排序",
imageVector = Icons.Rounded.DragIndicator,
contentDescription = stringResource(R.string.cd_drag_sort),
tint = colorScheme.onSurfaceVariantSummary,
)
}
@@ -210,8 +212,8 @@ class ReorderableList(
),
) {
Icon(
Icons.Rounded.DragIndicator,
contentDescription = "拖动排序",
imageVector = Icons.Rounded.DragIndicator,
contentDescription = stringResource(R.string.cd_drag_sort),
tint = colorScheme.onSurfaceVariantSummary,
)
}

View File

@@ -16,6 +16,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import io.github.miuzarte.scrcpyforandroid.R
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Slider
import top.yukonga.miuix.kmp.basic.Text
@@ -147,13 +149,13 @@ private fun SliderInputDialog(
Row(horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = onDismissRequest,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(20.dp))
TextButton(
text = "确定",
text = stringResource(R.string.button_confirm),
onClick = {
val inputValue = text.toFloatOrNull() ?: 0f
if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {

View File

@@ -1705,25 +1705,18 @@ class Scrcpy(
output.writeShort(screenHeight)
}
private fun encodeUnsignedFixedPoint16(value: Float): Int {
val clamped = value.coerceIn(0f, 1f)
return if (clamped >= 1f) {
0xffff
} else {
(clamped * 65536f).roundToInt().coerceIn(0, 0xfffe)
private fun encodeUnsignedFixedPoint16(value: Float) =
value.coerceIn(0f, 1f).let {
if (it >= 1f) 0xffff
else (it * 65536f).roundToInt().coerceIn(0, 0xfffe)
}
}
private fun encodeSignedFixedPoint16(value: Float): Int {
val clamped = value.coerceIn(-1f, 1f)
if (clamped >= 1f) {
return 0x7fff
private fun encodeSignedFixedPoint16(value: Float) =
value.coerceIn(-1f, 1f).let {
if (it >= 1f) 0x7fff
else if (it <= -1f) -0x8000
else (it * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe)
}
if (clamped <= -1f) {
return -0x8000
}
return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe)
}
}
companion object {

View File

@@ -3,9 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.scrcpy
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
/*
参考官方实现尽量统一行为
*/
// 参考官方实现尽量统一行为
class Shared {
@JvmInline

View File

@@ -1,9 +1,18 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import androidx.annotation.StringRes
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.SnackbarDuration
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.SnackbarResult
// 用于不同 activity 之间传递实例
object AppRuntime {
@@ -20,4 +29,88 @@ object AppRuntime {
var scrcpy: Scrcpy? = null
var currentConnectionTarget: ConnectionTarget? = null
var currentConnectedDevice: ConnectedDeviceInfo? = null
private val snackbarHostStateLock = Any()
private val snackbarHostStateStack = mutableListOf<SnackbarHostState>()
var snackbarHostState: SnackbarHostState?
get() = synchronized(snackbarHostStateLock) {
snackbarHostStateStack.lastOrNull()
}
set(value) {
synchronized(snackbarHostStateLock) {
snackbarHostStateStack.clear()
if (value != null) snackbarHostStateStack.add(value)
}
}
private val snackbarScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun registerSnackbarHostState(hostState: SnackbarHostState): () -> Unit {
synchronized(snackbarHostStateLock) {
snackbarHostStateStack.add(hostState)
}
return {
synchronized(snackbarHostStateLock) {
snackbarHostStateStack.remove(hostState)
}
}
}
suspend fun snackbarDismissNewest() = snackbarHostState?.newestSnackbarData()?.dismiss()
fun snackbar(
message: String,
actionLabel: String? = null,
withDismissAction: Boolean = true,
duration: SnackbarDuration = SnackbarDuration.Short,
onResult: ((SnackbarResult) -> Unit)? = null,
dismissNewest: Boolean = false,
) = snackbarHostState?.let {
snackbarScope.launch {
if (dismissNewest) snackbarDismissNewest()
it.showSnackbar(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration,
).let { result -> onResult?.invoke(result) }
}
}
fun snackbar(
@StringRes messageResId: Int,
@StringRes actionLabelResId: Int? = null,
withDismissAction: Boolean = true,
duration: SnackbarDuration = SnackbarDuration.Short,
onResult: ((SnackbarResult) -> Unit)? = null,
dismissNewest: Boolean = false,
) = snackbar(
message = stringResource(messageResId),
actionLabel = actionLabelResId?.let(::stringResource),
withDismissAction = withDismissAction,
duration = duration,
onResult = onResult,
dismissNewest = dismissNewest,
)
fun snackbar(
@StringRes messageResId: Int,
vararg args: Any,
@StringRes actionLabelResId: Int? = null,
withDismissAction: Boolean = true,
duration: SnackbarDuration = SnackbarDuration.Short,
onResult: ((SnackbarResult) -> Unit)? = null,
dismissNewest: Boolean = false,
) = snackbar(
message = stringResource(messageResId, *args),
actionLabel = actionLabelResId?.let(::stringResource),
withDismissAction = withDismissAction,
duration = duration,
onResult = onResult,
dismissNewest = dismissNewest,
)
fun stringResource(@StringRes resId: Int) = appContext.getString(resId)
fun stringResource(@StringRes resId: Int, vararg args: Any) = appContext.getString(resId, *args)
}

View File

@@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.services
import android.util.Log
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -49,9 +50,10 @@ object AppUpdateChecker {
.onSuccess { _state.value = State.Ready(it) }
.onFailure { error ->
EventLogger.logEvent(
"检查更新失败: ${error.message ?: error.javaClass.simpleName}",
Log.WARN,
error
R.string.main_update_check_failed,
error.message ?: error.javaClass.simpleName,
level = Log.WARN,
error = error,
)
_state.value = State.Error
}

View File

@@ -30,7 +30,7 @@ internal class ConnectionController(
suspend fun disconnectAdbConnection(
clearQuickOnlineForTarget: ConnectionTarget? = state.adbSession.currentTarget,
cause: DisconnectCause = DisconnectCause.Unknown,
statusLine: String = "未连接",
statusLine: String = "Disconnected",
): ConnectionDisconnectResult {
stateStore.markDisconnected(cause = cause, statusLine = statusLine)
AppRuntime.currentConnectionTarget = null
@@ -155,7 +155,7 @@ internal class ConnectionController(
fun markScrcpyStarted() {
stateStore.updateSession {
it.copy(statusLine = "scrcpy 运行中")
it.copy(statusLine = "scrcpy running")
}
}

View File

@@ -57,8 +57,8 @@ internal class ConnectionStateStore {
fun markDisconnected(
cause: DisconnectCause,
statusLine: String = "未连接",
connectedDeviceLabel: String = "未连接",
statusLine: String = "Disconnected",
connectedDeviceLabel: String = "Disconnected",
) {
update {
it.copy(
@@ -75,7 +75,7 @@ internal class ConnectionStateStore {
fun markConnectionFailed(message: String?) {
update {
it.copy(
adbSession = it.adbSession.copy(statusLine = "ADB 连接失败"),
adbSession = it.adbSession.copy(statusLine = "ADB connection failed"),
disconnectCause = DisconnectCause.ConnectFailed,
lastError = message,
)

View File

@@ -29,6 +29,10 @@ internal class DeviceAdbAutoReconnectManager(
onReconnectSuccess(host, port)
},
onReconnectFailure = onReconnectFailure,
shouldAutoReconnect = {
stateStore.state.value.disconnectCause != DisconnectCause.User &&
stateStore.state.value.disconnectCause != DisconnectCause.KillAdbOnClose
},
)
}

View File

@@ -23,6 +23,7 @@ internal class DeviceAdbBackgroundRunner : Closeable {
reconnect: suspend (host: String, port: Int) -> Unit,
onReconnectSuccess: suspend (host: String, port: Int) -> Unit,
onReconnectFailure: suspend (Throwable) -> Unit,
shouldAutoReconnect: () -> Boolean = { true },
) = withContext(dispatcher) {
val target = sessionState().currentTarget ?: return@withContext
val host = target.host
@@ -39,6 +40,7 @@ internal class DeviceAdbBackgroundRunner : Closeable {
keepAliveCheck(host, port)
}.getOrElse { false }
if (alive) continue
if (!shouldAutoReconnect()) break
try {
reconnect(host, port)

View File

@@ -8,15 +8,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
@Parcelize
internal data class DeviceAdbSessionState(
val isConnected: Boolean = false,
val statusLine: String = "未连接",
val statusLine: String = "Disconnected",
val currentTarget: ConnectionTarget? = null,
val connectedDeviceLabel: String = "未连接",
val connectedDeviceLabel: String = "Disconnected",
val isQuickConnected: Boolean = false,
val connectedScrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
val audioForwardingSupported: Boolean = true,
@@ -28,8 +29,9 @@ internal class DeviceAdbConnectionCoordinator(
) {
suspend fun connectWithTimeout(host: String, port: Int, timeoutMs: Long) {
withContext(Dispatchers.IO) {
val resolved = resolveHost(host)
withTimeout(timeoutMs) {
adbService.connect(host, port)
adbService.connect(resolved, port)
}
}
}
@@ -40,6 +42,15 @@ internal class DeviceAdbConnectionCoordinator(
}
}
private fun resolveHost(host: String): String {
val bareHost = if (host.startsWith('[') && host.endsWith(']'))
host.substring(1, host.length - 1)
else
host
return runCatching { InetAddress.getByName(bareHost).hostAddress }
.getOrDefault(host)
}
suspend fun isConnected(timeoutMs: Long): Boolean {
return withContext(Dispatchers.IO) {
withTimeout(timeoutMs) {
@@ -50,9 +61,10 @@ internal class DeviceAdbConnectionCoordinator(
suspend fun probeTcpReachable(host: String, port: Int, timeoutMs: Int): Boolean {
return withContext(Dispatchers.IO) {
val resolved = resolveHost(host)
runCatching {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), timeoutMs)
socket.connect(InetSocketAddress(resolved, port), timeoutMs)
true
}
}.getOrDefault(false)
@@ -89,7 +101,8 @@ internal class DeviceAdbConnectionCoordinator(
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean {
return withContext(Dispatchers.IO) {
adbService.pair(host, port, pairingCode)
val resolved = resolveHost(host)
adbService.pair(resolved, port, pairingCode)
}
}

View File

@@ -1,12 +1,43 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
sealed interface EventLogMessage {
data class Raw(val text: String) : EventLogMessage
data class Resource(
@param:StringRes val resId: Int,
val args: List<Any> = emptyList(),
) : EventLogMessage
}
data class EventLogEntry(
val timestamp: String,
val message: EventLogMessage,
) {
fun render(context: Context): String {
return "[$timestamp] ${message.render(context)}"
}
}
fun EventLogMessage.render(context: Context): String {
return when (this) {
is EventLogMessage.Raw -> text
is EventLogMessage.Resource -> context.getString(
resId,
*args.map { arg ->
if (arg is EventLogMessage) arg.render(context) else arg
}.toTypedArray(),
)
}
}
/**
* Global singleton for event logging.
*
@@ -18,12 +49,12 @@ object EventLogger {
const val MAX_LINES = 512
private val _eventLog: SnapshotStateList<String> = mutableStateListOf()
private val _eventLog: SnapshotStateList<EventLogEntry> = mutableStateListOf()
/**
* Read-only access to the event log list.
*/
val eventLog: List<String> get() = _eventLog
val eventLog: List<EventLogEntry> get() = _eventLog
/**
* Log an event with timestamp and optional error.
@@ -33,27 +64,42 @@ object EventLogger {
* @param error Optional throwable for error logging
*/
fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
logEvent(EventLogMessage.Raw(message), level, error)
}
fun logEvent(
@StringRes messageResId: Int,
vararg args: Any,
level: Int = Log.INFO,
error: Throwable? = null,
) {
logEvent(EventLogMessage.Resource(messageResId, args.toList()), level, error)
}
fun logEvent(message: EventLogMessage, level: Int = Log.INFO, error: Throwable? = null) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
_eventLog.add(0, "[$timestamp] $message")
_eventLog.add(0, EventLogEntry(timestamp, message))
// Rotate logs if exceeds max size
if (_eventLog.size > MAX_LINES) {
_eventLog.removeRange(MAX_LINES, _eventLog.size)
}
val logcatMessage = message.render(AppRuntime.context)
// Log to Android logcat
when (level) {
Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error)
else Log.e(LOG_TAG, message)
Log.ERROR -> if (error != null) Log.e(LOG_TAG, logcatMessage, error)
else Log.e(LOG_TAG, logcatMessage)
Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error)
else Log.w(LOG_TAG, message)
Log.WARN -> if (error != null) Log.w(LOG_TAG, logcatMessage, error)
else Log.w(LOG_TAG, logcatMessage)
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error)
else Log.d(LOG_TAG, message)
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, logcatMessage, error)
else Log.d(LOG_TAG, logcatMessage)
else -> if (error != null) Log.i(LOG_TAG, message, error)
else Log.i(LOG_TAG, message)
else -> if (error != null) Log.i(LOG_TAG, logcatMessage, error)
else Log.i(LOG_TAG, logcatMessage)
}
}

View File

@@ -6,6 +6,7 @@ import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import kotlinx.coroutines.Dispatchers
@@ -117,10 +118,10 @@ object FileManagerService {
): String = withContext(Dispatchers.IO) {
NativeAdbService.ensureConnectionResponsive()
val fileName = queryDisplayName(context.contentResolver, uri)
?: throw IOException("无法读取文件名")
?: throw IOException(AppRuntime.stringResource(R.string.fm_exception_cannot_read_filename))
val remotePath = joinRemotePath(remoteDirectory, fileName)
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "无法读取选择的文件" }
requireNotNull(input) { AppRuntime.stringResource(R.string.fm_exception_cannot_read_selected_file) }
NativeAdbService.push(input, remotePath)
}
return@withContext remotePath
@@ -131,7 +132,7 @@ object FileManagerService {
directoryName: String,
): String = withContext(Dispatchers.IO) {
val sanitizedName = directoryName.trim().trim('/').takeIf { it.isNotBlank() }
?: throw IOException("文件夹名称不能为空")
?: throw IOException(AppRuntime.stringResource(R.string.fm_exception_folder_name_empty))
val remotePath = joinRemotePath(parentDirectory, sanitizedName)
NativeAdbService.ensureConnectionResponsive()
NativeAdbService.shell("mkdir -p ${quoteShellArg(remotePath)}")
@@ -192,7 +193,7 @@ object FileManagerService {
)
val target = createUniqueDocument(context, rootDocument, fileName, guessMimeType(fileName))
context.contentResolver.openOutputStream(target, "w").use { output ->
requireNotNull(output) { "无法写入目标文件" }
requireNotNull(output) { AppRuntime.stringResource(R.string.fm_exception_cannot_write_target) }
NativeAdbService.pull(remotePath, output)
}
}
@@ -221,54 +222,49 @@ object FileManagerService {
val target =
createUniqueDocument(context, parentDocument, fileName, guessMimeType(fileName))
context.contentResolver.openOutputStream(target, "w").use { output ->
requireNotNull(output) { "无法写入目标文件" }
requireNotNull(output) { AppRuntime.stringResource(R.string.fm_exception_cannot_write_target) }
NativeAdbService.pull(joinRemotePath(snapshot.remoteRootPath, relativePath), output)
}
}
}
fun formatSummary(entry: RemoteFileEntry): String {
val timeText = entry.modifiedAt?.format(displayTimeFormatter) ?: "未知时间"
return if (entry.isDirectory || entry.sizeBytes == null) {
timeText
} else {
"$timeText ${formatSize(entry.sizeBytes)}"
}
val timeText = entry.modifiedAt?.format(displayTimeFormatter)
?: AppRuntime.stringResource(R.string.fm_unknown)
return if (entry.isDirectory || entry.sizeBytes == null) timeText
else "$timeText ${formatSize(entry.sizeBytes)}"
}
fun formatStatDetails(
stat: RemoteFileStat,
directorySnapshot: DirectoryDownloadSnapshot? = null,
): String {
val lines = mutableListOf<String>()
val resolvedSizeBytes = directorySnapshot?.totalBytes ?: stat.sizeBytes
lines += "路径: ${stat.path}"
stat.typeLabel?.let { lines += "类型: $it" }
resolvedSizeBytes?.let { lines += "大小: ${formatSize(it)} ($it B)" }
stat.blocks?.let { lines += "块数: $it" }
stat.ioBlockBytes?.let { lines += "IO 块大小: ${it}B" }
stat.inode?.let { lines += "inode: $it" }
stat.hardLinks?.let { lines += "硬链接: $it" }
if (!stat.octalMode.isNullOrBlank() || !stat.permissions.isNullOrBlank()) {
lines += "权限: ${stat.octalMode ?: "?"}/${stat.permissions ?: "?"}"
}
if (stat.uid != null || stat.uidName != null) {
lines += "Uid: ${stat.uid ?: "?"}/${stat.uidName ?: "?"}"
}
if (stat.gid != null || stat.gidName != null) {
lines += "Gid: ${stat.gid ?: "?"}/${stat.gidName ?: "?"}"
}
stat.device?.let { lines += "设备: $it" }
stat.deviceType?.let { lines += "设备类型: $it" }
stat.accessTime?.let { lines += "访问时间: $it" }
stat.modifyTime?.let { lines += "修改时间: $it" }
stat.changeTime?.let { lines += "变更时间: $it" }
stat.symlinkTarget?.let { lines += "链接目标: $it" }
val entries = mutableListOf<Pair<String, String>>()
entries.add(AppRuntime.stringResource(R.string.fm_stat_path) to stat.path)
stat.typeLabel?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_type) to it) }
(directorySnapshot?.totalBytes ?: stat.sizeBytes)
?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_size) to "${formatSize(it)} ($it B)") }
stat.blocks?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_blocks) to "$it") }
stat.ioBlockBytes?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_io_block_size) to "${it}B") }
stat.inode?.let { entries.add("inode" to "$it") }
stat.hardLinks?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_hard_links) to "$it") }
if (!stat.octalMode.isNullOrBlank() || !stat.permissions.isNullOrBlank())
entries.add(AppRuntime.stringResource(R.string.fm_stat_permissions) to "${stat.octalMode ?: "?"}/${stat.permissions ?: "?"}")
if (stat.uid != null || stat.uidName != null)
entries.add("Uid" to "${stat.uid ?: "?"}/${stat.uidName ?: "?"}")
if (stat.gid != null || stat.gidName != null)
entries.add("Gid" to "${stat.gid ?: "?"}/${stat.gidName ?: "?"}")
stat.device?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_device) to "$it") }
stat.deviceType?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_device_type) to "$it") }
stat.accessTime?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_access_time) to "$it") }
stat.modifyTime?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_modify_time) to "$it") }
stat.changeTime?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_change_time) to "$it") }
stat.symlinkTarget?.let { entries.add(AppRuntime.stringResource(R.string.fm_stat_link_target) to "$it") }
directorySnapshot?.let {
lines += "目录数: ${it.directories.size}"
lines += "文件数: ${it.files.size}"
entries.add(AppRuntime.stringResource(R.string.fm_stat_directories) to "${it.directories.size}")
entries.add(AppRuntime.stringResource(R.string.fm_stat_files) to "${it.files.size}")
}
return lines.joinToString(separator = "\n")
return entries.joinToString(separator = "\n") { "${it.first}: ${it.second}" }
}
fun formatSize(bytes: Long): String {
@@ -506,13 +502,13 @@ object FileManagerService {
}
private fun ensureDirectoryExists(directory: File?) {
requireNotNull(directory) { "目录不存在" }
requireNotNull(directory) { AppRuntime.stringResource(R.string.fm_exception_directory_not_found) }
if (directory.exists()) {
require(directory.isDirectory) { "目标不是文件夹: ${directory.absolutePath}" }
require(directory.isDirectory) { "${AppRuntime.stringResource(R.string.fm_exception_not_directory)}: ${directory.absolutePath}" }
return
}
if (!directory.mkdirs()) {
throw IOException("无法创建目录: ${directory.absolutePath}")
throw IOException("${AppRuntime.stringResource(R.string.fm_exception_cannot_create_dir)}: ${directory.absolutePath}")
}
}
@@ -550,7 +546,8 @@ object FileManagerService {
): Uri {
var name = baseName
var index = 1
while (findChildDocument(
while (
findChildDocument(
context,
parentDocument,
name,
@@ -565,7 +562,7 @@ object FileManagerService {
parentDocument,
DocumentsContract.Document.MIME_TYPE_DIR,
name,
) ?: throw IOException("无法创建目录: $name")
) ?: throw IOException("${AppRuntime.stringResource(R.string.fm_exception_cannot_create_dir)}: $name")
}
private fun createUniqueDocument(
@@ -579,11 +576,9 @@ object FileManagerService {
var candidate = baseName
var index = 1
while (findChildDocument(context, parentDocument, candidate, mimeType) != null) {
candidate = if (extension.isBlank() || fileBase == baseName) {
"$baseName ($index)"
} else {
"$fileBase ($index).$extension"
}
candidate =
if (extension.isBlank() || fileBase == baseName) "$baseName ($index)"
else "$fileBase ($index).$extension"
index++
}
return DocumentsContract.createDocument(
@@ -591,7 +586,7 @@ object FileManagerService {
parentDocument,
mimeType,
candidate,
) ?: throw IOException("无法创建文件: $candidate")
) ?: throw IOException("${AppRuntime.stringResource(R.string.fm_exception_cannot_create_file)}: $candidate")
}
private fun ensureTreeDirectory(
@@ -600,9 +595,7 @@ object FileManagerService {
relativePath: String
): Uri {
var current = rootDocument
if (relativePath.isBlank()) {
return current
}
if (relativePath.isBlank()) return current
relativePath.split('/')
.filter { it.isNotBlank() }
.forEach { segment ->
@@ -617,7 +610,7 @@ object FileManagerService {
current,
DocumentsContract.Document.MIME_TYPE_DIR,
segment,
) ?: throw IOException("无法创建目录: $segment")
) ?: throw IOException("${AppRuntime.stringResource(R.string.fm_exception_cannot_create_dir)}: $segment")
}
return current
}
@@ -649,7 +642,7 @@ object FileManagerService {
if (childDisplayName == displayName && childMimeType == mimeType) {
return DocumentsContract.buildDocumentUriUsingTree(
parentDocument,
cursor.getString(0)
cursor.getString(0),
)
}
}
@@ -722,11 +715,8 @@ class DirectorySnapshotSession private constructor(
private fun relativePath(root: String, fullPath: String): String {
val prefix = root.trimEnd('/') + "/"
return if (fullPath.startsWith(prefix)) {
fullPath.removePrefix(prefix)
} else {
fullPath
}
return if (fullPath.startsWith(prefix)) fullPath.removePrefix(prefix)
else fullPath
}
}
}
@@ -771,9 +761,9 @@ private class InteractiveShellSession private constructor(
while (true) {
val count = stream.inputStream.read(buffer)
if (count <= 0) {
throw IOException("远端 shell 已关闭")
}
if (count <= 0)
throw IOException(AppRuntime.stringResource(R.string.fm_exception_remote_shell_closed))
builder.append(String(buffer, 0, count, StandardCharsets.UTF_8))
val markerStart = builder.indexOf(markerPrefix)
if (markerStart >= 0) {
@@ -784,7 +774,7 @@ private class InteractiveShellSession private constructor(
val status = statusText.toIntOrNull() ?: 1
val output = builder.substring(0, markerStart).trimEnd('\r', '\n')
if (status != 0) {
throw IOException(output.ifBlank { "命令执行失败 ($status)" })
throw IOException(output.ifBlank { "${AppRuntime.stringResource(R.string.fm_exception_command_failed)} ($status)" })
}
return output
}

View File

@@ -1,12 +1,14 @@
package io.github.miuzarte.scrcpyforandroid.services
import androidx.annotation.StringRes
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import java.time.LocalDateTime
object RecordFilenameTemplate {
data class Entry(
val value: String,
val description: String?,
@field:StringRes val descriptionResId: Int?,
val isTemplate: Boolean,
)
@@ -45,28 +47,28 @@ object RecordFilenameTemplate {
get() = listOf(
Entry("-", null, false),
Entry("_", null, false),
Entry($$"${YYYY}", "四位年份,例如 2026", true),
Entry($$"${YY}", "两位年份,例如 26", true),
Entry($$"${MM}", "月份,两位,例如 09", true),
Entry($$"${M}", "月份,一位或两位,例如 9", true),
Entry($$"${DD}", "日期,两位,例如 19", true),
Entry($$"${D}", "日期,一位或两位,例如 9", true),
Entry($$"${HH}", "24 小时制小时,两位,例如 23", true),
Entry($$"${H}", "24 小时制小时,一位或两位,例如 9", true),
Entry($$"${hh}", "12 小时制小时,两位,例如 11", true),
Entry($$"${h}", "12 小时制小时,一位或两位,例如 9", true),
Entry($$"${mm}", "分钟,两位,例如 09", true),
Entry($$"${m}", "分钟,一位或两位,例如 9", true),
Entry($$"${SS}", "秒,两位,例如 09", true),
Entry($$"${S}", "秒,一位或两位,例如 9", true),
Entry($$"${timestamp}", "秒级时间戳,例如 1776952809", true),
Entry($$"${deviceName}", "设备名", true),
Entry($$"${deviceIp}", "设备 IP", true),
Entry($$"${devicePort}", "设备端口", true),
Entry($$"${videoCodec}", "视频串流编码(非文件实际编码)", true),
Entry($$"${audioCodec}", "音频串流编码(非文件实际编码)", true),
Entry($$"${width}", "视频宽度", true),
Entry($$"${height}", "视频高度", true),
Entry($$"${YYYY}", R.string.record_desc_yyyy, true),
Entry($$"${YY}", R.string.record_desc_yy, true),
Entry($$"${MM}", R.string.record_desc_MM, true),
Entry($$"${M}", R.string.record_desc_M, true),
Entry($$"${DD}", R.string.record_desc_dd, true),
Entry($$"${D}", R.string.record_desc_d, true),
Entry($$"${HH}", R.string.record_desc_HH, true),
Entry($$"${H}", R.string.record_desc_H, true),
Entry($$"${hh}", R.string.record_desc_hh, true),
Entry($$"${h}", R.string.record_desc_h, true),
Entry($$"${mm}", R.string.record_desc_mm, true),
Entry($$"${m}", R.string.record_desc_m, true),
Entry($$"${SS}", R.string.record_desc_ss, true),
Entry($$"${S}", R.string.record_desc_s, true),
Entry($$"${timestamp}", R.string.record_desc_timestamp, true),
Entry($$"${deviceName}", R.string.record_desc_device_name, true),
Entry($$"${deviceIp}", R.string.record_desc_device_ip, true),
Entry($$"${devicePort}", R.string.record_desc_device_port, true),
Entry($$"${videoCodec}", R.string.record_desc_video_codec, true),
Entry($$"${audioCodec}", R.string.record_desc_audio_codec, true),
Entry($$"${width}", R.string.record_desc_width, true),
Entry($$"${height}", R.string.record_desc_height, true),
Entry(".mp4", null, false),
Entry(".m4a", null, false),
Entry(".aac", null, false),

View File

@@ -17,21 +17,49 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") {
stringPreferencesKey("rsa_public_key_x509"),
"",
)
val IMPORTED_PRIVATE_KEY = Pair(
stringPreferencesKey("imported_private_key"),
"",
)
val IMPORTED_PRIVATE_KEY_FILE_NAME = Pair(
stringPreferencesKey("imported_private_key_file_name"),
"",
)
val IMPORTED_PUBLIC_KEY_X509 = Pair(
stringPreferencesKey("imported_public_key_x509"),
"",
)
val IMPORTED_PUBLIC_KEY_FILE_NAME = Pair(
stringPreferencesKey("imported_public_key_file_name"),
"",
)
}
val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
val importedPrivateKey by setting(IMPORTED_PRIVATE_KEY)
val importedPrivateKeyFileName by setting(IMPORTED_PRIVATE_KEY_FILE_NAME)
val importedPublicKeyX509 by setting(IMPORTED_PUBLIC_KEY_X509)
val importedPublicKeyFileName by setting(IMPORTED_PUBLIC_KEY_FILE_NAME)
@Parcelize
data class Bundle(
val rsaPrivateKey: String,
val rsaPublicKeyX509: String,
val importedPrivateKey: String,
val importedPrivateKeyFileName: String,
val importedPublicKeyX509: String,
val importedPublicKeyFileName: String,
) : Parcelable {
}
private val bundleFields = arrayOf<BundleField<Bundle>>(
bundleField(RSA_PRIVATE_KEY) { it.rsaPrivateKey },
bundleField(RSA_PUBLIC_KEY_X509) { it.rsaPublicKeyX509 },
bundleField(IMPORTED_PRIVATE_KEY) { it.importedPrivateKey },
bundleField(IMPORTED_PRIVATE_KEY_FILE_NAME) { it.importedPrivateKeyFileName },
bundleField(IMPORTED_PUBLIC_KEY_X509) { it.importedPublicKeyX509 },
bundleField(IMPORTED_PUBLIC_KEY_FILE_NAME) { it.importedPublicKeyFileName },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
@@ -39,6 +67,10 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") {
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
rsaPrivateKey = preferences.read(RSA_PRIVATE_KEY),
rsaPublicKeyX509 = preferences.read(RSA_PUBLIC_KEY_X509),
importedPrivateKey = preferences.read(IMPORTED_PRIVATE_KEY),
importedPrivateKeyFileName = preferences.read(IMPORTED_PRIVATE_KEY_FILE_NAME),
importedPublicKeyX509 = preferences.read(IMPORTED_PUBLIC_KEY_X509),
importedPublicKeyFileName = preferences.read(IMPORTED_PUBLIC_KEY_FILE_NAME),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)

View File

@@ -2,36 +2,49 @@ package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.os.Parcelable
import androidx.annotation.StringRes
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
class AppSettings(context: Context) : Settings(context, "AppSettings") {
object ThemeModes {
data class Option(
@field:StringRes val labelResId: Int,
val mode: ColorSchemeMode,
)
val baseOptions = listOf(
Option(R.string.theme_follow_system, ColorSchemeMode.System),
Option(R.string.theme_light, ColorSchemeMode.Light),
Option(R.string.theme_dark, ColorSchemeMode.Dark),
)
}
enum class FullscreenVirtualButtonDock(
val rawValue: String,
val isFixed: Boolean,
val directionLabel: String,
@field:StringRes val directionLabelResId: Int,
) {
FOLLOW_TOP("FOLLOW_TOP", false, "上方"),
FOLLOW_BOTTOM("FOLLOW_BOTTOM", false, "下方"),
FOLLOW_LEFT("FOLLOW_LEFT", false, "左侧"),
FOLLOW_RIGHT("FOLLOW_RIGHT", false, "右侧"),
FIXED_TOP("FIXED_TOP", true, "上方"),
FIXED_BOTTOM("FIXED_BOTTOM", true, "下方"),
FIXED_LEFT("FIXED_LEFT", true, "左侧"),
FIXED_RIGHT("FIXED_RIGHT", true, "右侧");
FOLLOW_TOP("FOLLOW_TOP", false, R.string.dock_top),
FOLLOW_BOTTOM("FOLLOW_BOTTOM", false, R.string.dock_bottom),
FOLLOW_LEFT("FOLLOW_LEFT", false, R.string.dock_left),
FOLLOW_RIGHT("FOLLOW_RIGHT", false, R.string.dock_right),
FIXED_TOP("FIXED_TOP", true, R.string.dock_top),
FIXED_BOTTOM("FIXED_BOTTOM", true, R.string.dock_bottom),
FIXED_LEFT("FIXED_LEFT", true, R.string.dock_left),
FIXED_RIGHT("FIXED_RIGHT", true, R.string.dock_right);
fun toStoredValue(): String = rawValue
val summary: String
get() = "${if (!isFixed) "跟随" else "固定"}显示在屏幕$directionLabel"
val modeIndex: Int
get() = if (!isFixed) 0 else 1
@@ -44,8 +57,16 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
}
companion object {
val modeItems = listOf("跟随", "固定")
val directionItems = listOf("上方", "下方", "左侧", "右侧")
val modeItemsResIds = listOf(
R.string.dock_follow,
R.string.dock_fixed,
)
val directionItemsResIds = listOf(
R.string.dock_top,
R.string.dock_bottom,
R.string.dock_left,
R.string.dock_right,
)
fun fromBundle(bundle: Bundle) =
entries.firstOrNull { it.rawValue == bundle.fullscreenVirtualButtonDock }
@@ -179,7 +200,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
)
val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
booleanPreferencesKey("preview_virtual_button_show_text"),
true,
false,
)
val VIRTUAL_BUTTONS_LAYOUT = Pair(
stringPreferencesKey("virtual_buttons_layout"),

View File

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.KeyInjectMode
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat
@@ -27,7 +28,8 @@ import kotlinx.parcelize.Parcelize
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
companion object {
const val GLOBAL_PROFILE_ID = "global"
const val GLOBAL_PROFILE_NAME = "全局"
val GLOBAL_PROFILE_NAME_RES_ID = R.string.text_global
val NEW_PROFILE_NAME_RES_ID = R.string.profile_new_name
val CROP = Pair(
stringPreferencesKey("crop"),

View File

@@ -2,8 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_ID
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_NAME
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
@@ -98,7 +97,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
}
suspend fun renameProfile(id: String, requestedName: String): Profile? {
if (id == GLOBAL_PROFILE_ID) return null
if (id == ScrcpyOptions.GLOBAL_PROFILE_ID) return null
val current = loadState()
val existing = current.profiles.firstOrNull { it.id == id } ?: return null
val updated = existing.copy(
@@ -116,7 +115,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
}
suspend fun deleteProfile(id: String): Boolean {
if (id == GLOBAL_PROFILE_ID) return false
if (id == ScrcpyOptions.GLOBAL_PROFILE_ID) return false
val current = loadState()
if (current.profiles.none { it.id == id }) return false
val next = normalizeState(
@@ -150,8 +149,11 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
requestedName: String,
excludeId: String? = null,
): String {
val baseName = requestedName.trim().ifBlank { "配置" }
if (baseName == GLOBAL_PROFILE_NAME) return ensureUniqueName(state, "配置", excludeId)
val textGlobal = AppRuntime.stringResource(ScrcpyOptions.GLOBAL_PROFILE_NAME_RES_ID)
val textNewProfile = AppRuntime.stringResource(ScrcpyOptions.NEW_PROFILE_NAME_RES_ID)
val baseName = requestedName.trim().ifBlank { textNewProfile }
if (baseName == textGlobal)
return ensureUniqueName(state, textNewProfile, excludeId)
val existingNames = state.profiles
.filterNot { it.id == excludeId }
.map { it.name }
@@ -166,11 +168,13 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
}
private fun normalizeState(state: State): State {
val global = state.profiles.firstOrNull { it.id == GLOBAL_PROFILE_ID }
?.copy(name = GLOBAL_PROFILE_NAME, isBuiltinGlobal = true)
val textGlobal = AppRuntime.stringResource(ScrcpyOptions.GLOBAL_PROFILE_NAME_RES_ID)
val textNewProfile = AppRuntime.stringResource(ScrcpyOptions.NEW_PROFILE_NAME_RES_ID)
val global = state.profiles.firstOrNull { it.id == ScrcpyOptions.GLOBAL_PROFILE_ID }
?.copy(name = textGlobal, isBuiltinGlobal = true)
?: Profile(
id = GLOBAL_PROFILE_ID,
name = GLOBAL_PROFILE_NAME,
id = ScrcpyOptions.GLOBAL_PROFILE_ID,
name = textGlobal,
bundle = ScrcpyOptions.defaultBundle(),
isBuiltinGlobal = true,
)
@@ -178,11 +182,11 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
val others = buildList {
state.profiles
.asSequence()
.filterNot { it.id == GLOBAL_PROFILE_ID }
.filterNot { it.id == ScrcpyOptions.GLOBAL_PROFILE_ID }
.forEach { profile ->
val baseName = profile.name.trim().ifBlank { "配置" }
.takeUnless { it == GLOBAL_PROFILE_NAME }
?: "配置"
val baseName = profile.name.trim().ifBlank { textNewProfile }
.takeUnless { it == textGlobal }
?: textNewProfile
var normalizedName = baseName
if (normalizedName in usedNames) {
var suffix = 1
@@ -216,6 +220,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
}
private fun decodeState(raw: String): State {
val textNewProfile = AppRuntime.stringResource(ScrcpyOptions.NEW_PROFILE_NAME_RES_ID)
if (raw.isBlank()) return State(emptyList())
val array = JSONArray(raw)
val profiles = buildList {
@@ -228,9 +233,9 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
add(
Profile(
id = id,
name = name.ifBlank { "配置" },
name = name.ifBlank { textNewProfile },
bundle = decodeBundle(item.optJSONObject("bundle")),
isBuiltinGlobal = id == GLOBAL_PROFILE_ID,
isBuiltinGlobal = id == ScrcpyOptions.GLOBAL_PROFILE_ID,
)
)
}

View File

@@ -19,6 +19,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
@@ -61,7 +63,7 @@ fun AppListBottomSheet(
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = "刷新$title",
contentDescription = stringResource(R.string.cd_refresh, title),
)
}
},

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Surface
@@ -58,8 +57,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -73,6 +74,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
@@ -82,7 +84,7 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
@@ -119,6 +121,7 @@ import kotlin.math.roundToInt
@Composable
internal fun StatusCard(
// TODO: unused
statusLine: String,
adbConnected: Boolean,
streaming: Boolean,
@@ -130,8 +133,6 @@ internal fun StatusCard(
val appSettingsBundle by appSettings.bundleState.collectAsState()
val themeBaseIndex = appSettingsBundle.themeBaseIndex
val cleanStatusLine = normalizeStatusLine(statusLine)
// 根据应用主题设置决定是否使用深色模式
val isDarkTheme = when (themeBaseIndex) {
0 -> isSystemInDarkTheme() // 跟随系统
@@ -159,7 +160,7 @@ internal fun StatusCard(
}
StatusCardSpec(
big = StatusBigCardSpec(
title = "投屏中 (视频流)",
title = stringResource(R.string.device_status_mirroring),
subtitle = sessionInfo.deviceName,
containerColor = streamCardColor,
titleColor = streamTextColor,
@@ -168,11 +169,11 @@ internal fun StatusCard(
iconTint = streamIconColor,
),
firstSmall = StatusSmallCardSpec(
"分辨率",
stringResource(R.string.device_status_resolution),
"${sessionInfo.width}×${sessionInfo.height}",
),
secondSmall = StatusSmallCardSpec(
"编解码器",
stringResource(R.string.device_status_codec),
sessionInfo.codec?.displayName ?: "null",
),
)
@@ -180,8 +181,8 @@ internal fun StatusCard(
adbConnected -> StatusCardSpec(
big = StatusBigCardSpec(
title = "ADB 已连接",
subtitle = cleanStatusLine,
title = stringResource(R.string.device_status_adb_connected),
subtitle = connectedDeviceLabel,
containerColor = colorScheme.primaryContainer,
titleColor = colorScheme.onPrimaryContainer,
subtitleColor = colorScheme.onPrimaryContainer,
@@ -189,18 +190,18 @@ internal fun StatusCard(
iconTint = colorScheme.primary.copy(alpha = 0.6f),
),
firstSmall = StatusSmallCardSpec(
"当前设备",
stringResource(R.string.device_status_current),
connectedDeviceLabel,
),
secondSmall = StatusSmallCardSpec(
"状态",
"空闲",
stringResource(R.string.label_status),
stringResource(R.string.device_status_idle),
),
)
else -> StatusCardSpec(
big = StatusBigCardSpec(
title = "ADB 未连接",
title = stringResource(R.string.device_status_adb_disconnected),
subtitle = "",
containerColor = colorScheme.secondaryContainer,
titleColor = colorScheme.onSecondaryContainer,
@@ -209,11 +210,11 @@ internal fun StatusCard(
iconTint = colorScheme.onSecondaryContainer.copy(alpha = 0.6f),
),
firstSmall = StatusSmallCardSpec(
"当前设备",
stringResource(R.string.device_status_current),
"N/A",
),
secondSmall = StatusSmallCardSpec(
"状态",
stringResource(R.string.label_status),
"N/A",
),
)
@@ -234,7 +235,7 @@ internal fun PairingCard(
Card {
ArrowPreference(
title = "使用配对码配对设备",
title = stringResource(R.string.device_pairing_title),
onClick = {
showPairDialog.value = true
holdDownState.value = true
@@ -435,11 +436,11 @@ internal fun PreviewCard(
modifier = Modifier.alpha(alpha),
) {
Icon(
Icons.Rounded.Fullscreen,
contentDescription = "全屏",
imageVector = Icons.Rounded.Fullscreen,
contentDescription = stringResource(R.string.cd_fullscreen),
)
Spacer(Modifier.width(UiSpacing.SectionTitleBottom))
Text("全屏")
Text(stringResource(R.string.button_fullscreen))
}
}
}
@@ -506,8 +507,6 @@ internal fun ConfigPanel(
) {
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val snackbar = LocalSnackbarController.current
val sessionStarted = sessionInfo != null
val activeProfileIdLatest by rememberUpdatedState(activeProfileId)
@@ -515,28 +514,25 @@ internal fun ConfigPanel(
var soBundle by rememberSaveable(activeProfileId, activeBundle) { mutableStateOf(activeBundle) }
val soBundleLatest by rememberUpdatedState(soBundle)
LaunchedEffect(activeProfileId, activeBundle) {
if (soBundle != activeBundle) {
if (soBundle != activeBundle)
soBundle = activeBundle
}
}
LaunchedEffect(soBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (soBundle != activeBundleLatest) {
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID) {
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID)
scrcpyOptions.saveBundle(soBundle)
} else {
else
Storage.scrcpyProfiles.updateBundle(activeProfileIdLatest, soBundle)
}
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID) {
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID)
scrcpyOptions.saveBundle(soBundleLatest)
} else {
else
Storage.scrcpyProfiles.updateBundle(activeProfileIdLatest, soBundleLatest)
}
}
}
}
@@ -562,31 +558,36 @@ internal fun ConfigPanel(
Card {
if (!hideSimpleConfigItems) {
SwitchPreference(
title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)",
title = stringResource(R.string.device_config_audio_forwarding),
summary = stringResource(R.string.device_config_audio_forwarding_desc),
checked = soBundle.audio,
onCheckedChange = { soBundle = soBundle.copy(audio = it) },
onCheckedChange = {
soBundle = soBundle.copy(
audio = it
)
},
enabled = !sessionStarted
&& audioForwardingSupported,
)
OverlayDropdownPreference(
title = "音频编码",
title = stringResource(R.string.device_config_audio_codec),
summary = "--audio-codec",
items = audioCodecItems,
selectedIndex = audioCodecIndex,
onSelectedIndexChange = {
val codec = Codec.AUDIO[it]
soBundle = soBundle.copy(audioCodec = codec.string)
if (codec == Codec.FLAC) {
snackbar.show("注意FLAC编解码会引入较大的延迟")
}
soBundle = soBundle.copy(
audioCodec = codec.string
)
if (codec == Codec.FLAC)
AppRuntime.snackbar(R.string.device_config_audio_codec_note)
},
enabled = !sessionStarted && soBundle.audio,
)
AnimatedVisibility(audioBitRateVisibility) {
SuperSlider(
title = "音频码率",
title = stringResource(R.string.device_config_audio_bitrate),
summary = "--audio-bit-rate",
value = ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000)
@@ -601,7 +602,7 @@ internal fun ConfigPanel(
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
unit = "Kbps",
zeroStateText = "默认",
zeroStateText = stringResource(R.string.text_default),
displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue =
if (soBundle.audioBitRate <= 0) ""
@@ -618,22 +619,22 @@ internal fun ConfigPanel(
}
OverlayDropdownPreference(
title = "视频编码",
title = stringResource(R.string.device_config_video_codec),
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = {
val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) {
snackbar.show("注意绝大部分设备不支持AV1硬件编码")
}
soBundle = soBundle.copy(
videoCodec = codec.string
)
if (codec == Codec.AV1)
AppRuntime.snackbar(R.string.device_config_video_codec_note)
},
enabled = !sessionStarted,
)
@SuppressLint("DefaultLocale")
SuperSlider(
title = "视频码率",
title = stringResource(R.string.device_config_video_bitrate),
summary = "--video-bit-rate",
value = soBundle.videoBitRate / 1_000_000f,
onValueChange = { mbps ->
@@ -642,13 +643,13 @@ internal fun ConfigPanel(
)
},
valueRange = 0f..40f,
steps = 400 - 1,
steps = 400 - 0 - 1,
unit = "Mbps",
zeroStateText = "默认",
displayFormatter = { String.format("%.1f", it) },
zeroStateText = stringResource(R.string.text_default),
displayFormatter = { "%.1f".format(it) },
inputInitialValue =
if (soBundle.videoBitRate <= 0) ""
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
else "%.1f".format(soBundle.videoBitRate / 1_000_000f),
inputFilter = { text ->
var dotUsed = false
text.filter { ch ->
@@ -678,8 +679,11 @@ internal fun ConfigPanel(
}
ArrowPreference(
title = if (!hideSimpleConfigItems) "更多参数" else "所有参数",
summary = "所有 scrcpy 参数",
title = stringResource(
if (!hideSimpleConfigItems) R.string.device_config_more_params
else R.string.device_config_all_params
),
summary = stringResource(R.string.device_config_all_scrcpy_params),
endActions = {
Text(
text = advancedEndActionText,
@@ -692,7 +696,7 @@ internal fun ConfigPanel(
)
ArrowPreference(
title = "所有应用",
title = stringResource(R.string.bottomsheet_all_apps),
endActions = {
Text(
text = allAppsEndActionText,
@@ -704,7 +708,7 @@ internal fun ConfigPanel(
enabled = !busy && !adbConnecting,
)
ArrowPreference(
title = "最近任务",
title = stringResource(R.string.bottomsheet_recent_tasks),
endActions = {
Text(
text = recentTasksEndActionText,
@@ -732,7 +736,7 @@ internal fun ConfigPanel(
@Composable
fun DisconnectButton() {
if (isQuickConnected) TextButton(
text = "断开",
text = stringResource(R.string.button_disconnect),
onClick = {
onStartStopHaptic()
onDisconnect()
@@ -745,7 +749,7 @@ internal fun ConfigPanel(
@Composable
fun MainButton() {
if (!sessionStarted) TextButton(
text = "启动",
text = stringResource(R.string.button_start),
onClick = {
onStartStopHaptic()
onStart()
@@ -755,7 +759,7 @@ internal fun ConfigPanel(
colors = ButtonDefaults.textButtonColorsPrimary(),
)
if (sessionStarted) TextButton(
text = "停止",
text = stringResource(R.string.button_stop),
onClick = {
onStartStopHaptic()
onStop()
@@ -768,7 +772,7 @@ internal fun ConfigPanel(
@Composable
fun FullscreenButton() {
if (showFullscreenAction) TextButton(
text = "全屏",
text = stringResource(R.string.button_fullscreen),
onClick = {
onStartStopHaptic()
onOpenFullscreen()
@@ -840,8 +844,8 @@ private fun PairingDialog(
OverlayDialog(
show = showDialog,
title = "使用配对码配对设备",
summary = "使用六位数的配对码配对新设备",
title = stringResource(R.string.device_pairing_title),
summary = stringResource(R.string.device_pairing_desc),
defaultWindowInsetsPadding = false,
onDismissRequest = {
onDismissRequest()
@@ -856,7 +860,7 @@ private fun PairingDialog(
TextField(
value = host,
onValueChange = { host = it },
label = "IP 地址",
label = stringResource(R.string.label_ip_address),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -870,7 +874,7 @@ private fun PairingDialog(
TextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = "端口",
label = stringResource(R.string.label_port),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -884,7 +888,7 @@ private fun PairingDialog(
TextField(
value = code,
onValueChange = { code = it },
label = "WLAN 配对码",
label = stringResource(R.string.label_wlan_pairing_code),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -903,7 +907,10 @@ private fun PairingDialog(
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextButton(
text = if (!discoveringPort) "自动发现" else "发现中...",
text = stringResource(
if (!discoveringPort) R.string.button_auto_discover
else R.string.button_discovering
),
onClick = {
if (enabled && onDiscoverTarget != null && !discoveringPort)
scope.launch { doDiscover() }
@@ -915,14 +922,14 @@ private fun PairingDialog(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = {
onDismissRequest()
},
modifier = Modifier.weight(1f),
)
TextButton(
text = "配对",
text = stringResource(R.string.button_pair),
onClick = {
onConfirm(host.trim(), port.trim(), code.trim())
onDismissRequest()
@@ -1141,7 +1148,6 @@ internal fun DeviceTile(
onEditorCancel: () -> Unit,
) {
val haptic = LocalHapticFeedback.current
val snackbar = LocalSnackbarController.current
val scrcpyProfilesState by Storage.scrcpyProfiles.state.collectAsState()
var draft by remember(editing, device.id) {
@@ -1253,14 +1259,17 @@ internal fun DeviceTile(
Spacer(Modifier.width(UiSpacing.Medium))
}
TextButton(
text = if (!isConnected) "连接" else "断开",
text = stringResource(
if (!isConnected) R.string.button_connect
else R.string.button_disconnect
),
onClick = onAction,
enabled = actionEnabled && !actionInProgress,
colors = if (!isConnected && device.startScrcpyOnConnect) {
ButtonDefaults.textButtonColorsPrimary()
} else {
ButtonDefaults.textButtonColors()
},
colors =
if (!isConnected && device.startScrcpyOnConnect)
ButtonDefaults.textButtonColorsPrimary()
else
ButtonDefaults.textButtonColors(),
)
}
}
@@ -1277,14 +1286,14 @@ internal fun DeviceTile(
SuperTextField(
value = currentDraft.name,
onValueChange = { draft = currentDraft.copy(name = it) },
label = "设备名",
label = stringResource(R.string.label_device_name),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
SuperTextField(
value = currentDraft.host,
onValueChange = { draft = currentDraft.copy(host = it) },
label = "IP 地址",
label = stringResource(R.string.label_ip_address),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
@@ -1297,13 +1306,13 @@ internal fun DeviceTile(
port = draftPortText?.toIntOrNull() ?: Defaults.ADB_PORT
)
},
label = "端口",
label = stringResource(R.string.label_port),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
CheckboxPreference(
title = "连接后立即启动 scrcpy",
title = stringResource(R.string.device_config_start_immediately),
checkboxLocation = CheckboxLocation.End,
checked = currentDraft.startScrcpyOnConnect,
onCheckedChange = {
@@ -1312,7 +1321,7 @@ internal fun DeviceTile(
)
AnimatedVisibility(currentDraft.startScrcpyOnConnect) {
CheckboxPreference(
title = "直接进入全屏",
title = stringResource(R.string.device_config_direct_fullscreen),
checkboxLocation = CheckboxLocation.End,
checked = currentDraft.startScrcpyOnConnect
&& currentDraft.openFullscreenOnStart,
@@ -1322,18 +1331,23 @@ internal fun DeviceTile(
},
)
}
val textGlobal = stringResource(R.string.text_global)
OverlayDropdownPreference(
title = "scrcpy 配置",
title = stringResource(R.string.device_config_scrcpy_config),
items = profileNames,
selectedIndex = profileDropdownIndex,
onSelectedIndexChange = {
val profileId = profileIds.getOrElse(it) {
ScrcpyOptions.GLOBAL_PROFILE_ID
}
val profileName = profileNames.getOrElse(it) { "全局" }
val profileName = profileNames.getOrElse(it) { textGlobal }
val deviceName = currentDraft.name.ifBlank { currentDraft.host }
draft = currentDraft.copy(scrcpyProfileId = profileId)
snackbar.show("$deviceName 已切换到配置 $profileName")
AppRuntime.snackbar(
R.string.device_switched_profile,
deviceName,
profileName,
)
},
)
}
@@ -1344,7 +1358,7 @@ internal fun DeviceTile(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextButton(
text = "取消",
text = stringResource(R.string.button_cancel),
onClick = {
onEditorSave(currentOriginalDraft)
onEditorCancel()
@@ -1352,12 +1366,12 @@ internal fun DeviceTile(
modifier = Modifier.weight(1f),
)
TextButton(
text = "删除",
text = stringResource(R.string.button_delete),
onClick = onEditorDelete,
modifier = Modifier.weight(1f),
)
TextButton(
text = "完成",
text = stringResource(R.string.button_done),
onClick = onEditorCancel,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
@@ -1431,12 +1445,12 @@ internal fun QuickConnectCard(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Icon(
Icons.Rounded.AddLink,
contentDescription = "连接 / 添加设备",
imageVector = Icons.Rounded.AddLink,
contentDescription = stringResource(R.string.device_quick_connect_title),
tint = colorScheme.onPrimaryContainer,
)
Text(
"连接 / 添加设备",
stringResource(R.string.device_quick_connect_title),
fontWeight = FontWeight.Bold,
color = colorScheme.onPrimaryContainer,
)
@@ -1458,13 +1472,13 @@ internal fun QuickConnectCard(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
) {
TextButton(
text = "添加设备",
text = stringResource(R.string.button_add_device),
onClick = onAddDevice,
modifier = Modifier.weight(1f),
enabled = enabled,
)
TextButton(
text = "直接连接",
text = stringResource(R.string.button_direct_connect),
onClick = onConnect,
modifier = Modifier.weight(1f),
enabled = enabled,

View File

@@ -2,7 +2,9 @@ package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.pages.LocalRootNavigator
import io.github.miuzarte.scrcpyforandroid.pages.RootScreen
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat
@@ -24,20 +26,20 @@ fun RecordPreferences(
val navigator = LocalRootNavigator.current
val supportedFormats = remember { NativeRecordingSupport.supportedFormats }
val formatItems = remember {
supportedFormats.map {
if (it == RecordFormat.AUTO) "自动"
else it.string
}
val formatItems = supportedFormats.map {
if (it == RecordFormat.AUTO) stringResource(R.string.text_auto)
else it.string
}
val formatIndex = remember(recordFormat) {
supportedFormats.indexOfFirst { it.string == recordFormat }
.coerceAtLeast(0)
}
val currentTemplateSummary = recordFilenameTemplate.ifBlank { "关闭" }
val currentTemplateSummary = recordFilenameTemplate
.ifBlank { stringResource(R.string.text_off) }
ArrowPreference(
title = "录制",
title = stringResource(R.string.record_title),
summary = "--record",
enabled = enabled,
onClick = {
@@ -55,7 +57,7 @@ fun RecordPreferences(
)
OverlayDropdownPreference(
title = "录制格式",
title = stringResource(R.string.record_format),
summary = "--record-format",
items = formatItems,
selectedIndex = formatIndex,

View File

@@ -58,11 +58,6 @@ internal data class StatusCardSpec(
val secondSmall: StatusSmallCardSpec,
)
internal fun normalizeStatusLine(statusLine: String): String {
val cleaned = statusLine.removePrefix("ADB 已连接:").trim()
return cleaned.ifBlank { statusLine }
}
@Composable
internal fun StatusCardLayout(
spec: StatusCardSpec,
@@ -93,7 +88,7 @@ internal fun StatusCardLayout(
contentAlignment = Alignment.BottomEnd,
) {
Icon(
spec.big.icon,
imageVector = spec.big.icon,
contentDescription = null,
modifier = Modifier.size(170.dp),
tint = spec.big.iconTint,
@@ -137,25 +132,29 @@ internal fun StatusCardLayout(
.fillMaxHeight()
) {
StatusMetricCard(
spec = spec.firstSmall,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
spec = spec.firstSmall,
)
Spacer(Modifier.height(UiSpacing.PageItem))
StatusMetricCard(
spec = spec.secondSmall,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
spec = spec.secondSmall,
)
}
}
}
@Composable
private fun StatusMetricCard(spec: StatusSmallCardSpec, modifier: Modifier) {
private fun StatusMetricCard(
modifier: Modifier,
spec: StatusSmallCardSpec,
) {
val haptic = LocalHapticFeedback.current
Card(
modifier = modifier,
insideMargin = PaddingValues(UiSpacing.Large),

View File

@@ -1,5 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
@@ -50,12 +51,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
@@ -83,103 +86,103 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
enum class VirtualButtonAction(
val id: String,
val title: String,
@field:StringRes val titleResId: Int,
val icon: ImageVector,
val keycode: Int?,
) {
MORE(
"more",
"更多",
R.string.vb_more,
MiuixIcons.More,
null
),
HOME(
"home",
"主页",
R.string.vb_home,
Icons.Rounded.Home,
UiAndroidKeycodes.HOME
),
BACK(
"back",
"返回",
R.string.vb_back,
Icons.AutoMirrored.Rounded.ArrowBack,
UiAndroidKeycodes.BACK
),
APP_SWITCH(
"app_switch",
"多任务",
R.string.vb_app_switch,
Icons.Rounded.Apps,
UiAndroidKeycodes.APP_SWITCH
),
MENU(
"menu",
"菜单",
R.string.vb_menu,
Icons.Rounded.Menu,
UiAndroidKeycodes.MENU
),
NOTIFICATION(
"notification",
"通知栏",
R.string.vb_notifications,
Icons.Rounded.Notifications,
UiAndroidKeycodes.NOTIFICATION
),
VOLUME_UP(
"volume_up",
"音量+",
R.string.vb_volume_up,
Icons.AutoMirrored.Rounded.VolumeUp,
UiAndroidKeycodes.VOLUME_UP
),
VOLUME_DOWN(
"volume_down",
"音量-",
R.string.vb_volume_down,
Icons.AutoMirrored.Rounded.VolumeDown,
UiAndroidKeycodes.VOLUME_DOWN
),
VOLUME_MUTE(
"volume_mute",
"静音",
R.string.vb_volume_mute,
Icons.AutoMirrored.Rounded.VolumeOff,
UiAndroidKeycodes.VOLUME_MUTE
),
POWER(
"power",
"锁屏",
R.string.vb_lock_screen,
Icons.Rounded.PowerSettingsNew,
UiAndroidKeycodes.POWER
),
SCREENSHOT(
"screenshot",
"截图",
R.string.vb_screenshot,
Icons.Rounded.Screenshot,
UiAndroidKeycodes.SYSRQ
),
PASSWORD_INPUT(
"password_input",
"填充锁屏密码",
R.string.vb_fill_password,
Icons.Rounded.Password,
null
),
ALL_APPS(
"all_apps",
"所有应用",
R.string.vb_all_apps,
Icons.Rounded.Apps,
null
),
RECENT_TASKS(
"recent_tasks",
"最近任务",
R.string.vb_recent_tasks,
Icons.Rounded.DashboardCustomize,
null
),
TOGGLE_IME(
"toggle_ime",
"拉起输入法",
R.string.vb_toggle_ime,
Icons.Rounded.Keyboard,
null
),
PASTE_LOCAL_CLIPBOARD(
"paste_local_clipboard",
"粘贴本机剪贴板",
R.string.vb_paste_clipboard,
Icons.Rounded.ContentPaste,
null
);
@@ -304,14 +307,14 @@ class VirtualButtonBar(
if (enabled) activeContentColor
else disabledContentColor
Icon(
action.icon,
contentDescription = action.title,
imageVector = action.icon,
contentDescription = stringResource(action.titleResId),
modifier = Modifier.size(18.dp),
tint = contentColor,
)
if (showText) {
Spacer(Modifier.width(UiSpacing.Small))
Text(action.title, color = contentColor)
Text(stringResource(action.titleResId), color = contentColor)
}
}
if (action == VirtualButtonAction.MORE) {
@@ -412,7 +415,11 @@ class VirtualButtonBar(
color = Color.Black.copy(alpha = 0.1f),
),
) {
Icon(action.icon, contentDescription = action.title, tint = Color.White)
Icon(
imageVector = action.icon,
contentDescription = stringResource(action.titleResId),
tint = Color.White
)
}
if (action == VirtualButtonAction.MORE) {
@@ -465,7 +472,11 @@ class VirtualButtonBar(
color = Color.Black.copy(alpha = 0.1f),
),
) {
Icon(action.icon, contentDescription = action.title, tint = Color.White)
Icon(
imageVector = action.icon,
contentDescription = stringResource(action.titleResId),
tint = Color.White
)
}
if (action == VirtualButtonAction.MORE) {
@@ -677,20 +688,19 @@ class VirtualButtonBar(
) {
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
val spinnerItems = remember(actions) {
actions.map { action ->
SpinnerEntry(
icon = {
Icon(
action.icon,
contentDescription = action.title,
modifier = Modifier
.padding(end = UiSpacing.ContentVertical),
)
},
title = action.title,
)
}
val spinnerItems = actions.map { action ->
val title = stringResource(action.titleResId)
SpinnerEntry(
icon = {
Icon(
imageVector = action.icon,
contentDescription = title,
modifier = Modifier
.padding(end = UiSpacing.ContentVertical),
)
},
title = title,
)
}
NavOverlayListPopup(

View File

@@ -0,0 +1 @@
unqualifiedResLocale=en

View File

@@ -0,0 +1,554 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Scrcpy for Android</string>
<!-- Main Tab Labels -->
<string name="main_tab_devices">设备</string>
<string name="main_tab_terminal">终端</string>
<string name="main_tab_files">文件</string>
<string name="main_tab_settings">设置</string>
<string name="main_press_back_again">再按一次返回键退出</string>
<string name="main_font_read_error">无法读取字体文件</string>
<string name="main_terminal_font_imported">终端字体导入成功</string>
<string name="main_terminal_font_import_failed">终端字体导入失败:%1$s</string>
<string name="main_update_check_failed">检查更新失败:%1$s</string>
<!-- About -->
<string name="about_title">关于</string>
<string name="about_project_repo">项目仓库</string>
<string name="about_releases">版本发布</string>
<string name="about_error">错误</string>
<!-- Common / Shared -->
<string name="cd_back">返回</string>
<string name="cd_more">更多</string>
<string name="cd_clear">清空</string>
<string name="cd_select_file">选择文件</string>
<string name="cd_select_font">选择字体</string>
<string name="cd_fullscreen">全屏</string>
<string name="cd_close">关闭</string>
<string name="cd_save">保存</string>
<string name="cd_edit">编辑</string>
<string name="cd_drag_sort">拖动排序</string>
<string name="cd_refresh">刷新%1$s</string>
<string name="text_none"></string>
<string name="text_default">默认</string>
<string name="text_global">全局</string>
<string name="text_loading">加载中…</string>
<string name="text_not_available">没有可用的</string>
<string name="text_off">关闭</string>
<string name="text_auto">自动</string>
<string name="text_custom">自定义</string>
<string name="text_fetching">获取中…</string>
<string name="text_fetch_success">获取成功</string>
<string name="text_fetch_failed">刷新失败:%1$s</string>
<string name="button_cancel">取消</string>
<string name="button_delete">删除</string>
<string name="button_done">完成</string>
<string name="button_connect">连接</string>
<string name="button_disconnect">断开</string>
<string name="button_start">启动</string>
<string name="button_stop">停止</string>
<string name="button_fullscreen">全屏</string>
<string name="button_pair">配对</string>
<string name="button_confirm">确定</string>
<string name="button_add_device">添加设备</string>
<string name="button_direct_connect">直接连接</string>
<string name="button_auto_discover">自动发现</string>
<string name="button_discovering">发现中…</string>
<string name="button_swap">交换</string>
<string name="label_ip_address">IP 地址</string>
<string name="label_port">端口</string>
<string name="label_device_name">设备名</string>
<string name="label_name">名称</string>
<string name="label_wlan_pairing_code">WLAN 配对码</string>
<string name="label_resolution">分辨率</string>
<string name="label_codec">编解码器</string>
<string name="label_status">状态</string>
<string name="label_fps">FPS</string>
<string name="label_touches">触点</string>
<!-- Device Tab -->
<string name="device_title">设备</string>
<string name="device_cd_config_right">配置显示在右侧</string>
<string name="device_cd_config_left">配置显示在左侧</string>
<string name="device_menu_quick_sort">快速设备排序</string>
<string name="device_menu_virtual_button_sort">虚拟按钮排序</string>
<string name="device_menu_clear_logs">清空日志</string>
<string name="device_hint_long_press_edit">长按可编辑</string>
<string name="device_cannot_modify_connected">无法修改已连接的设备</string>
<string name="device_added">已添加设备:%1$s:%2$s</string>
<string name="device_section_wireless_pairing">无线配对</string>
<string name="device_section_scrcpy">Scrcpy</string>
<string name="device_section_logs">日志</string>
<string name="device_pairing_title">使用配对码配对设备</string>
<string name="device_pairing_desc">使用六位数的配对码配对新设备</string>
<string name="device_quick_connect_title">连接 / 添加设备</string>
<string name="device_status_mirroring">投屏中</string>
<string name="device_status_resolution">分辨率</string>
<string name="device_status_codec">编解码器</string>
<string name="device_status_adb_connected">ADB 已连接</string>
<string name="device_status_adb_disconnected">ADB 未连接</string>
<string name="device_status_current">当前设备</string>
<string name="device_status_state">状态</string>
<string name="device_status_idle">空闲</string>
<string name="device_config_audio_forwarding">音频转发</string>
<string name="device_config_audio_forwarding_desc">转发设备音频到本机 (Android 11+)</string>
<string name="device_config_audio_codec">音频编码</string>
<string name="device_config_audio_codec_note">注意FLAC编解码会引入较大的延迟</string>
<string name="device_config_audio_bitrate">音频码率</string>
<string name="device_config_video_codec">视频编码</string>
<string name="device_config_video_codec_note">注意绝大部分设备不支持AV1硬件编码</string>
<string name="device_config_video_bitrate">视频码率</string>
<string name="device_config_more_params">更多参数</string>
<string name="device_config_all_params">所有参数</string>
<string name="device_config_all_scrcpy_params">所有 scrcpy 参数</string>
<string name="device_config_start_immediately">连接后立即启动 scrcpy</string>
<string name="device_config_direct_fullscreen">直接进入全屏</string>
<string name="device_config_scrcpy_config">scrcpy 配置</string>
<string name="device_switched_profile">%1$s 已切换到配置 %2$s</string>
<!-- Recent Tasks / All Apps -->
<string name="bottomsheet_recent_tasks">最近任务</string>
<string name="bottomsheet_loading_tasks">最近任务加载中</string>
<string name="bottomsheet_no_tasks">没有可用的最近任务</string>
<string name="bottomsheet_all_apps">所有应用</string>
<string name="bottomsheet_loading_apps">应用列表加载中</string>
<string name="bottomsheet_no_apps">没有可用的应用列表</string>
<!-- Fullscreen Control -->
<string name="fullscreen_failed_app_list">获取应用列表失败</string>
<string name="fullscreen_failed_tasks">获取最近任务失败</string>
<string name="fullscreen_paste_non_ascii">非 ASCII 文本粘贴失败</string>
<string name="fullscreen_text_input_failed">文本输入失败</string>
<string name="fullscreen_clipboard_empty">本机剪贴板为空或不是文本</string>
<string name="fullscreen_legacy_paste_failed">legacy 粘贴失败</string>
<string name="fullscreen_clipboard_sync_failed">剪贴板同步粘贴失败,可尝试开启 --legacy-paste</string>
<string name="fullscreen_start_app_failed">启动应用失败:%1$s</string>
<string name="fullscreen_debug_resolution">分辨率:%1$sx%2$s</string>
<string name="fullscreen_debug_fps">帧率:%1$s</string>
<string name="fullscreen_debug_touches">触点:%1$d</string>
<!-- Settings -->
<string name="settings_title">设置</string>
<!-- Settings - Update -->
<string name="pref_update_current">当前版本 %1$s</string>
<string name="pref_update_checking">,正在检查更新</string>
<string name="pref_update_failed">,检查更新失败</string>
<string name="pref_update_found">,发现新版本 %1$s</string>
<string name="pref_update_latest">,已是最新版本</string>
<string name="pref_update_newer">,高于最新发布版本 %1$s</string>
<!-- Settings - Theme -->
<string name="section_theme">主题</string>
<string name="pref_title_appearance_mode">外观模式</string>
<string name="pref_summary_appearance_mode">选择应用的外观模式</string>
<string name="pref_title_monet">Monet 颜色</string>
<string name="pref_summary_monet">开启后使用 Monet 动态配色</string>
<string name="pref_title_monet_key_color">Monet Key Color</string>
<string name="pref_summary_monet_key_color">设置 Monet 强调色</string>
<string name="pref_title_monet_palette_style">Monet Palette Style</string>
<string name="pref_summary_monet_palette_style">设置 Monet 调色板风格</string>
<string name="pref_title_monet_color_spec">Monet Color Spec</string>
<string name="pref_summary_monet_color_spec">设置 Monet 色彩规格</string>
<string name="pref_title_blur">模糊</string>
<string name="pref_summary_blur">启用顶栏和底栏的模糊效果</string>
<string name="pref_title_floating_bottom_bar">悬浮底栏</string>
<string name="pref_summary_floating_bottom_bar">使用 Apple 风格的悬浮底栏</string>
<string name="pref_title_liquid_glass">液态玻璃</string>
<string name="pref_summary_liquid_glass">启用悬浮底栏的液态玻璃效果</string>
<string name="pref_title_smooth_corners">平滑圆角</string>
<string name="pref_summary_smooth_corners">启用全局平滑圆角效果</string>
<!-- Settings - Screen Mirroring -->
<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_debug_info">启用调试信息</string>
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
<string name="pref_summary_hide_simple_settings">启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮</string>
<string name="pref_title_preview_card_height">预览卡高度</string>
<string name="pref_summary_preview_card_height">设备页预览卡高度</string>
<string name="pref_title_quick_device_sort">快速设备排序</string>
<string name="pref_summary_quick_device_sort">手动排序设备页的快速设备</string>
<string name="pref_title_virtual_button_sort">虚拟按钮排序</string>
<string name="pref_summary_virtual_button_sort">手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外</string>
<string name="pref_title_password_autofill">锁屏密码自动填充</string>
<string name="pref_summary_password_autofill">管理用于自动填充的锁屏密码</string>
<string name="pref_title_clipboard_sync">实时同步剪贴板到受控机</string>
<string name="pref_summary_clipboard_sync">本机剪贴板更新后会自动同步到受控机\n禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容\nMIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效</string>
<!-- Settings - Fullscreen -->
<string name="section_fullscreen">全屏</string>
<string name="pref_title_ignore_rotation_lock">全屏时不跟随系统旋转锁定</string>
<string name="pref_summary_ignore_rotation_lock">启用后使用传感器方向,忽略系统自动旋转锁定状态</string>
<string name="pref_title_back_to_device">全屏时返回键发送到远程</string>
<string name="pref_summary_back_to_device">启用后系统返回键会发送给设备,不再退出全屏控制页\n此时退出全屏需要回到桌面通过图标重新进入应用</string>
<string name="pref_title_show_virtual_buttons">全屏时显示虚拟按钮</string>
<string name="pref_summary_show_virtual_buttons">在全屏控制页中显示返回键、主页键等虚拟按钮</string>
<string name="pref_title_virtual_button_direction">虚拟按钮方向</string>
<string name="pref_title_virtual_button_height">虚拟按钮高度</string>
<string name="pref_title_show_floating_button">全屏时显示悬浮球</string>
<string name="pref_summary_show_floating_button">在全屏控制页中显示可拖动的悬浮球,点击后弹出完整虚拟按键菜单</string>
<string name="pref_title_floating_button_size">悬浮球尺寸</string>
<string name="pref_title_floating_button_bg_opacity">悬浮球背景透明度</string>
<string name="pref_title_floating_button_ring_opacity">悬浮球白环透明度</string>
<string name="pref_title_fullscreen_compat_mode">全屏兼容模式</string>
<string name="pref_summary_fullscreen_compat_mode">启用后全屏控制页不再跨 Activity会导致画中画不可用</string>
<!-- Settings - scrcpy-server -->
<string name="section_scrcpy_server">scrcpy-server</string>
<string name="pref_title_custom_binary">自定义 binary</string>
<string name="pref_title_custom_binary_version">自定义 binary version</string>
<string name="pref_title_remote_path">Remote Path</string>
<!-- Settings - ADB -->
<string name="section_adb">ADB</string>
<string name="pref_title_battery_optimization">调整后台电池策略</string>
<string name="pref_summary_battery_optimization">解决 Scrcpy 切换到后台时无法联网导致 ADB 断连\n应用的电池使用情况 -> 允许后台使用 -> 无限制\n国产ROM魔改的电源设置一般都可在对应的魔改应用设置中找到</string>
<string name="pref_title_app_info">应用信息</string>
<string name="pref_title_custom_adb_key">自定义 ADB 密钥名</string>
<string name="pref_title_adb_private_key">ADB 私钥</string>
<string name="pref_title_adb_public_key">ADB 公钥</string>
<string name="pref_adb_key_not_imported">未导入</string>
<string name="pref_adb_key_imported">已导入</string>
<string name="pref_adb_key_generated">已生成</string>
<string name="pref_adb_private_key_imported">私钥已导入,公钥指纹:%1$s</string>
<string name="pref_adb_public_key_imported_snackbar">公钥已导入,指纹:%1$s</string>
<string name="pref_adb_key_reset">ADB 密钥已重新生成,公钥指纹:%1$s</string>
<string name="pref_adb_key_removed">已移除导入的 ADB 密钥,当前公钥指纹:%1$s</string>
<string name="pref_adb_key_import_failed">ADB 密钥导入失败:%1$s</string>
<string name="pref_adb_key_remove">移除</string>
<string name="pref_adb_key_regenerate">重新生成</string>
<string name="pref_title_auto_discovery">配对时自动启用发现服务</string>
<string name="pref_summary_auto_discovery">打开配对弹窗后自动搜索可用配对端口</string>
<string name="pref_title_auto_reconnect">自动重连已配对设备</string>
<string name="pref_summary_auto_reconnect">自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)</string>
<string name="pref_title_auto_load_apps">连接后自动获取应用列表</string>
<string name="pref_summary_auto_load_apps">ADB 连接成功后立刻执行 --list-apps用于补全最近任务列表应用名</string>
<string name="pref_warning_list_apps">--list-apps 操作可能非常耗时(特别是在息屏状态下),启用后可能导致连接设备后阻塞过久!</string>
<string name="pref_cannot_open_settings">无法打开设置</string>
<!-- Settings - Terminal -->
<string name="section_terminal">终端</string>
<string name="pref_title_terminal_font_size">终端字号</string>
<string name="pref_summary_terminal_font_size">也可以在终端上双指缩放调整</string>
<string name="pref_title_custom_font">自定义终端字体</string>
<string name="pref_hint_builtin_font">内置等宽字体</string>
<string name="pref_font_restored">已恢复默认终端字体</string>
<string name="pref_no_custom_font">当前没有可清除的自定义字体</string>
<!-- Settings - Miscellaneous -->
<string name="section_misc">杂项</string>
<string name="pref_title_clear_logs_on_exit">退出应用时清除日志</string>
<string name="pref_summary_clear_logs_on_exit">双击返回退出应用时清除日志</string>
<string name="pref_title_hide_log_box">隐藏设备页日志框</string>
<string name="pref_summary_hide_log_box">隐藏设备页最下方的日志框</string>
<!-- Terminal -->
<string name="terminal_title">终端</string>
<string name="terminal_menu_free_copy">自由复制</string>
<string name="terminal_menu_clear_screen">清屏</string>
<string name="terminal_copied_all">已复制所有终端输出</string>
<string name="terminal_copied">已复制到剪贴板</string>
<string name="terminal_copy_all">复制全部</string>
<string name="terminal_no_output">当前没有输出</string>
<string name="terminal_output_label">终端输出</string>
<string name="terminal_font_size_snackbar">终端字号 %1$dsp</string>
<string name="terminal_font_size_restore_default">恢复默认</string>
<!-- Virtual Button Order -->
<string name="vb_order_title">虚拟按钮排序</string>
<string name="vb_order_button_text">按钮显示文本</string>
<string name="vb_order_hint">超过3个建议关闭只对预览卡下方的虚拟按钮生效</string>
<string name="vb_order_display_external">显示在外部</string>
<string name="vb_order_display_more_menu">显示在更多菜单内</string>
<!-- Virtual Button Actions -->
<string name="vb_more">更多</string>
<string name="vb_home">主页</string>
<string name="vb_back">返回</string>
<string name="vb_app_switch">多任务</string>
<string name="vb_menu">菜单</string>
<string name="vb_notifications">通知栏</string>
<string name="vb_volume_up">音量+</string>
<string name="vb_volume_down">音量-</string>
<string name="vb_volume_mute">静音</string>
<string name="vb_lock_screen">锁屏</string>
<string name="vb_screenshot">截图</string>
<string name="vb_fill_password">填充锁屏密码</string>
<string name="vb_all_apps">所有应用</string>
<string name="vb_recent_tasks">最近任务</string>
<string name="vb_toggle_ime">拉起输入法</string>
<string name="vb_paste_clipboard">粘贴本机剪贴板</string>
<!-- Dock Positions -->
<string name="dock_follow">跟随</string>
<string name="dock_fixed">固定</string>
<string name="dock_top">上方</string>
<string name="dock_bottom">下方</string>
<string name="dock_left">左侧</string>
<string name="dock_right">右侧</string>
<string name="dock_display_on">显示在屏幕</string>
<!-- Theme Modes -->
<string name="theme_follow_system">跟随系统</string>
<string name="theme_light">浅色</string>
<string name="theme_dark">深色</string>
<!-- Recording -->
<string name="record_title">录制</string>
<string name="record_format">录制格式</string>
<string name="record_filename_hint">文件名,不为空时启用</string>
<string name="record_preview">预览:</string>
<string name="record_plain_text">纯文本</string>
<string name="record_desc_yyyy">四位年份,例如 2026</string>
<string name="record_desc_yy">两位年份,例如 26</string>
<string name="record_desc_MM">月份,两位,例如 09</string>
<string name="record_desc_M">月份,一位或两位,例如 9</string>
<string name="record_desc_dd">日期,两位,例如 19</string>
<string name="record_desc_d">日期,一位或两位,例如 9</string>
<string name="record_desc_HH">24 小时制,两位,例如 23</string>
<string name="record_desc_H">24 小时制,一位或两位,例如 9</string>
<string name="record_desc_hh">12 小时制,两位,例如 11</string>
<string name="record_desc_h">12 小时制,一位或两位,例如 9</string>
<string name="record_desc_mm">分钟,两位,例如 09</string>
<string name="record_desc_m">分钟,一位或两位,例如 9</string>
<string name="record_desc_ss">秒,两位,例如 09</string>
<string name="record_desc_s">秒,一位或两位,例如 9</string>
<string name="record_desc_timestamp">秒级时间戳,例如 1776952809</string>
<string name="record_desc_device_name">设备名</string>
<string name="record_desc_device_ip">设备 IP</string>
<string name="record_desc_device_port">设备端口</string>
<string name="record_desc_video_codec">视频串流编码(非文件实际编码)</string>
<string name="record_desc_audio_codec">音频串流编码(非文件实际编码)</string>
<string name="record_desc_width">视频宽度</string>
<string name="record_desc_height">视频高度</string>
<!-- Reorder Devices -->
<string name="reorder_devices_title">快速设备排序</string>
<!-- Lockscreen Password -->
<string name="password_stop_mirroring">停止投屏</string>
<string name="password_authenticate_create">验证以创建新密码</string>
<string name="password_authenticate_subtitle">验证身份以继续</string>
<string name="password_auth_to_disable">验证以禁用密码保护</string>
<string name="password_auth_subtitle">验证身份以继续</string>
<string name="password_auth_fill_title">验证以填充锁屏密码</string>
<string name="password_fill_failed">密码填充失败</string>
<string name="password_no_available">无可用密码</string>
<string name="password_cannot_auth_here">当前页面无法拉起验证</string>
<string name="password_auth_failed">认证失败</string>
<string name="password_security_state_changed">设备安全状态已变更,请重新设置密码</string>
<string name="password_expired">密码已过期,请重新设置密码</string>
<string name="password_autofill_title">锁屏密码自动填充</string>
<string name="password_create_new">创建新密码</string>
<string name="password_no_lock_screen">当前设备未设置锁屏保护</string>
<string name="password_no_lock_screen_warn">继续使用将允许在无认证保护的情况下保存和填充锁屏密码</string>
<string name="password_agree">同意</string>
<string name="password_auth_lost_warn">关闭验证后密码将失去保护</string>
<string name="password_auth_lost_detail">关闭后每次填充密码时将不再强制认证\n同时会熔断当前经认证创建的密码</string>
<string name="password_continue_disable">继续关闭</string>
<string name="password_delete_confirm">删除密码</string>
<string name="password_delete_msg">将删除 %1$s</string>
<string name="password_default_name">密码 %1$d</string>
<string name="password_cannot_be_empty">密码不能为空</string>
<string name="password_require_auth">填充密码时需要验证</string>
<string name="password_require_auth_detail">关闭后将允许直接填充锁屏密码\n同时会熔断当前经认证创建的密码</string>
<string name="password_no_auth_capability">当前设备无认证能力</string>
<string name="password_or_menu_hint">或在右上角菜单中</string>
<string name="password_status_invalidated">已失效</string>
<string name="password_status_authenticated">创建时已验证</string>
<string name="password_status_unauthenticated">创建时未经验证</string>
<string name="password_status_burned">创建时已验证(熔断)</string>
<string name="password_rename">重命名密码</string>
<string name="password_lockscreen_label">锁屏密码</string>
<string name="password_disclaimer">免责声明\n0. 无法保证没有 bug\n1. 本功能的防护边界仅包括加密存储、按需认证和使用后内存清理,不构成绝对安全保证\n2. 在 root / posed / hook / 调试器 / 恶意输入法 等环境下,密码仍可能泄露\n3. 本功能不会绕过系统锁屏认证,仅用于你已合法授权控制的设备\n4. 关闭\"填充密码时需要验证\"会显著降低安全性,请谨慎选择</string>
<!-- File Manager -->
<string name="fm_cd_parent">上一层</string>
<string name="fm_cd_sort">排序</string>
<string name="fm_sort_name">文件名</string>
<string name="fm_sort_size">大小</string>
<string name="fm_sort_time">时间</string>
<string name="fm_sort_extension">扩展名</string>
<string name="fm_sort_asc">正序</string>
<string name="fm_sort_desc">倒序</string>
<string name="fm_menu_create_folder">创建文件夹</string>
<string name="fm_menu_upload">上传文件到该目录</string>
<string name="fm_loading_details">正在加载详情</string>
<string name="fm_no_details">暂无详情</string>
<string name="fm_pull_refresh">下拉刷新</string>
<string name="fm_release_refresh">释放刷新</string>
<string name="fm_refreshing">正在刷新…</string>
<string name="fm_refresh_done">刷新完成</string>
<string name="fm_load_failed">加载失败:%1$s</string>
<string name="fm_empty_dir">空目录</string>
<string name="fm_file_details">文件详情</string>
<string name="fm_show_raw">显示原文</string>
<string name="fm_show_parsed">显示解析</string>
<string name="fm_cd_download">下载</string>
<string name="fm_goto_path">跳转路径</string>
<string name="fm_title_create_folder">创建文件夹</string>
<string name="fm_label_new_folder">新建文件夹</string>
<string name="fm_button_create">创建</string>
<string name="fm_snack_link_unavailable">链接目标不可用,长按查看信息</string>
<string name="fm_snack_not_dir">链接目标不是文件夹,长按查看信息</string>
<string name="fm_snack_link_read_failed">读取链接目标失败:%1$s</string>
<string name="fm_snack_long_press_details">长按可查看文件详情</string>
<string name="fm_snack_folder_name_empty">文件夹名称不能为空</string>
<string name="fm_snack_folder_created">已创建文件夹</string>
<string name="fm_snack_create_failed">创建失败:%1$s</string>
<string name="fm_snack_uploaded">已上传到 %1$s</string>
<string name="fm_snack_upload_failed">上传失败:%1$s</string>
<string name="fm_snack_read_details_failed">读取详情失败:%1$s</string>
<string name="fm_snack_scan_failed">目录扫描失败:%1$s</string>
<string name="fm_snack_download_starting">开始下载</string>
<string name="fm_snack_dir_loading">目录信息仍在加载,请稍后重试</string>
<string name="fm_snack_downloaded">已下载到 Download/Scrcpy</string>
<string name="fm_snack_cannot_save">无法直接写入 Download/Scrcpy请选择保存目录</string>
<string name="fm_snack_download_complete">下载完成</string>
<string name="fm_snack_download_failed">下载失败:%1$s</string>
<string name="terminal_snack_input_failed">终端输入失败:%1$s</string>
<string name="terminal_snack_session_failed">终端会话创建失败:%1$s</string>
<string name="terminal_snack_output_failed">终端输出失败:%1$s</string>
<string name="fm_exception_cannot_read_filename">无法读取文件名</string>
<string name="fm_exception_cannot_read_selected_file">无法读取选择的文件</string>
<string name="fm_exception_folder_name_empty">文件夹名称不能为空</string>
<string name="fm_exception_cannot_write_target">无法写入目标文件</string>
<string name="fm_exception_directory_not_found">目录不存在</string>
<string name="fm_exception_not_directory">目标不是文件夹</string>
<string name="fm_exception_cannot_create_dir">无法创建目录</string>
<string name="fm_exception_cannot_create_file">无法创建文件</string>
<string name="fm_exception_remote_shell_closed">远端 shell 已关闭</string>
<string name="fm_exception_command_failed">命令执行失败</string>
<string name="fm_stat_path">路径</string>
<string name="fm_stat_type">类型</string>
<string name="fm_stat_size">大小</string>
<string name="fm_stat_blocks">块数</string>
<string name="fm_stat_io_block_size">IO 块大小</string>
<string name="fm_stat_hard_links">硬链接</string>
<string name="fm_stat_permissions">权限</string>
<string name="fm_stat_device">设备</string>
<string name="fm_stat_device_type">设备类型</string>
<string name="fm_stat_access_time">访问时间</string>
<string name="fm_stat_modify_time">修改时间</string>
<string name="fm_stat_change_time">变更时间</string>
<string name="fm_stat_link_target">链接目标</string>
<string name="fm_stat_directories">目录数</string>
<string name="fm_stat_files">文件数</string>
<string name="fm_stat_target_info">目标信息</string>
<string name="fm_unknown">未知</string>
<!-- Scrcpy All Options -->
<string name="profile_default_name">配置</string>
<string name="profile_new_name">新配置</string>
<string name="scrcpyopt_title">所有参数</string>
<string name="scrcpyopt_cd_profile_manage">配置管理</string>
<string name="scrcpyopt_manage_profiles">管理配置</string>
<string name="scrcpyopt_new_profile">新建配置</string>
<string name="scrcpyopt_rename_profile">重命名配置</string>
<string name="scrcpyopt_duplicate_name_hint">配置名重复时会自动追加序号</string>
<string name="scrcpyopt_profile_name">配置名</string>
<string name="scrcpyopt_copy_from">复制配置</string>
<string name="scrcpyopt_current_profile">当前配置</string>
<string name="scrcpyopt_delete_profile">删除配置</string>
<string name="scrcpyopt_delete_confirm">确认删除 \"%1$s\"</string>
<string name="scrcpyopt_prefer_text">优先文本</string>
<string name="scrcpyopt_raw_key_events">原始按键事件</string>
<string name="scrcpyopt_default_mixed">默认</string>
<string name="scrcpyopt_dont_modify">不修改</string>
<string name="scrcpyopt_invalid_options">无效参数:%1$s</string>
<string name="scrcpyopt_turn_screen_off">scrcpy 启动后熄灭设备屏幕</string>
<string name="scrcpyopt_turn_screen_off_note">注意:大部分设备在关闭屏幕后刷新率会降低/减半</string>
<string name="scrcpyopt_no_control">禁用控制</string>
<string name="scrcpyopt_no_video">禁用视频</string>
<string name="scrcpyopt_no_video_playback">仅转发不播放视频</string>
<string name="scrcpyopt_no_audio">禁用音频</string>
<string name="scrcpyopt_no_audio_playback">仅转发不播放</string>
<string name="scrcpyopt_screen_off_timeout">scrcpy 启动后受控机的息屏时间</string>
<string name="scrcpyopt_no_power_on">scrcpy 启动时不唤醒屏幕</string>
<string name="scrcpyopt_power_off_on_close">scrcpy 结束后息屏</string>
<string name="scrcpyopt_stay_awake">scrcpy 启动后保持设备唤醒状态(插入电源)</string>
<string name="scrcpyopt_show_touches">显示物理触控</string>
<string name="scrcpyopt_fullscreen">启动后进入全屏</string>
<string name="scrcpyopt_disable_screensaver">scrcpy 启动后保持本机屏幕常亮</string>
<string name="scrcpyopt_disable_screensaver_note">注意防烧屏,画中画也生效</string>
<string name="scrcpyopt_kill_adb_on_close">scrcpy 结束时断开 adb</string>
<string name="scrcpyopt_audio_codec">音频编码</string>
<string name="scrcpyopt_audio_bitrate">音频码率</string>
<string name="scrcpyopt_video_codec">视频编码</string>
<string name="scrcpyopt_video_bitrate">视频码率</string>
<string name="scrcpyopt_video_source">视频来源</string>
<string name="scrcpyopt_display_id">监视器 ID</string>
<string name="scrcpyopt_max_size">最大分辨率</string>
<string name="scrcpyopt_max_fps">最大帧率</string>
<string name="scrcpyopt_camera_id">摄像头 ID</string>
<string name="scrcpyopt_camera_facing">摄像头朝向</string>
<string name="scrcpyopt_camera_size">摄像头分辨率</string>
<string name="scrcpyopt_camera_fps">摄像头帧率</string>
<string name="scrcpyopt_camera_high_speed">高帧率模式</string>
<string name="scrcpyopt_audio_source">音频来源</string>
<string name="scrcpyopt_audio_dup">音频双路输出</string>
<string name="scrcpyopt_require_audio">音频转发失败时终止</string>
<string name="scrcpyopt_video_encoder">视频编码器</string>
<string name="scrcpyopt_audio_encoder">音频编码器</string>
<string name="scrcpyopt_ime_display_policy">输入法显示策略</string>
<string name="scrcpyopt_no_vd_destroy_content">关闭虚拟显示器时保留内容</string>
<string name="scrcpyopt_no_vd_decorations">禁用虚拟显示器系统装饰</string>
<string name="scrcpyopt_no_downsize_on_error">禁用 MediaCodec 出错时自动降级</string>
<string name="scrcpyopt_no_downsize_on_error_desc">默认情况下,在 MediaCodec 出错时scrcpy 会自动尝试使用更低的分辨率重新开始\n此选项将禁用此行为</string>
<string name="scrcpyopt_legacy_paste">粘贴兼容回退仅支持ASCII/英文字符)</string>
<string name="scrcpyopt_key_inject_mode">键盘注入策略</string>
<string name="scrcpyopt_no_key_repeat">禁用按键重复转发</string>
<string name="scrcpyopt_no_clipboard_autosync">禁用剪贴板双向同步</string>
<string name="scrcpyopt_no_mouse_hover">禁用鼠标悬停转发</string>
<string name="scrcpyopt_no_cleanup">禁用结束后清理</string>
<string name="scrcpyopt_no_cleanup_desc">默认情况下scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string>
<string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string>
<string name="scrcpyopt_native">本机</string>
<string name="scrcpyopt_log_level">日志等级</string>
<!-- DeviceTabViewModel snackbar & log messages -->
<string name="vm_adb_connected">ADB 已连接</string>
<string name="vm_scrcpy_started">scrcpy 已启动</string>
<string name="vm_scrcpy_started_recording">scrcpy 已启动并开始录制</string>
<string name="vm_scrcpy_stopped_adb_disconnected">scrcpy 已停止ADB 已断开</string>
<string name="vm_scrcpy_stopped">scrcpy 已停止</string>
<string name="vm_adb_connection_failed">ADB 连接失败</string>
<string name="vm_adb_disconnected">ADB 已断开</string>
<string name="vm_pairing_succeeded">配对成功</string>
<string name="vm_pairing_failed">配对失败</string>
<string name="vm_auto_reconnect_succeeded">ADB 自动重连成功</string>
<string name="vm_auto_reconnect_failed">ADB 自动重连失败</string>
<string name="vm_start_scrcpy">启动 scrcpy</string>
<string name="vm_stop_scrcpy">停止 scrcpy</string>
<string name="vm_launch_app">启动应用</string>
<string name="vm_connect_adb">连接 ADB</string>
<string name="vm_disconnect_adb">断开 ADB</string>
<string name="vm_execute_pairing">执行配对</string>
<string name="vm_send_action">发送 %1$s</string>
<string name="vm_label_timeout">%1$s 超时</string>
<string name="vm_label_param_error">%1$s 参数错误:%2$s</string>
<string name="vm_label_failed">%1$s 失败:%2$s</string>
<string name="vm_app_started_on_display">已在当前显示启动应用:%1$s</string>
<string name="vm_start_app_fallback_adb">通过 scrcpy 控制通道启动应用失败,回退 ADB</string>
<string name="vm_app_started_via_adb">已通过 ADB 启动应用:%1$s</string>
<string name="vm_adb_connected_detail">ADB 已连接model=%1$s</string>
<string name="vm_failed_app_list_msg">获取应用列表失败:%1$s</string>
<string name="vm_scrcpy_requested_app">已请求 scrcpy 启动应用:%1$s</string>
<string name="vm_scrcpy_start_app_failed">通过 scrcpy 控制通道启动应用失败:%1$s</string>
<string name="vm_scrcpy_started_detail">scrcpy 已启动device=%1$s</string>
<string name="vm_scrcpy_stopped_adb_disconnected_log">scrcpy 已停止ADB 已断开</string>
<string name="vm_failed_recent_tasks_msg">获取最近任务失败:%1$s</string>
<string name="vm_legacy_paste_injected">已使用 legacy paste 注入本机剪贴板文本</string>
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</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>
<string name="vm_quick_probe_success">ADB 快速探测连接成功:%1$s:%2$s</string>
<string name="vm_device_switched_profile">当前连接设备已切换为配置:%1$s</string>
</resources>

View File

@@ -1,3 +1,554 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Scrcpy for Android</string>
</resources>
<!-- Main Tab Labels -->
<string name="main_tab_devices">Devices</string>
<string name="main_tab_terminal">Terminal</string>
<string name="main_tab_files">Files</string>
<string name="main_tab_settings">Settings</string>
<string name="main_press_back_again">Press back again to exit</string>
<string name="main_font_read_error">Cannot read font file</string>
<string name="main_terminal_font_imported">Terminal font imported successfully</string>
<string name="main_terminal_font_import_failed">Terminal font import failed: %1$s</string>
<string name="main_update_check_failed">Update check failed: %1$s</string>
<!-- About -->
<string name="about_title">About</string>
<string name="about_project_repo">Project repo</string>
<string name="about_releases">Releases</string>
<string name="about_error">Error</string>
<!-- Common / Shared -->
<string name="cd_back">Back</string>
<string name="cd_more">More</string>
<string name="cd_clear">Clear</string>
<string name="cd_select_file">Select file</string>
<string name="cd_select_font">Select font</string>
<string name="cd_fullscreen">Fullscreen</string>
<string name="cd_close">Close</string>
<string name="cd_save">Save</string>
<string name="cd_edit">Edit</string>
<string name="cd_drag_sort">Drag to sort</string>
<string name="cd_refresh">Refresh %1$s</string>
<string name="text_none">None</string>
<string name="text_default">Default</string>
<string name="text_global">Global</string>
<string name="text_loading">Loading…</string>
<string name="text_not_available">None available</string>
<string name="text_off">Off</string>
<string name="text_auto">Auto</string>
<string name="text_custom">Custom</string>
<string name="text_fetching">Fetching…</string>
<string name="text_fetch_success">Fetch successful</string>
<string name="text_fetch_failed">Refresh failed: %1$s</string>
<string name="button_cancel">Cancel</string>
<string name="button_delete">Delete</string>
<string name="button_done">Done</string>
<string name="button_connect">Connect</string>
<string name="button_disconnect">Disconnect</string>
<string name="button_start">Start</string>
<string name="button_stop">Stop</string>
<string name="button_fullscreen">Fullscreen</string>
<string name="button_pair">Pair</string>
<string name="button_confirm">OK</string>
<string name="button_add_device">Add device</string>
<string name="button_direct_connect">Direct connect</string>
<string name="button_auto_discover">Auto discover</string>
<string name="button_discovering">Discovering…</string>
<string name="button_swap">Swap</string>
<string name="label_ip_address">IP address</string>
<string name="label_port">Port</string>
<string name="label_device_name">Device name</string>
<string name="label_name">Name</string>
<string name="label_wlan_pairing_code">WLAN pairing code</string>
<string name="label_resolution">Resolution</string>
<string name="label_codec">Codec</string>
<string name="label_status">Status</string>
<string name="label_fps">FPS</string>
<string name="label_touches">Touches</string>
<!-- Device Tab -->
<string name="device_title">Devices</string>
<string name="device_cd_config_right">Config panel on right</string>
<string name="device_cd_config_left">Config panel on left</string>
<string name="device_menu_quick_sort">Quick device sort</string>
<string name="device_menu_virtual_button_sort">Virtual button order</string>
<string name="device_menu_clear_logs">Clear logs</string>
<string name="device_hint_long_press_edit">Long press to edit</string>
<string name="device_cannot_modify_connected">Cannot modify a connected device</string>
<string name="device_added">Device added: %1$s:%2$s</string>
<string name="device_section_wireless_pairing">Wireless pairing</string>
<string name="device_section_scrcpy">Scrcpy</string>
<string name="device_section_logs">Logs</string>
<string name="device_pairing_title">Pair device using pairing code</string>
<string name="device_pairing_desc">Use a six-digit pairing code to pair a new device</string>
<string name="device_quick_connect_title">Connect / Add device</string>
<string name="device_status_mirroring">Mirroring</string>
<string name="device_status_resolution">Resolution</string>
<string name="device_status_codec">Codec</string>
<string name="device_status_adb_connected">ADB connected</string>
<string name="device_status_adb_disconnected">ADB disconnected</string>
<string name="device_status_current">Current device</string>
<string name="device_status_state">Status</string>
<string name="device_status_idle">Idle</string>
<string name="device_config_audio_forwarding">Audio forwarding</string>
<string name="device_config_audio_forwarding_desc">Forward device audio to this device (Android 11+)</string>
<string name="device_config_audio_codec">Audio codec</string>
<string name="device_config_audio_codec_note">Note: FLAC codec introduces significant latency</string>
<string name="device_config_audio_bitrate">Audio bitrate</string>
<string name="device_config_video_codec">Video codec</string>
<string name="device_config_video_codec_note">Note: The vast majority of devices do not support AV1 hardware encoding</string>
<string name="device_config_video_bitrate">Video bitrate</string>
<string name="device_config_more_params">More parameters</string>
<string name="device_config_all_params">All parameters</string>
<string name="device_config_all_scrcpy_params">All scrcpy parameters</string>
<string name="device_config_start_immediately">Start scrcpy immediately after connecting</string>
<string name="device_config_direct_fullscreen">Enter fullscreen directly</string>
<string name="device_config_scrcpy_config">scrcpy configuration</string>
<string name="device_switched_profile">%1$s switched to configuration %2$s</string>
<!-- Recent Tasks / All Apps -->
<string name="bottomsheet_recent_tasks">Recent tasks</string>
<string name="bottomsheet_loading_tasks">Loading recent tasks…</string>
<string name="bottomsheet_no_tasks">No recent tasks available</string>
<string name="bottomsheet_all_apps">All apps</string>
<string name="bottomsheet_loading_apps">Loading app list…</string>
<string name="bottomsheet_no_apps">No app list available</string>
<!-- Fullscreen Control -->
<string name="fullscreen_failed_app_list">Failed to get app list</string>
<string name="fullscreen_failed_tasks">Failed to get recent tasks</string>
<string name="fullscreen_paste_non_ascii">Non-ASCII text paste failed</string>
<string name="fullscreen_text_input_failed">Text input failed</string>
<string name="fullscreen_clipboard_empty">Local clipboard is empty or not text</string>
<string name="fullscreen_legacy_paste_failed">Legacy paste failed</string>
<string name="fullscreen_clipboard_sync_failed">Clipboard sync paste failed, try enabling --legacy-paste</string>
<string name="fullscreen_start_app_failed">Start app failed: %1$s</string>
<string name="fullscreen_debug_resolution">Resolution: %1$sx%2$s</string>
<string name="fullscreen_debug_fps">FPS: %1$s</string>
<string name="fullscreen_debug_touches">Touches: %1$d</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<!-- Settings - Update -->
<string name="pref_update_current">Current version %1$s</string>
<string name="pref_update_checking">, checking for updates</string>
<string name="pref_update_failed">, update check failed</string>
<string name="pref_update_found">, found new version %1$s</string>
<string name="pref_update_latest">, already the latest version</string>
<string name="pref_update_newer">, newer than latest release %1$s</string>
<!-- Settings - Theme -->
<string name="section_theme">Theme</string>
<string name="pref_title_appearance_mode">Appearance mode</string>
<string name="pref_summary_appearance_mode">Select the app\'s appearance mode</string>
<string name="pref_title_monet">Monet color</string>
<string name="pref_summary_monet">Uses Monet dynamic color when enabled</string>
<string name="pref_title_monet_key_color">Monet Key Color</string>
<string name="pref_summary_monet_key_color">Set Monet accent color</string>
<string name="pref_title_monet_palette_style">Monet Palette Style</string>
<string name="pref_summary_monet_palette_style">Set Monet palette style</string>
<string name="pref_title_monet_color_spec">Monet Color Spec</string>
<string name="pref_summary_monet_color_spec">Set Monet color specification</string>
<string name="pref_title_blur">Blur</string>
<string name="pref_summary_blur">Enable blur effect on top bar and bottom bar</string>
<string name="pref_title_floating_bottom_bar">Floating bottom bar</string>
<string name="pref_summary_floating_bottom_bar">Use Apple-style floating bottom bar</string>
<string name="pref_title_liquid_glass">Liquid glass</string>
<string name="pref_summary_liquid_glass">Enable liquid glass effect on the floating bottom bar</string>
<string name="pref_title_smooth_corners">Smooth corners</string>
<string name="pref_summary_smooth_corners">Enable global smooth corner effect</string>
<!-- Settings - Screen Mirroring -->
<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_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>
<string name="pref_summary_hide_simple_settings">When enabled, the device page only keeps More parameters, All apps, Recent tasks, and Start/Stop buttons</string>
<string name="pref_title_preview_card_height">Preview card height</string>
<string name="pref_summary_preview_card_height">Preview card height on device page</string>
<string name="pref_title_quick_device_sort">Quick device sort</string>
<string name="pref_summary_quick_device_sort">Manually sort quick devices on the device page</string>
<string name="pref_title_virtual_button_sort">Virtual button order</string>
<string name="pref_summary_virtual_button_sort">Manually sort virtual buttons in preview/fullscreen and select which are shown</string>
<string name="pref_title_password_autofill">Lockscreen password auto-fill</string>
<string name="pref_summary_password_autofill">Manage lockscreen passwords used for auto-fill</string>
<string name="pref_title_clipboard_sync">Realtime clipboard sync to device</string>
<string name="pref_summary_clipboard_sync">When local clipboard is updated, it is automatically synced to the controlled device.\nAfter disabling, use the Paste virtual button to paste local content.\nMIUI does not allow background clipboard monitoring at all, so this option may not work on Xiaomi devices</string>
<!-- Settings - Fullscreen -->
<string name="section_fullscreen">Fullscreen</string>
<string name="pref_title_ignore_rotation_lock">Don\'t follow system rotation lock in fullscreen</string>
<string name="pref_summary_ignore_rotation_lock">When enabled, uses sensor orientation, ignoring system auto-rotation lock</string>
<string name="pref_title_back_to_device">Send BACK to device in fullscreen</string>
<string name="pref_summary_back_to_device">When enabled, the system back key is sent to the device and no longer exits the fullscreen control page.\nTo exit fullscreen, return to the desktop and re-enter via the app icon</string>
<string name="pref_title_show_virtual_buttons">Show virtual buttons in fullscreen</string>
<string name="pref_summary_show_virtual_buttons">Show Back, Home and other virtual buttons on the fullscreen control page</string>
<string name="pref_title_virtual_button_direction">Virtual button direction</string>
<string name="pref_title_virtual_button_height">Virtual button height</string>
<string name="pref_title_show_floating_button">Show floating button in fullscreen</string>
<string name="pref_summary_show_floating_button">Show a draggable floating button on the fullscreen control page; tap to open the full virtual button menu</string>
<string name="pref_title_floating_button_size">Floating button size</string>
<string name="pref_title_floating_button_bg_opacity">Floating button background opacity</string>
<string name="pref_title_floating_button_ring_opacity">Floating button ring opacity</string>
<string name="pref_title_fullscreen_compat_mode">Fullscreen compatibility mode</string>
<string name="pref_summary_fullscreen_compat_mode">When enabled, the fullscreen control page no longer spans Activities; Picture-in-Picture will be unavailable</string>
<!-- Settings - scrcpy-server -->
<string name="section_scrcpy_server">scrcpy-server</string>
<string name="pref_title_custom_binary">Custom binary</string>
<string name="pref_title_custom_binary_version">Custom binary version</string>
<string name="pref_title_remote_path">Remote Path</string>
<!-- Settings - ADB -->
<string name="section_adb">ADB</string>
<string name="pref_title_battery_optimization">Adjust background battery optimization</string>
<string name="pref_summary_battery_optimization">Fix ADB disconnection when Scrcpy switches to background and cannot reach the network.\nApp battery usage -> Allow background usage -> Unrestricted.\nModified power settings in domestic ROMs can generally be found in the corresponding modified app settings</string>
<string name="pref_title_app_info">App info</string>
<string name="pref_title_custom_adb_key">Custom ADB key name</string>
<string name="pref_title_adb_private_key">ADB private key</string>
<string name="pref_title_adb_public_key">ADB public key</string>
<string name="pref_adb_key_not_imported">Not imported</string>
<string name="pref_adb_key_imported">Imported</string>
<string name="pref_adb_key_generated">Generated</string>
<string name="pref_adb_private_key_imported">Private key imported, public key fingerprint: %1$s</string>
<string name="pref_adb_public_key_imported_snackbar">Public key imported, fingerprint: %1$s</string>
<string name="pref_adb_key_reset">ADB key regenerated, public key fingerprint: %1$s</string>
<string name="pref_adb_key_removed">Imported ADB key removed, active public key fingerprint: %1$s</string>
<string name="pref_adb_key_import_failed">ADB key import failed: %1$s</string>
<string name="pref_adb_key_remove">Remove</string>
<string name="pref_adb_key_regenerate">Regenerate</string>
<string name="pref_title_auto_discovery">Auto enable discovery service when pairing</string>
<string name="pref_summary_auto_discovery">Automatically search for available pairing ports after opening the pairing dialog</string>
<string name="pref_title_auto_reconnect">Auto reconnect paired devices</string>
<string name="pref_summary_auto_reconnect">Automatically discover devices with wireless debugging enabled, update quick device random ports, and attempt connection (results may vary)</string>
<string name="pref_title_auto_load_apps">Auto fetch app list after connection</string>
<string name="pref_summary_auto_load_apps">Execute --list-apps immediately after successful ADB connection to populate app names in recent tasks list</string>
<string name="pref_warning_list_apps">--list-apps may be very time-consuming (especially when screen is off); enabling may cause excessive blocking after connecting to the device!</string>
<string name="pref_cannot_open_settings">Cannot open settings</string>
<!-- Settings - Terminal -->
<string name="section_terminal">Terminal</string>
<string name="pref_title_terminal_font_size">Terminal font size</string>
<string name="pref_summary_terminal_font_size">Can also be adjusted with pinch-to-zoom on the terminal</string>
<string name="pref_title_custom_font">Custom terminal font</string>
<string name="pref_hint_builtin_font">Built-in monospace font</string>
<string name="pref_font_restored">Restored default terminal font</string>
<string name="pref_no_custom_font">No custom font to clear currently</string>
<!-- Settings - Miscellaneous -->
<string name="section_misc">Miscellaneous</string>
<string name="pref_title_clear_logs_on_exit">Clear logs when exiting app</string>
<string name="pref_summary_clear_logs_on_exit">Clear logs when double-pressing back to exit the app</string>
<string name="pref_title_hide_log_box">Hide device page log box</string>
<string name="pref_summary_hide_log_box">Hide the log box at the bottom of the device page</string>
<!-- Terminal -->
<string name="terminal_title">Terminal</string>
<string name="terminal_menu_free_copy">Free copy</string>
<string name="terminal_menu_clear_screen">Clear screen</string>
<string name="terminal_copied_all">Copied all terminal output</string>
<string name="terminal_copied">Copied to clipboard</string>
<string name="terminal_copy_all">Copy all</string>
<string name="terminal_no_output">No output currently</string>
<string name="terminal_output_label">Terminal output</string>
<string name="terminal_font_size_snackbar">Terminal font size %1$dsp</string>
<string name="terminal_font_size_restore_default">Restore default</string>
<!-- Virtual Button Order -->
<string name="vb_order_title">Virtual button order</string>
<string name="vb_order_button_text">Button display text</string>
<string name="vb_order_hint">Exceed 3, recommend hiding, only effective for virtual buttons below the preview card</string>
<string name="vb_order_display_external">Display on external</string>
<string name="vb_order_display_more_menu">Display in more menu</string>
<!-- Virtual Button Actions -->
<string name="vb_more">More</string>
<string name="vb_home">Home</string>
<string name="vb_back">Back</string>
<string name="vb_app_switch">App switch</string>
<string name="vb_menu">Menu</string>
<string name="vb_notifications">Notifications</string>
<string name="vb_volume_up">Volume up</string>
<string name="vb_volume_down">Volume down</string>
<string name="vb_volume_mute">Mute</string>
<string name="vb_lock_screen">Lock screen</string>
<string name="vb_screenshot">Screenshot</string>
<string name="vb_fill_password">Fill lockscreen password</string>
<string name="vb_all_apps">All apps</string>
<string name="vb_recent_tasks">Recent tasks</string>
<string name="vb_toggle_ime">Toggle IME</string>
<string name="vb_paste_clipboard">Paste local clipboard</string>
<!-- Dock Positions -->
<string name="dock_follow">Follow</string>
<string name="dock_fixed">Fixed</string>
<string name="dock_top">Top</string>
<string name="dock_bottom">Bottom</string>
<string name="dock_left">Left</string>
<string name="dock_right">Right</string>
<string name="dock_display_on"> show at screen </string>
<!-- Theme Modes -->
<string name="theme_follow_system">Follow system</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<!-- Recording -->
<string name="record_title">Recording</string>
<string name="record_format">Recording format</string>
<string name="record_filename_hint">Filename, enabled when not empty</string>
<string name="record_preview">Preview:</string>
<string name="record_plain_text">Plain text</string>
<string name="record_desc_yyyy">Four-digit year, e.g. 2026</string>
<string name="record_desc_yy">Two-digit year, e.g. 26</string>
<string name="record_desc_MM">Month, two digits, e.g. 09</string>
<string name="record_desc_M">Month, one or two digits, e.g. 9</string>
<string name="record_desc_dd">Day, two digits, e.g. 19</string>
<string name="record_desc_d">Day, one or two digits, e.g. 9</string>
<string name="record_desc_HH">24-hour, two digits, e.g. 23</string>
<string name="record_desc_H">24-hour, one or two digits, e.g. 9</string>
<string name="record_desc_hh">12-hour, two digits, e.g. 11</string>
<string name="record_desc_h">12-hour, one or two digits, e.g. 9</string>
<string name="record_desc_mm">Minutes, two digits, e.g. 09</string>
<string name="record_desc_m">Minutes, one or two digits, e.g. 9</string>
<string name="record_desc_ss">Seconds, two digits, e.g. 09</string>
<string name="record_desc_s">Seconds, one or two digits, e.g. 9</string>
<string name="record_desc_timestamp">Unix timestamp in seconds, e.g. 1776952809</string>
<string name="record_desc_device_name">Device name</string>
<string name="record_desc_device_ip">Device IP</string>
<string name="record_desc_device_port">Device port</string>
<string name="record_desc_video_codec">Video stream codec (not actual file encoding)</string>
<string name="record_desc_audio_codec">Audio stream codec (not actual file encoding)</string>
<string name="record_desc_width">Video width</string>
<string name="record_desc_height">Video height</string>
<!-- Reorder Devices -->
<string name="reorder_devices_title">Quick device sort</string>
<!-- Lockscreen Password -->
<string name="password_stop_mirroring">Stop screen mirroring</string>
<string name="password_authenticate_create">Authenticate to create a new password</string>
<string name="password_authenticate_subtitle">Verify your identity to continue</string>
<string name="password_auth_to_disable">Authenticate to disable password protection</string>
<string name="password_auth_subtitle">Verify your identity to continue</string>
<string name="password_auth_fill_title">Authenticate to fill lockscreen password</string>
<string name="password_fill_failed">Password fill failed</string>
<string name="password_no_available">No available passwords</string>
<string name="password_cannot_auth_here">Cannot authenticate on this page</string>
<string name="password_auth_failed">Authentication failed</string>
<string name="password_security_state_changed">Device security state has changed, please reset password</string>
<string name="password_expired">Password has expired, please reset</string>
<string name="password_autofill_title">Lockscreen Password Auto-fill</string>
<string name="password_create_new">Create new password</string>
<string name="password_no_lock_screen">Current device does not have lock screen protection set</string>
<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_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_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>
<string name="password_status_authenticated">Authenticated when created</string>
<string name="password_status_unauthenticated">Unauthenticated when created</string>
<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>
<!-- File Manager -->
<string name="fm_cd_parent">Parent directory</string>
<string name="fm_cd_sort">Sort</string>
<string name="fm_sort_name">Name</string>
<string name="fm_sort_size">Size</string>
<string name="fm_sort_time">Time</string>
<string name="fm_sort_extension">Extension</string>
<string name="fm_sort_asc">Ascending</string>
<string name="fm_sort_desc">Descending</string>
<string name="fm_menu_create_folder">Create folder</string>
<string name="fm_menu_upload">Upload file to this directory</string>
<string name="fm_loading_details">Loading details…</string>
<string name="fm_no_details">No details</string>
<string name="fm_pull_refresh">Pull to refresh</string>
<string name="fm_release_refresh">Release to refresh</string>
<string name="fm_refreshing">Refreshing…</string>
<string name="fm_refresh_done">Refresh done</string>
<string name="fm_load_failed">Load failed: %1$s</string>
<string name="fm_empty_dir">Empty directory</string>
<string name="fm_file_details">File Details</string>
<string name="fm_show_raw">Show raw</string>
<string name="fm_show_parsed">Show parsed</string>
<string name="fm_cd_download">Download</string>
<string name="fm_goto_path">Go to path</string>
<string name="fm_title_create_folder">Create folder</string>
<string name="fm_label_new_folder">New folder</string>
<string name="fm_button_create">Create</string>
<string name="fm_snack_link_unavailable">Link target unavailable, long press for details</string>
<string name="fm_snack_not_dir">Link target is not a directory, long press for details</string>
<string name="fm_snack_link_read_failed">Failed to read link target: %1$s</string>
<string name="fm_snack_long_press_details">Long press to view file details</string>
<string name="fm_snack_folder_name_empty">Folder name cannot be empty</string>
<string name="fm_snack_folder_created">Folder created</string>
<string name="fm_snack_create_failed">Create failed: %1$s</string>
<string name="fm_snack_uploaded">Uploaded to %1$s</string>
<string name="fm_snack_upload_failed">Upload failed: %1$s</string>
<string name="fm_snack_read_details_failed">Failed to read details: %1$s</string>
<string name="fm_snack_scan_failed">Directory scan failed: %1$s</string>
<string name="fm_snack_download_starting">Starting download</string>
<string name="fm_snack_dir_loading">Directory info still loading, please try again later</string>
<string name="fm_snack_downloaded">Downloaded to Download/Scrcpy</string>
<string name="fm_snack_cannot_save">Cannot write directly to Download/Scrcpy, please select a save directory</string>
<string name="fm_snack_download_complete">Download complete</string>
<string name="fm_snack_download_failed">Download failed: %1$s</string>
<string name="terminal_snack_input_failed">Terminal input failed: %1$s</string>
<string name="terminal_snack_session_failed">Terminal session creation failed: %1$s</string>
<string name="terminal_snack_output_failed">Terminal output failed: %1$s</string>
<string name="fm_exception_cannot_read_filename">Cannot read file name</string>
<string name="fm_exception_cannot_read_selected_file">Cannot read selected file</string>
<string name="fm_exception_folder_name_empty">Folder name cannot be empty</string>
<string name="fm_exception_cannot_write_target">Cannot write target file</string>
<string name="fm_exception_directory_not_found">Directory not found</string>
<string name="fm_exception_not_directory">Target is not a directory</string>
<string name="fm_exception_cannot_create_dir">Cannot create directory</string>
<string name="fm_exception_cannot_create_file">Cannot create file</string>
<string name="fm_exception_remote_shell_closed">Remote shell closed</string>
<string name="fm_exception_command_failed">Command failed</string>
<string name="fm_stat_path">Path</string>
<string name="fm_stat_type">Type</string>
<string name="fm_stat_size">Size</string>
<string name="fm_stat_blocks">Blocks</string>
<string name="fm_stat_io_block_size">IO block size</string>
<string name="fm_stat_hard_links">Hard links</string>
<string name="fm_stat_permissions">Permissions</string>
<string name="fm_stat_device">Device</string>
<string name="fm_stat_device_type">Device type</string>
<string name="fm_stat_access_time">Access time</string>
<string name="fm_stat_modify_time">Modify time</string>
<string name="fm_stat_change_time">Change time</string>
<string name="fm_stat_link_target">Link target</string>
<string name="fm_stat_directories">Directories</string>
<string name="fm_stat_files">Files</string>
<string name="fm_stat_target_info">Target info</string>
<string name="fm_unknown">Unknown</string>
<!-- Scrcpy All Options -->
<string name="profile_default_name">Profile</string>
<string name="profile_new_name">New Profile</string>
<string name="scrcpyopt_title">All Parameters</string>
<string name="scrcpyopt_cd_profile_manage">Profile Management</string>
<string name="scrcpyopt_manage_profiles">Manage Profiles</string>
<string name="scrcpyopt_new_profile">New Profile</string>
<string name="scrcpyopt_rename_profile">Rename Profile</string>
<string name="scrcpyopt_duplicate_name_hint">Duplicate profile names will have a number appended automatically</string>
<string name="scrcpyopt_profile_name">Profile Name</string>
<string name="scrcpyopt_copy_from">Copy From Profile</string>
<string name="scrcpyopt_current_profile">Current Profile</string>
<string name="scrcpyopt_delete_profile">Delete Profile</string>
<string name="scrcpyopt_delete_confirm">Confirm deletion of \"%1$s\"?</string>
<string name="scrcpyopt_prefer_text">Prefer Text</string>
<string name="scrcpyopt_raw_key_events">Raw Key Events</string>
<string name="scrcpyopt_default_mixed">Default</string>
<string name="scrcpyopt_dont_modify">Don\'t Modify</string>
<string name="scrcpyopt_invalid_options">Invalid options: %1$s</string>
<string name="scrcpyopt_turn_screen_off">Turn device screen off after scrcpy starts</string>
<string name="scrcpyopt_turn_screen_off_note">Note: On most devices, the refresh rate decreases/is halved when the screen is turned off</string>
<string name="scrcpyopt_no_control">Disable device control (mirror the device in read-only)</string>
<string name="scrcpyopt_no_video">Disable video forwarding</string>
<string name="scrcpyopt_no_video_playback">Disable video playback on the computer</string>
<string name="scrcpyopt_no_audio">Disable audio forwarding</string>
<string name="scrcpyopt_no_audio_playback">Disable audio playback on the computer</string>
<string name="scrcpyopt_screen_off_timeout">Controlled device\'s screen-off timeout after scrcpy starts</string>
<string name="scrcpyopt_no_power_on">Do not power on the device on start</string>
<string name="scrcpyopt_power_off_on_close">Turn the device screen off when closing scrcpy</string>
<string name="scrcpyopt_stay_awake">Keep the device on while scrcpy is running, when the device is plugged in</string>
<string name="scrcpyopt_show_touches">Enable \"show touches\" on start, restore the initial value on exit</string>
<string name="scrcpyopt_fullscreen">Start in fullscreen</string>
<string name="scrcpyopt_disable_screensaver">Disable screensaver while scrcpy is running</string>
<string name="scrcpyopt_disable_screensaver_note">Be aware of burn-in; this also affects picture-in-picture</string>
<string name="scrcpyopt_kill_adb_on_close">Kill adb when scrcpy terminates</string>
<string name="scrcpyopt_audio_codec">Audio codec</string>
<string name="scrcpyopt_audio_bitrate">Audio bitrate</string>
<string name="scrcpyopt_video_codec">Video codec</string>
<string name="scrcpyopt_video_bitrate">Video bitrate</string>
<string name="scrcpyopt_video_source">Video source</string>
<string name="scrcpyopt_display_id">Display ID</string>
<string name="scrcpyopt_max_size">Max resolution</string>
<string name="scrcpyopt_max_fps">Max framerate</string>
<string name="scrcpyopt_camera_id">Camera ID</string>
<string name="scrcpyopt_camera_facing">Camera facing</string>
<string name="scrcpyopt_camera_size">Camera resolution</string>
<string name="scrcpyopt_camera_fps">Camera framerate</string>
<string name="scrcpyopt_camera_high_speed">High framerate mode</string>
<string name="scrcpyopt_audio_source">Audio source</string>
<string name="scrcpyopt_audio_dup">Duplicate audio (capture and keep playing on the device)</string>
<string name="scrcpyopt_require_audio">Terminate when audio forwarding fails</string>
<string name="scrcpyopt_video_encoder">Video encoder</string>
<string name="scrcpyopt_audio_encoder">Audio encoder</string>
<string name="scrcpyopt_ime_display_policy">IME display policy</string>
<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_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_start_app">Open app after scrcpy starts</string>
<string name="scrcpyopt_native">Native</string>
<string name="scrcpyopt_log_level">Log level</string>
<!-- DeviceTabViewModel snackbar & log messages -->
<string name="vm_adb_connected">ADB connected</string>
<string name="vm_scrcpy_started">scrcpy started</string>
<string name="vm_scrcpy_started_recording">scrcpy started and recording</string>
<string name="vm_scrcpy_stopped_adb_disconnected">scrcpy stopped, ADB disconnected</string>
<string name="vm_scrcpy_stopped">scrcpy stopped</string>
<string name="vm_adb_connection_failed">ADB connection failed</string>
<string name="vm_adb_disconnected">ADB disconnected</string>
<string name="vm_pairing_succeeded">Pairing succeeded</string>
<string name="vm_pairing_failed">Pairing failed</string>
<string name="vm_auto_reconnect_succeeded">ADB auto-reconnect succeeded</string>
<string name="vm_auto_reconnect_failed">ADB auto-reconnect failed</string>
<string name="vm_start_scrcpy">Start scrcpy</string>
<string name="vm_stop_scrcpy">Stop scrcpy</string>
<string name="vm_launch_app">Launch app</string>
<string name="vm_connect_adb">Connect ADB</string>
<string name="vm_disconnect_adb">Disconnect ADB</string>
<string name="vm_execute_pairing">Execute pairing</string>
<string name="vm_send_action">Send %1$s</string>
<string name="vm_label_timeout">%1$s timed out</string>
<string name="vm_label_param_error">%1$s parameter error: %2$s</string>
<string name="vm_label_failed">%1$s failed: %2$s</string>
<string name="vm_app_started_on_display">App started on current display: %1$s</string>
<string name="vm_start_app_fallback_adb">Failed to start app via scrcpy control channel, falling back to ADB</string>
<string name="vm_app_started_via_adb">App started via ADB: %1$s</string>
<string name="vm_adb_connected_detail">ADB connected: model=%1$s</string>
<string name="vm_failed_app_list_msg">Failed to get app list: %1$s</string>
<string name="vm_scrcpy_requested_app">Scrcpy requested to start app: %1$s</string>
<string name="vm_scrcpy_start_app_failed">Failed to start app via scrcpy control channel: %1$s</string>
<string name="vm_scrcpy_started_detail">scrcpy started: device=%1$s</string>
<string name="vm_scrcpy_stopped_adb_disconnected_log">scrcpy stopped, ADB disconnected</string>
<string name="vm_failed_recent_tasks_msg">Failed to get recent tasks: %1$s</string>
<string name="vm_legacy_paste_injected">Used legacy paste to inject local clipboard text</string>
<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_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>
<string name="vm_quick_probe_success">ADB quick probe connection succeeded: %1$s:%2$s</string>
<string name="vm_device_switched_profile">Current connected device switched to profile: %1$s</string>
</resources>