From f3a5cc53f69fa29bac1d60e45c93c36244fb2d7a Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Thu, 30 Apr 2026 19:12:36 +0800 Subject: [PATCH] refactor: implement I18N - fix IPv6 parsing - Domain support - ADB key pair manually importing --- CHANGELOG.md | 8 +- app/build.gradle.kts | 4 +- .../LockscreenPasswordActivity.kt | 150 ++--- .../miuzarte/scrcpyforandroid/MainActivity.kt | 1 + .../scrcpyforandroid/StreamActivity.kt | 4 +- .../scrcpyforandroid/constants/ThemeModes.kt | 16 - .../scrcpyforandroid/models/DeviceModels.kt | 47 +- .../nativecore/DirectAdbClient.kt | 359 +++++++++++- .../scrcpyforandroid/pages/AboutScreen.kt | 16 +- .../scrcpyforandroid/pages/DeviceTabScreen.kt | 67 ++- .../pages/DeviceTabViewModel.kt | 314 +++++----- .../pages/FileManagerScreen.kt | 86 +-- .../pages/FileManagerViewModel.kt | 68 ++- .../pages/FullscreenControlScreen.kt | 62 +- .../scrcpyforandroid/pages/MainScreen.kt | 58 +- .../pages/RecordPreferencesScreen.kt | 29 +- .../pages/ReorderDevicesScreen.kt | 4 +- .../pages/ScrcpyAllOptionsScreen.kt | 405 +++++++------ .../scrcpyforandroid/pages/SettingsScreen.kt | 453 +++++++++----- .../scrcpyforandroid/pages/StreamScreen.kt | 4 + .../scrcpyforandroid/pages/TerminalScreen.kt | 56 +- .../pages/TerminalViewModel.kt | 60 +- .../pages/VirtualButtonOrderScreen.kt | 32 +- .../password/BiometricGate.kt | 4 +- .../password/PasswordPickerPopup.kt | 32 +- .../password/PasswordUseCase.kt | 27 +- .../scaffolds/OverlaySpinnerWithFallback.kt | 9 +- .../scaffolds/ReorderableList.kt | 10 +- .../scrcpyforandroid/scaffolds/SuperSlider.kt | 6 +- .../scrcpyforandroid/scrcpy/Scrcpy.kt | 25 +- .../scrcpyforandroid/scrcpy/Shared.kt | 4 +- .../scrcpyforandroid/services/AppRuntime.kt | 93 +++ .../services/AppUpdateChecker.kt | 8 +- .../services/ConnectionController.kt | 4 +- .../services/ConnectionStateStore.kt | 6 +- .../services/DeviceAdbAutoReconnectManager.kt | 4 + .../services/DeviceAdbBackgroundRunner.kt | 2 + .../DeviceAdbConnectionCoordinator.kt | 23 +- .../scrcpyforandroid/services/EventLogger.kt | 68 ++- .../services/FileManagerService.kt | 116 ++-- .../services/RecordFilenameTemplate.kt | 48 +- .../scrcpyforandroid/storage/AdbClientData.kt | 32 + .../scrcpyforandroid/storage/AppSettings.kt | 51 +- .../scrcpyforandroid/storage/ScrcpyOptions.kt | 4 +- .../storage/ScrcpyProfiles.kt | 37 +- .../widgets/AppBottomSheets.kt | 4 +- .../scrcpyforandroid/widgets/DeviceWidgets.kt | 192 +++--- .../widgets/RecordPreferences.kt | 18 +- .../widgets/StatusCardModels.kt | 17 +- .../widgets/VirtualButtons.kt | 82 +-- app/src/main/res/resources.properties | 1 + app/src/main/res/values-zh-rCN/strings.xml | 554 ++++++++++++++++++ app/src/main/res/values/strings.xml | 553 ++++++++++++++++- 53 files changed, 3184 insertions(+), 1153 deletions(-) delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt create mode 100644 app/src/main/res/resources.properties create mode 100644 app/src/main/res/values-zh-rCN/strings.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 257e773..3f9f22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,15 @@ ## 0.3.0 -该版本主要进行代码重构,引入 ViewModel 以优化代码结构 - +- 重构: 引入 ViewModel - 重构: 部分自行实现的组件迁移至 miuix - 引入子模块以在本地编译 miuix 的最新提交 - 新增: 所有参数页中的五个列表展开即触发懒加载 +- 新增: 多语言支持 +- 修复: IPv6 输入 +- 新增: 域名解析 +- 新增: 手动导入 ADB 密钥对 +- 修复: snackbar 跨 activity ## 0.2.9 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d997b2b..b3a2587 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt index 248c0b3..f3aba1d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt @@ -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( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index 87d934c..fd25e64 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -21,6 +21,7 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) applyMainOrientationPolicy() + // no logEvent before context init AppRuntime.init(applicationContext) AppScreenOn.register(window) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt index 1f66119..b063193 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt @@ -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), ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt deleted file mode 100644 index 5312e96..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt +++ /dev/null @@ -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), - ) -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 6b57c11..3089b42 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -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) } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index 0d49a57..c87d3f9 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -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 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? = null + + private fun keys(): Pair = + 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 { 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 } /** diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt index eabf336..9123664 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt @@ -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( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index 6e2d997..d63b3ec 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt index b2bde85..f2a3628 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt @@ -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 = 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 = + 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 = combine( adbConnected, @@ -228,9 +233,6 @@ internal class DeviceTabViewModel( ), ) - private val _snackbarEvents = Channel(Channel.BUFFERED) - val snackbarEvents: Flow = _snackbarEvents.receiveAsFlow() - private val _fullscreenRequests = Channel(Channel.BUFFERED) val fullscreenRequests: Flow = _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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt index ec21a71..1ab7618 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt @@ -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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt index a464868..3381d3f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt @@ -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(null) val pendingTreeDownload: StateFlow = _pendingTreeDownload.asStateFlow() - private val _snackbarEvents = Channel(Channel.BUFFERED) - val snackbarEvents: Flow = _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, + ) + } } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 1d84d43..80058f2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -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, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index a8e09ec..2e586dd 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RecordPreferencesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RecordPreferencesScreen.kt index bab4951..39ffd4b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RecordPreferencesScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RecordPreferencesScreen.kt @@ -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), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt index 2a686b5..abbad48 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt @@ -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, ) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index dfb46be..8f558de 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -1,6 +1,5 @@ package io.github.miuzarte.scrcpyforandroid.pages -import android.annotation.SuppressLint import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,10 +39,12 @@ import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources +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 import androidx.compose.ui.unit.dp +import io.github.miuzarte.scrcpyforandroid.R import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.miuix.SpinnerEntry @@ -65,7 +66,6 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource import io.github.miuzarte.scrcpyforandroid.services.AppRuntime -import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles import io.github.miuzarte.scrcpyforandroid.storage.Settings @@ -119,7 +119,6 @@ internal fun ScrcpyAllOptionsScreen( val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) val blurActive = blurBackdrop != null val scope = rememberCoroutineScope() - val snackbar = LocalSnackbarController.current var showProfileMenu by rememberSaveable { mutableStateOf(false) } var showManageProfilesSheet by rememberSaveable { mutableStateOf(false) } val qdBundleShared by quickDevices.bundleState.collectAsState() @@ -142,13 +141,11 @@ internal fun ScrcpyAllOptionsScreen( var selectedProfileId by selectedProfileIdState val soBundleState = rememberSaveable(selectedProfileId, soBundleShared, scrcpyProfilesState) { mutableStateOf( - if (selectedProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { + if (selectedProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) soBundleShared - } else { - scrcpyProfilesState.profiles - .firstOrNull { it.id == selectedProfileId } - ?.bundle ?: soBundleShared - } + else scrcpyProfilesState.profiles + .firstOrNull { it.id == selectedProfileId } + ?.bundle ?: soBundleShared ) } val lastValidSoBundleState = rememberSaveable(selectedProfileId) { @@ -165,15 +162,12 @@ internal fun ScrcpyAllOptionsScreen( } val currentConnectedDeviceName = remember(qdBundleShared.quickDevicesList) { val currentTarget = AppRuntime.currentConnectionTarget - if (currentTarget == null) { - null - } else { - DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList) - .get(currentTarget.host, currentTarget.port) - ?.name - ?.ifBlank { currentTarget.host } - ?: currentTarget.host - } + if (currentTarget == null) null + else DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList) + .get(currentTarget.host, currentTarget.port) + ?.name + ?.ifBlank { currentTarget.host } + ?: currentTarget.host } var activeProfileDialog by rememberSaveable { mutableStateOf(null) } var profileDialogTargetId by rememberSaveable { mutableStateOf(null) } @@ -182,22 +176,19 @@ internal fun ScrcpyAllOptionsScreen( var deletingProfileId by rememberSaveable { mutableStateOf(null) } suspend fun saveBundleForProfile(profileId: String, bundle: ScrcpyOptions.Bundle) { - if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { + if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) scrcpyOptions.saveBundle(bundle) - } else { + else scrcpyProfiles.updateBundle(profileId, bundle) - } } suspend fun rebindDeletedProfileReferences(profileId: String) { val shortcuts = DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList) val updated = shortcuts.copy( devices = shortcuts.map { device -> - if (device.scrcpyProfileId == profileId) { + if (device.scrcpyProfileId == profileId) device.copy(scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID) - } else { - device - } + else device } ) if (updated != shortcuts) { @@ -226,15 +217,15 @@ internal fun ScrcpyAllOptionsScreen( topBar = { BlurredBar(backdrop = blurBackdrop) { TopAppBar( - title = "所有参数", + title = stringResource(R.string.scrcpyopt_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), ) } }, @@ -246,7 +237,7 @@ internal fun ScrcpyAllOptionsScreen( ) { Icon( imageVector = MiuixIcons.More, - contentDescription = "配置管理", + contentDescription = stringResource(R.string.scrcpyopt_cd_profile_manage), ) } OverlayListPopup( @@ -257,7 +248,7 @@ internal fun ScrcpyAllOptionsScreen( ) { ListPopupColumn { DropdownImpl( - text = "管理配置", + text = stringResource(R.string.scrcpyopt_manage_profiles), optionSize = 1, isSelected = false, index = 0, @@ -272,6 +263,7 @@ internal fun ScrcpyAllOptionsScreen( }, scrollBehavior = scrollBehavior, bottomContent = { + val textGlobal = stringResource(R.string.text_global) TabRow( tabs = profileTabs, selectedTabIndex = selectedProfileIndex, @@ -283,9 +275,13 @@ internal fun ScrcpyAllOptionsScreen( saveBundleForProfile(selectedProfileId, soBundleState.value) bindCurrentConnectedDevice(nextProfileId) selectedProfileId = nextProfileId - val profileName = profileTabs.getOrElse(index) { "全局" } + val profileName = profileTabs.getOrElse(index) { textGlobal } currentConnectedDeviceName?.let { deviceName -> - snackbar.show("$deviceName 已切换到配置 $profileName") + AppRuntime.snackbar( + R.string.device_switched_profile, + deviceName, + profileName, + ) } } }, @@ -301,15 +297,12 @@ internal fun ScrcpyAllOptionsScreen( ) } }, - snackbarHost = { - val snackbar = LocalSnackbarController.current - SnackbarHost(snackbar.hostState) - }, + snackbarHost = { AppRuntime.snackbarHostState?.let { SnackbarHost(it) } }, ) { contentPadding -> Box( modifier = if (blurActive) Modifier.layerBackdrop(blurBackdrop) - else Modifier, + else Modifier ) { ScrcpyAllOptionsPage( contentPadding = contentPadding, @@ -370,6 +363,7 @@ internal fun ScrcpyAllOptionsScreen( } } + val textNewProfile = stringResource(R.string.scrcpyopt_new_profile) ManageProfilesSheet( show = showManageProfilesSheet, profiles = scrcpyProfilesState.profiles, @@ -377,7 +371,7 @@ internal fun ScrcpyAllOptionsScreen( onDismissRequest = { showManageProfilesSheet = false }, onCreateProfile = { profileDialogTargetId = null - profileDialogInput = "新配置" + profileDialogInput = textNewProfile profileDialogCopySourceId = selectedProfileId activeProfileDialog = ProfileDialogMode.Create }, @@ -410,9 +404,8 @@ internal fun ScrcpyAllOptionsScreen( val deleted = scrcpyProfiles.deleteProfile(profileId) if (deleted) { rebindDeletedProfileReferences(profileId) - if (selectedProfileId == profileId) { + if (selectedProfileId == profileId) selectedProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID - } } deletingProfileId = null } @@ -436,7 +429,6 @@ internal fun ScrcpyAllOptionsPage( val resources = LocalResources.current val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) } - val snackbar = LocalSnackbarController.current val refreshBusy by scrcpy.listings.refreshBusyState.collectAsState() val listRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState() @@ -501,9 +493,14 @@ internal fun ScrcpyAllOptionsPage( .coerceAtLeast(0) } + val textAuto = stringResource(R.string.text_auto) + val textDefault = stringResource(R.string.text_default) + val textNone = stringResource(R.string.text_none) + val textCustom = stringResource(R.string.text_custom) + val displays = scrcpy.listings.displays - val displayDropdownItems = rememberSaveable(displays, listRefreshVersion) { - listOf("默认") + displays.map { "${it.id} (${it.width}x${it.height})" } + val displayDropdownItems = rememberSaveable(textDefault, displays, listRefreshVersion) { + listOf(textDefault) + displays.map { "${it.id} (${it.width}x${it.height})" } } val displaySpinnerItems = remember(displayDropdownItems) { displayDropdownItems.map { SpinnerEntry(title = it) } @@ -523,8 +520,8 @@ internal fun ScrcpyAllOptionsPage( } val cameras = scrcpy.listings.cameras - val cameraDropdownItems = rememberSaveable(cameras, listRefreshVersion) { - listOf("默认") + cameras.map { info -> + val cameraDropdownItems = rememberSaveable(textDefault, cameras, listRefreshVersion) { + listOf(textDefault) + cameras.map { info -> buildString { append(info.id) append(" (") @@ -554,19 +551,17 @@ internal fun ScrcpyAllOptionsPage( else null } - val cameraFacingItems = rememberSaveable { - listOf("默认") + CameraFacing.entries + val cameraFacingItems = rememberSaveable(textDefault) { + listOf(textDefault) + CameraFacing.entries .drop(1) .map { it.string } } val cameraFacingIndex = rememberSaveable(soBundle.cameraFacing) { - if (soBundle.cameraFacing.isEmpty()) { - 0 - } else { - val idx = CameraFacing.entries - .indexOfFirst { it.string == soBundle.cameraFacing } - if (idx > 0) idx else 0 - } + if (soBundle.cameraFacing.isEmpty()) 0 + else CameraFacing.entries + .indexOfFirst { it.string == soBundle.cameraFacing } + .takeIf { it > 0 } + ?: 0 } var cameraSizeCustomInput by rememberSaveable(soBundle.cameraSizeCustom) { @@ -574,8 +569,13 @@ internal fun ScrcpyAllOptionsPage( } val cameraSizes = scrcpy.listings.cameraSizes - val cameraSizeDropdownItems = rememberSaveable(cameraSizes, listRefreshVersion) { - listOf("默认", "自定义") + cameraSizes + val cameraSizeDropdownItems = rememberSaveable( + textDefault, + textCustom, + cameraSizes, + listRefreshVersion, + ) { + listOf(textDefault, textCustom) + cameraSizes } val cameraSizeSpinnerItems = remember(cameraSizeDropdownItems) { cameraSizeDropdownItems.map { SpinnerEntry(title = it) } @@ -630,8 +630,20 @@ internal fun ScrcpyAllOptionsPage( .indexOfFirst { it.string == soBundle.audioSource } .coerceAtLeast(0) } - val keyInjectModeItems = rememberSaveable { - listOf("默认", "优先文本", "原始按键事件") + + val keyInjectModeTextDefaultMixed = stringResource(R.string.scrcpyopt_default_mixed) + val keyInjectModeTextPreferText = stringResource(R.string.scrcpyopt_prefer_text) + val keyInjectModeTextRawKeyEvent = stringResource(R.string.scrcpyopt_raw_key_events) + val keyInjectModeItems = rememberSaveable( + keyInjectModeTextDefaultMixed, + keyInjectModeTextPreferText, + keyInjectModeTextRawKeyEvent, + ) { + listOf( + keyInjectModeTextDefaultMixed, + keyInjectModeTextPreferText, + keyInjectModeTextRawKeyEvent, + ) } val keyInjectModeIndex = rememberSaveable(soBundle.keyInjectMode) { when (soBundle.keyInjectMode) { @@ -658,10 +670,10 @@ internal fun ScrcpyAllOptionsPage( } val videoEncoders = scrcpy.listings.videoEncoders - val videoEncoderItems by remember(videoEncoders, listRefreshVersion) { + val videoEncoderItems by remember(textAuto, videoEncoders, listRefreshVersion) { derivedStateOf { buildList { - add(SpinnerEntry(title = "自动")) + add(SpinnerEntry(title = textAuto)) videoEncoders.forEach { info -> add( SpinnerEntry( @@ -692,7 +704,7 @@ internal fun ScrcpyAllOptionsPage( val audioEncoderItems by remember(audioEncoders, listRefreshVersion) { derivedStateOf { buildList { - add(SpinnerEntry(title = "自动")) + add(SpinnerEntry(title = textAuto)) audioEncoders.forEach { info -> add( SpinnerEntry( @@ -720,28 +732,26 @@ internal fun ScrcpyAllOptionsPage( } val displayImePolicyItems = rememberSaveable { - listOf("默认") + DisplayImePolicy.entries + listOf(textDefault) + DisplayImePolicy.entries .drop(1) .map { it.string } } val displayImePolicyIndex = rememberSaveable(soBundle.displayImePolicy) { - if (soBundle.displayImePolicy.isEmpty()) { - 0 - } else { - val idx = DisplayImePolicy.entries - .indexOfFirst { it.string == soBundle.displayImePolicy } - if (idx > 0) idx else 0 - } + if (soBundle.displayImePolicy.isEmpty()) 0 + else DisplayImePolicy.entries + .indexOfFirst { it.string == soBundle.displayImePolicy } + .takeIf { it > 0 } + ?: 0 } val apps = remember(scrcpy.listings.apps, listRefreshVersion) { - scrcpy.listings.apps // .sortedBy { it.packageName } + scrcpy.listings.apps } val appDropdownItems by remember(apps, listRefreshVersion) { derivedStateOf { buildList { - add(SpinnerEntry(title = "无")) - add(SpinnerEntry(title = "自定义")) + add(SpinnerEntry(title = textNone)) + add(SpinnerEntry(title = textCustom)) apps.forEach { app -> add( SpinnerEntry( @@ -749,7 +759,7 @@ internal fun ScrcpyAllOptionsPage( { Icon( imageVector = - if (system) Icons.Rounded.Android // MiuixIcons.Tune + if (system) Icons.Rounded.Android else MiuixIcons.Store, contentDescription = app.label ?: app.packageName, modifier = Modifier.padding(end = UiSpacing.ContentVertical), @@ -848,7 +858,10 @@ internal fun ScrcpyAllOptionsPage( clientOptions.validate() } catch (e: IllegalArgumentException) { if (soBundle != lastValidSoBundle) { - snackbar.show("Invalid options: ${e.message}") + AppRuntime.snackbar( + R.string.scrcpyopt_invalid_options, + e.message ?: e.javaClass.simpleName, + ) soBundle = lastValidSoBundle } return@LaunchedEffect @@ -883,23 +896,23 @@ internal fun ScrcpyAllOptionsPage( item { Card { SwitchPreference( - title = "scrcpy 启动后熄灭设备屏幕", + title = stringResource(R.string.scrcpyopt_turn_screen_off), summary = "--turn-screen-off", checked = soBundle.turnScreenOff, onCheckedChange = { soBundle = soBundle.copy( turnScreenOff = it ) - if (it) snackbar.show( + if (it) AppRuntime.snackbar( // github.com/Genymobile/scrcpy/issues/3376 // github.com/Genymobile/scrcpy/issues/4587 // github.com/Genymobile/scrcpy/issues/5676 - "注意:大部分设备在关闭屏幕后刷新率会降低/减半" + R.string.scrcpyopt_turn_screen_off_note ) }, ) SwitchPreference( - title = "禁用控制", + title = stringResource(R.string.scrcpyopt_no_control), summary = "--no-control", checked = !soBundle.control, onCheckedChange = { @@ -909,7 +922,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "禁用视频", + title = stringResource(R.string.scrcpyopt_no_video), summary = "--no-video", checked = !soBundle.video, onCheckedChange = { @@ -919,7 +932,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "仅转发不播放视频", + title = stringResource(R.string.scrcpyopt_no_video_playback), summary = "--no-video-playback", checked = !soBundle.videoPlayback, onCheckedChange = { @@ -930,7 +943,7 @@ internal fun ScrcpyAllOptionsPage( enabled = soBundle.video, ) SwitchPreference( - title = "禁用音频", + title = stringResource(R.string.scrcpyopt_no_audio), summary = "--no-audio", checked = !soBundle.audio, onCheckedChange = { @@ -940,7 +953,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "仅转发不播放", + title = stringResource(R.string.scrcpyopt_no_audio_playback), summary = "--no-audio-playback", checked = !soBundle.audioPlayback, onCheckedChange = { @@ -951,7 +964,7 @@ internal fun ScrcpyAllOptionsPage( enabled = soBundle.audio, ) SuperSlider( - title = "scrcpy 启动后受控机的息屏时间", + title = stringResource(R.string.scrcpyopt_screen_off_timeout), summary = "--screen-off-timeout", value = screenOffTimeoutPresetIndex.toFloat(), onValueChange = { value -> @@ -969,7 +982,7 @@ internal fun ScrcpyAllOptionsPage( valueRange = 0f..ScrcpyPresets.ScreenOffTimeout.lastIndex.toFloat(), steps = (ScrcpyPresets.ScreenOffTimeout.size - 2).coerceAtLeast(0), unit = "s", - zeroStateText = "不修改", + zeroStateText = stringResource(R.string.scrcpyopt_dont_modify), showKeyPoints = true, keyPoints = ScrcpyPresets.ScreenOffTimeout.indices.map { it.toFloat() }, displayText = Tick(soBundle.screenOffTimeout).sec.toString(), @@ -989,7 +1002,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "scrcpy 启动时不唤醒屏幕", + title = stringResource(R.string.scrcpyopt_no_power_on), summary = "--no-power-on", checked = !soBundle.powerOn, onCheckedChange = { @@ -999,7 +1012,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "scrcpy 结束后息屏", + title = stringResource(R.string.scrcpyopt_power_off_on_close), summary = "--power-off-on-close", checked = soBundle.powerOffOnClose, onCheckedChange = { @@ -1009,7 +1022,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "scrcpy 启动后保持设备唤醒状态(插入电源)", + title = stringResource(R.string.scrcpyopt_stay_awake), summary = "--stay-awake", checked = soBundle.stayAwake, onCheckedChange = { @@ -1019,7 +1032,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "显示物理触控", + title = stringResource(R.string.scrcpyopt_show_touches), summary = "--show-touches", checked = soBundle.showTouches, onCheckedChange = { @@ -1029,7 +1042,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "启动后进入全屏", + title = stringResource(R.string.scrcpyopt_fullscreen), summary = "--fullscreen", checked = soBundle.fullscreen, onCheckedChange = { @@ -1039,15 +1052,15 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "scrcpy 启动后保持本机屏幕常亮", + title = stringResource(R.string.scrcpyopt_disable_screensaver), summary = "--disable-screensaver", checked = soBundle.disableScreensaver, onCheckedChange = { soBundle = soBundle.copy( disableScreensaver = it ) - if (it) snackbar.show( - "注意防烧屏,画中画也生效" + if (it) AppRuntime.snackbar( + R.string.scrcpyopt_disable_screensaver_note ) }, ) @@ -1061,7 +1074,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "scrcpy 结束时断开 adb", + title = stringResource(R.string.scrcpyopt_kill_adb_on_close), summary = "--kill-adb-on-close", checked = soBundle.killAdbOnClose, onCheckedChange = { @@ -1076,7 +1089,7 @@ internal fun ScrcpyAllOptionsPage( item { Card { OverlayDropdownPreference( - title = "音频编码", + title = stringResource(R.string.scrcpyopt_audio_codec), summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, @@ -1087,7 +1100,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SuperSlider( - title = "音频码率", + title = stringResource(R.string.scrcpyopt_audio_bitrate), summary = "--audio-bit-rate", value = ScrcpyPresets.AudioBitRate .indexOfOrNearest(soBundle.audioBitRate / 1000) @@ -1102,7 +1115,7 @@ internal fun ScrcpyAllOptionsPage( 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) "" @@ -1121,7 +1134,7 @@ internal fun ScrcpyAllOptionsPage( ) OverlayDropdownPreference( - title = "视频编码", + title = stringResource(R.string.scrcpyopt_video_codec), summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, @@ -1131,9 +1144,8 @@ internal fun ScrcpyAllOptionsPage( ) }, ) - @SuppressLint("DefaultLocale") SuperSlider( - title = "视频码率", + title = stringResource(R.string.scrcpyopt_video_bitrate), summary = "--video-bit-rate", value = soBundle.videoBitRate / 1_000_000f, onValueChange = { mbps -> @@ -1144,11 +1156,11 @@ internal fun ScrcpyAllOptionsPage( valueRange = 0f..40f, steps = 400 - 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 -> @@ -1180,7 +1192,7 @@ internal fun ScrcpyAllOptionsPage( item { Card { OverlayDropdownPreference( - title = "视频来源", + title = stringResource(R.string.scrcpyopt_video_source), summary = "--video-source", items = videoSourceItems, selectedIndex = videoSourceIndex, @@ -1193,7 +1205,7 @@ internal fun ScrcpyAllOptionsPage( AnimatedVisibility(soBundle.videoSource == "display") { Column { OverlaySpinnerWithFallback( - title = "监视器 ID", + title = stringResource(R.string.scrcpyopt_display_id), summary = "--display-id", items = displaySpinnerItems, selectedIndex = displayDropdownIndex, @@ -1203,14 +1215,17 @@ internal fun ScrcpyAllOptionsPage( onExpandedChange = { expanded -> if (expanded && displays.isEmpty()) { scope.launch { - snackbar.show("获取中") + AppRuntime.snackbar(R.string.text_fetching) try { withContext(Dispatchers.IO) { scrcpy.listings.getDisplays(forceRefresh = false) } - snackbar.show("获取成功") + AppRuntime.snackbar(R.string.text_fetch_success) } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") + AppRuntime.snackbar( + R.string.text_fetch_failed, + e.message ?: "", + ) } } } @@ -1224,7 +1239,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SuperSlider( - title = "最大分辨率", + title = stringResource(R.string.scrcpyopt_max_size), summary = "--max-size", value = maxSizePresetIndex.toFloat(), onValueChange = { @@ -1237,7 +1252,7 @@ internal fun ScrcpyAllOptionsPage( valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), unit = "px", - zeroStateText = "关闭", + zeroStateText = stringResource(R.string.text_off), showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, @@ -1255,7 +1270,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SuperSlider( - title = "最大帧率", + title = stringResource(R.string.scrcpyopt_max_fps), summary = "--max-fps", value = maxFpsPresetIndex.toFloat(), onValueChange = { value -> @@ -1270,7 +1285,7 @@ internal fun ScrcpyAllOptionsPage( valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), unit = "fps", - zeroStateText = "关闭", + zeroStateText = stringResource(R.string.text_off), showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, @@ -1289,7 +1304,7 @@ internal fun ScrcpyAllOptionsPage( AnimatedVisibility(soBundle.videoSource == "camera") { Column { OverlaySpinnerWithFallback( - title = "摄像头 ID", + title = stringResource(R.string.scrcpyopt_camera_id), summary = "--camera-id", items = cameraSpinnerItems, selectedIndex = cameraDropdownIndex, @@ -1299,14 +1314,17 @@ internal fun ScrcpyAllOptionsPage( onExpandedChange = { expanded -> if (expanded && cameras.isEmpty()) { scope.launch { - snackbar.show("获取中") + AppRuntime.snackbar(R.string.text_fetching) try { withContext(Dispatchers.IO) { scrcpy.listings.getCameras(forceRefresh = false) } - snackbar.show("获取成功") + AppRuntime.snackbar(R.string.text_fetch_success) } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") + AppRuntime.snackbar( + R.string.text_fetch_failed, + e.message ?: "", + ) } } } @@ -1320,7 +1338,7 @@ internal fun ScrcpyAllOptionsPage( }, ) OverlayDropdownPreference( - title = "摄像头朝向", + title = stringResource(R.string.scrcpyopt_camera_facing), summary = "--camera-facing", items = cameraFacingItems, selectedIndex = cameraFacingIndex, @@ -1333,7 +1351,7 @@ internal fun ScrcpyAllOptionsPage( }, ) OverlaySpinnerWithFallback( - title = "摄像头分辨率", + title = stringResource(R.string.scrcpyopt_camera_size), summary = "--camera-size", items = cameraSizeSpinnerItems, selectedIndex = cameraSizeDropdownIndex, @@ -1343,14 +1361,17 @@ internal fun ScrcpyAllOptionsPage( onExpandedChange = { expanded -> if (expanded && cameraSizes.isEmpty()) { scope.launch { - snackbar.show("获取中") + AppRuntime.snackbar(R.string.text_fetching) try { withContext(Dispatchers.IO) { scrcpy.listings.getCameraSizes(forceRefresh = false) } - snackbar.show("获取成功") + AppRuntime.snackbar(R.string.text_fetch_success) } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") + AppRuntime.snackbar( + R.string.text_fetch_failed, + e.message ?: "", + ) } } } @@ -1427,7 +1448,7 @@ internal fun ScrcpyAllOptionsPage( .padding(all = UiSpacing.Large), ) SuperSlider( - title = "摄像头帧率", + title = stringResource(R.string.scrcpyopt_camera_fps), summary = "--camera-fps", value = cameraFpsPresetIndex.toFloat(), onValueChange = { value -> @@ -1440,7 +1461,7 @@ internal fun ScrcpyAllOptionsPage( valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), unit = "fps", - zeroStateText = "默认", + zeroStateText = stringResource(R.string.text_default), showKeyPoints = true, keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, displayText = soBundle.cameraFps.toString(), @@ -1456,7 +1477,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "高帧率模式", + title = stringResource(R.string.scrcpyopt_camera_high_speed), summary = "--camera-high-speed", checked = soBundle.cameraHighSpeed, onCheckedChange = { @@ -1474,7 +1495,7 @@ internal fun ScrcpyAllOptionsPage( item { Card { OverlayDropdownPreference( - title = "音频来源", + title = stringResource(R.string.scrcpyopt_audio_source), summary = "--audio-source", items = audioSourceItems, selectedIndex = audioSourceIndex, @@ -1485,7 +1506,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "音频双路输出", + title = stringResource(R.string.scrcpyopt_audio_dup), summary = "--audio-dup", checked = soBundle.audioDup, onCheckedChange = { @@ -1495,7 +1516,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "音频转发失败时终止", + title = stringResource(R.string.scrcpyopt_require_audio), summary = "--require-audio", checked = soBundle.requireAudio, onCheckedChange = { @@ -1510,7 +1531,7 @@ internal fun ScrcpyAllOptionsPage( item { Card { OverlaySpinnerWithFallback( - title = "视频编码器", + title = stringResource(R.string.scrcpyopt_video_encoder), summary = "--video-encoder", items = videoEncoderItems, selectedIndex = videoEncoderIndex, @@ -1520,14 +1541,17 @@ internal fun ScrcpyAllOptionsPage( onExpandedChange = { expanded -> if (expanded && videoEncoders.isEmpty()) { scope.launch { - snackbar.show("获取中") + AppRuntime.snackbar(R.string.text_fetching) try { withContext(Dispatchers.IO) { scrcpy.listings.getEncoders(forceRefresh = false) } - snackbar.show("获取成功") + AppRuntime.snackbar(R.string.text_fetch_success) } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") + AppRuntime.snackbar( + R.string.text_fetch_failed, + e.message ?: "", + ) } } } @@ -1555,7 +1579,7 @@ internal fun ScrcpyAllOptionsPage( .padding(all = UiSpacing.Large), ) OverlaySpinnerWithFallback( - title = "音频编码器", + title = stringResource(R.string.scrcpyopt_audio_encoder), summary = "--audio-encoder", items = audioEncoderItems, selectedIndex = audioEncoderIndex, @@ -1565,14 +1589,17 @@ internal fun ScrcpyAllOptionsPage( onExpandedChange = { expanded -> if (expanded && audioEncoders.isEmpty()) { scope.launch { - snackbar.show("获取中") + AppRuntime.snackbar(R.string.text_fetching) try { withContext(Dispatchers.IO) { scrcpy.listings.getEncoders(forceRefresh = false) } - snackbar.show("获取成功") + AppRuntime.snackbar(R.string.text_fetch_success) } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") + AppRuntime.snackbar( + R.string.text_fetch_failed, + e.message ?: "", + ) } } } @@ -1605,7 +1632,7 @@ internal fun ScrcpyAllOptionsPage( if (soBundle.videoSource == "display") item { Card { OverlayDropdownPreference( - title = "输入法显示策略", + title = stringResource(R.string.scrcpyopt_ime_display_policy), summary = "--display-ime-policy", items = displayImePolicyItems, selectedIndex = displayImePolicyIndex, @@ -1618,7 +1645,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "关闭虚拟显示器时保留内容", + title = stringResource(R.string.scrcpyopt_no_vd_destroy_content), summary = "--no-vd-destroy-content", checked = !soBundle.vdDestroyContent, onCheckedChange = { @@ -1628,7 +1655,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "禁用虚拟显示器系统装饰", + title = stringResource(R.string.scrcpyopt_no_vd_decorations), summary = "--no-vd-system-decorations", checked = !soBundle.vdSystemDecorations, onCheckedChange = { @@ -1638,23 +1665,20 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "禁用 MediaCodec 出错时自动降级", + title = stringResource(R.string.scrcpyopt_no_downsize_on_error), summary = "--no-downsize-on-error", checked = !soBundle.downsizeOnError, onCheckedChange = { soBundle = soBundle.copy( downsizeOnError = !it ) - if (it) snackbar.show( - """ - 默认情况下,在 MediaCodec 出错时,scrcpy 会自动尝试使用更低的分辨率重新开始 - 此选项将禁用此行为 - """.trimIndent() + if (it) AppRuntime.snackbar( + R.string.scrcpyopt_no_downsize_on_error_desc ) }, ) SwitchPreference( - title = "粘贴兼容回退(仅支持ASCII/英文字符)", + title = stringResource(R.string.scrcpyopt_legacy_paste), summary = "--legacy-paste", checked = soBundle.legacyPaste, onCheckedChange = { @@ -1664,11 +1688,11 @@ internal fun ScrcpyAllOptionsPage( }, ) OverlayDropdownPreference( - title = "键盘注入策略", + title = stringResource(R.string.scrcpyopt_key_inject_mode), summary = when (keyInjectModeIndex) { 1 -> "--prefer-text" 2 -> "--raw-key-events" - else -> "默认" + else -> stringResource(R.string.text_default) }, items = keyInjectModeItems, selectedIndex = keyInjectModeIndex, @@ -1683,7 +1707,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "禁用按键重复转发", + title = stringResource(R.string.scrcpyopt_no_key_repeat), summary = "--no-key-repeat", checked = !soBundle.forwardKeyRepeat, onCheckedChange = { @@ -1694,7 +1718,7 @@ internal fun ScrcpyAllOptionsPage( enabled = soBundle.keyInjectMode != ClientOptions.KeyInjectMode.PREFER_TEXT.string, ) SwitchPreference( - title = "禁用剪贴板双向同步", + title = stringResource(R.string.scrcpyopt_no_clipboard_autosync), summary = "--no-clipboard-autosync", checked = !soBundle.clipboardAutosync, onCheckedChange = { @@ -1704,7 +1728,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "禁用鼠标悬停转发", + title = stringResource(R.string.scrcpyopt_no_mouse_hover), summary = "--no-mouse-hover", checked = !soBundle.mouseHover, onCheckedChange = { @@ -1714,18 +1738,15 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "禁用结束后清理", + title = stringResource(R.string.scrcpyopt_no_cleanup), summary = "--no-cleanup", checked = !soBundle.cleanup, onCheckedChange = { soBundle = soBundle.copy( cleanup = !it ) - if (it) snackbar.show( - """ - 默认情况下,scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式) - 此选项将禁用此清理操作 - """.trimIndent() + if (it) AppRuntime.snackbar( + R.string.scrcpyopt_no_cleanup_desc ) }, ) @@ -1735,7 +1756,7 @@ internal fun ScrcpyAllOptionsPage( if (soBundle.videoSource == "display") item { Card { OverlaySpinnerWithFallback( - title = "scrcpy 启动后打开应用", + title = stringResource(R.string.scrcpyopt_start_app), summary = "--start-app", items = appDropdownItems, selectedIndex = appDropdownIndex, @@ -1745,14 +1766,17 @@ internal fun ScrcpyAllOptionsPage( onExpandedChange = { expanded -> if (expanded && apps.isEmpty()) { scope.launch { - snackbar.show("获取中") + AppRuntime.snackbar(R.string.text_fetching) try { withContext(Dispatchers.IO) { scrcpy.listings.getApps(forceRefresh = false) } - snackbar.show("获取成功") + AppRuntime.snackbar(R.string.text_fetch_success) } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") + AppRuntime.snackbar( + R.string.text_fetch_failed, + e.message ?: "", + ) } } } @@ -1914,7 +1938,7 @@ internal fun ScrcpyAllOptionsPage( horizontalArrangement = Arrangement.spacedBy(gap), ) { TextButton( - text = "清空", + text = stringResource(R.string.cd_clear), onClick = { newDisplayWidthInput = "" newDisplayHeightInput = "" @@ -1925,9 +1949,10 @@ internal fun ScrcpyAllOptionsPage( enabled = !newDisplayAllBlank, ) TextButton( - text = - if (newDisplayWidthBlank || newDisplayHeightBlank) "本机" - else "交换", + text = stringResource( + if (newDisplayWidthBlank || newDisplayHeightBlank) R.string.scrcpyopt_native + else R.string.button_swap + ), onClick = { if (newDisplayWidthBlank || newDisplayHeightBlank) { val metrics = resources.displayMetrics @@ -2090,7 +2115,7 @@ internal fun ScrcpyAllOptionsPage( item { Card { OverlayDropdownPreference( - title = "日志等级", + title = stringResource(R.string.scrcpyopt_log_level), summary = "--verbosity", items = logLevelItems, selectedIndex = logLevelIndex, @@ -2122,10 +2147,15 @@ private fun ProfileNameDialog( ) { if (mode == null) return val focusManager = LocalFocusManager.current + + val textDefault = stringResource(R.string.text_default) + var input by rememberSaveable(mode, initialInput) { mutableStateOf(initialInput) } val profileNames = remember(profiles) { profiles.map { it.name } } val profileIds = remember(profiles) { profiles.map { it.id } } - val copySourceItems = remember(profileNames) { listOf("默认") + profileNames } + val copySourceItems = remember(profileNames) { + listOf(textDefault) + profileNames + } var copySourceProfileId by rememberSaveable(mode, initialCopySourceProfileId) { mutableStateOf(initialCopySourceProfileId) } @@ -2134,13 +2164,14 @@ private fun ProfileNameDialog( ?.let { profileIds.indexOf(it).takeIf { index -> index >= 0 }?.plus(1) } ?: 0 } + OverlayDialog( show = true, title = when (mode) { - ProfileDialogMode.Create -> "新建配置" - ProfileDialogMode.Rename -> "重命名配置" + ProfileDialogMode.Create -> stringResource(R.string.scrcpyopt_new_profile) + ProfileDialogMode.Rename -> stringResource(R.string.scrcpyopt_rename_profile) }, - summary = "配置名重复时会自动追加序号", + summary = stringResource(R.string.scrcpyopt_duplicate_name_hint), defaultWindowInsetsPadding = false, onDismissRequest = onDismissRequest, ) { @@ -2150,7 +2181,7 @@ private fun ProfileNameDialog( TextField( value = input, onValueChange = { input = it }, - label = "配置名", + label = stringResource(R.string.scrcpyopt_profile_name), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( @@ -2160,7 +2191,7 @@ private fun ProfileNameDialog( ) AnimatedVisibility(mode == ProfileDialogMode.Create) { OverlayDropdownPreference( - title = "复制配置", + title = stringResource(R.string.scrcpyopt_copy_from), items = copySourceItems, selectedIndex = copySourceDropdownIndex, onSelectedIndexChange = { index -> @@ -2178,12 +2209,12 @@ private fun ProfileNameDialog( 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_confirm), onClick = { onConfirm(input, copySourceProfileId) }, modifier = Modifier.weight(1f), colors = ButtonDefaults.textButtonColorsPrimary(), @@ -2206,30 +2237,30 @@ private fun ManageProfilesSheet( ) { OverlayBottomSheet( show = show, - title = "管理配置", + title = stringResource(R.string.scrcpyopt_manage_profiles), defaultWindowInsetsPadding = false, onDismissRequest = onDismissRequest, endAction = { - IconButton( - onClick = onCreateProfile, - ) { + IconButton(onClick = onCreateProfile) { Icon( imageVector = Icons.Rounded.Add, - contentDescription = "新建配置", + contentDescription = stringResource(R.string.scrcpyopt_new_profile), ) } }, ) { + val textCurrent = stringResource(R.string.scrcpyopt_current_profile) + val textRename = stringResource(R.string.scrcpyopt_rename_profile) + val textDelete = stringResource(R.string.scrcpyopt_delete_profile) ReorderableList( itemsProvider = { profiles.map { profile -> ReorderableList.Item( id = profile.id, title = profile.name, - subtitle = when (profile.id) { - selectedProfileId -> "当前配置" - else -> "" - }, + subtitle = + if (profile.id == selectedProfileId) textCurrent + else "", onClick = if (profile.id != ScrcpyOptions.GLOBAL_PROFILE_ID) { { onRenameProfile(profile.id) } @@ -2240,14 +2271,14 @@ private fun ManageProfilesSheet( add( ReorderableList.EndAction.Icon( icon = Icons.Rounded.Edit, - contentDescription = "重命名配置", + contentDescription = textRename, onClick = { onRenameProfile(profile.id) }, ) ) add( ReorderableList.EndAction.Icon( icon = Icons.Rounded.DeleteOutline, - contentDescription = "删除配置", + contentDescription = textDelete, onClick = { onDeleteProfile(profile.id) }, ) ) @@ -2272,8 +2303,8 @@ private fun DeleteProfileDialog( if (!show) return OverlayDialog( show = true, - title = "删除配置", - summary = "确认删除 \"$profileName\"?", + title = stringResource(R.string.scrcpyopt_delete_profile), + summary = stringResource(R.string.scrcpyopt_delete_confirm, profileName), defaultWindowInsetsPadding = false, onDismissRequest = onDismissRequest, ) { @@ -2281,12 +2312,12 @@ private fun DeleteProfileDialog( 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_delete), onClick = onConfirm, modifier = Modifier.weight(1f), colors = ButtonDefaults.textButtonColorsPrimary(), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index 11647af..b95d944 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -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) }, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt index bca6d28..0f68802 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt index de472f4..6d86da2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt @@ -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, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt index 80fe9e6..2d71644 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt @@ -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 = _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(Channel.BUFFERED) - val snackbarEvents: Flow = _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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt index b146935..c2b866e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/BiometricGate.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/BiometricGate.kt index f6b9831..868c606 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/BiometricGate.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/BiometricGate.kt @@ -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 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt index 30b5ddf..e85468d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt @@ -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), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordUseCase.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordUseCase.kt index 9468f2f..9ee713c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordUseCase.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordUseCase.kt @@ -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 { 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()) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt index ec2f650..7369dc5 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt @@ -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, ) ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt index 869361a..cd27e72 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt @@ -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, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index 7e03c8a..8d94577 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -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) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index ac4b2e7..c7e7938 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt index 9590ae0..72d6190 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -3,9 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.scrcpy import kotlin.time.Duration import kotlin.time.Duration.Companion.microseconds -/* -参考官方实现尽量统一行为 - */ +// 参考官方实现尽量统一行为 class Shared { @JvmInline diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt index fd5c350..d5749af 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt @@ -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() + + 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) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppUpdateChecker.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppUpdateChecker.kt index ed4ee39..e2606bc 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppUpdateChecker.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppUpdateChecker.kt @@ -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 } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt index 9df71e7..e980f82 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt @@ -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") } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionStateStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionStateStore.kt index 937366d..f982f70 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionStateStore.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionStateStore.kt @@ -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, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt index f64dc29..b01a8b8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt @@ -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 + }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt index 398021e..a8ea1d6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt @@ -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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt index bb60f5f..70c541a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt @@ -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) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt index b0f323b..190390d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt @@ -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 = 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 = mutableStateListOf() + private val _eventLog: SnapshotStateList = mutableStateListOf() /** * Read-only access to the event log list. */ - val eventLog: List get() = _eventLog + val eventLog: List 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) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt index a1d9c33..6e83fd8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt @@ -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() - 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>() + 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 } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordFilenameTemplate.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordFilenameTemplate.kt index 45ec7cd..873ce7e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordFilenameTemplate.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordFilenameTemplate.kt @@ -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), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt index a93e552..0314285 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt @@ -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(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 = 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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index 2932f04..8582caf 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -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"), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index 4724999..4af691a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -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"), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt index d7baee2..445aedc 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt @@ -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, ) ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt index b8872aa..a201d73 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt @@ -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), ) } }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index a78c9f7..7b293f0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/RecordPreferences.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/RecordPreferences.kt index bb7ede3..8ba35c8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/RecordPreferences.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/RecordPreferences.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt index b446382..c4c5db4 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt @@ -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), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index 4130071..2b2fa77 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -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( diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..63b46f9 --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..69ad3f3 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,554 @@ + + + Scrcpy for Android + + + 设备 + 终端 + 文件 + 设置 + 再按一次返回键退出 + 无法读取字体文件 + 终端字体导入成功 + 终端字体导入失败:%1$s + 检查更新失败:%1$s + + + 关于 + 项目仓库 + 版本发布 + 错误 + + + 返回 + 更多 + 清空 + 选择文件 + 选择字体 + 全屏 + 关闭 + 保存 + 编辑 + 拖动排序 + 刷新%1$s + + 默认 + 全局 + 加载中… + 没有可用的 + 关闭 + 自动 + 自定义 + 获取中… + 获取成功 + 刷新失败:%1$s + 取消 + 删除 + 完成 + 连接 + 断开 + 启动 + 停止 + 全屏 + 配对 + 确定 + 添加设备 + 直接连接 + 自动发现 + 发现中… + 交换 + IP 地址 + 端口 + 设备名 + 名称 + WLAN 配对码 + 分辨率 + 编解码器 + 状态 + FPS + 触点 + + + 设备 + 配置显示在右侧 + 配置显示在左侧 + 快速设备排序 + 虚拟按钮排序 + 清空日志 + 长按可编辑 + 无法修改已连接的设备 + 已添加设备:%1$s:%2$s + 无线配对 + Scrcpy + 日志 + 使用配对码配对设备 + 使用六位数的配对码配对新设备 + 连接 / 添加设备 + 投屏中 + 分辨率 + 编解码器 + ADB 已连接 + ADB 未连接 + 当前设备 + 状态 + 空闲 + 音频转发 + 转发设备音频到本机 (Android 11+) + 音频编码 + 注意:FLAC编解码会引入较大的延迟 + 音频码率 + 视频编码 + 注意:绝大部分设备不支持AV1硬件编码 + 视频码率 + 更多参数 + 所有参数 + 所有 scrcpy 参数 + 连接后立即启动 scrcpy + 直接进入全屏 + scrcpy 配置 + %1$s 已切换到配置 %2$s + + + 最近任务 + 最近任务加载中 + 没有可用的最近任务 + 所有应用 + 应用列表加载中 + 没有可用的应用列表 + + + 获取应用列表失败 + 获取最近任务失败 + 非 ASCII 文本粘贴失败 + 文本输入失败 + 本机剪贴板为空或不是文本 + legacy 粘贴失败 + 剪贴板同步粘贴失败,可尝试开启 --legacy-paste + 启动应用失败:%1$s + 分辨率:%1$sx%2$s + 帧率:%1$s + 触点:%1$d + + + 设置 + + + 当前版本 %1$s + ,正在检查更新 + ,检查更新失败 + ,发现新版本 %1$s + ,已是最新版本 + ,高于最新发布版本 %1$s + + + 主题 + 外观模式 + 选择应用的外观模式 + Monet 颜色 + 开启后使用 Monet 动态配色 + Monet Key Color + 设置 Monet 强调色 + Monet Palette Style + 设置 Monet 调色板风格 + Monet Color Spec + 设置 Monet 色彩规格 + 模糊 + 启用顶栏和底栏的模糊效果 + 悬浮底栏 + 使用 Apple 风格的悬浮底栏 + 液态玻璃 + 启用悬浮底栏的液态玻璃效果 + 平滑圆角 + 启用全局平滑圆角效果 + + + 投屏 + 低延迟音频(实验性) + 启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用 + 启用调试信息 + 在全屏界面悬浮显示分辨率、帧率和触点信息 + 设备页隐藏简单设置项 + 启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮 + 预览卡高度 + 设备页预览卡高度 + 快速设备排序 + 手动排序设备页的快速设备 + 虚拟按钮排序 + 手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外 + 锁屏密码自动填充 + 管理用于自动填充的锁屏密码 + 实时同步剪贴板到受控机 + 本机剪贴板更新后会自动同步到受控机\n禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容\nMIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效 + + + 全屏 + 全屏时不跟随系统旋转锁定 + 启用后使用传感器方向,忽略系统自动旋转锁定状态 + 全屏时返回键发送到远程 + 启用后系统返回键会发送给设备,不再退出全屏控制页\n此时退出全屏需要回到桌面通过图标重新进入应用 + 全屏时显示虚拟按钮 + 在全屏控制页中显示返回键、主页键等虚拟按钮 + 虚拟按钮方向 + 虚拟按钮高度 + 全屏时显示悬浮球 + 在全屏控制页中显示可拖动的悬浮球,点击后弹出完整虚拟按键菜单 + 悬浮球尺寸 + 悬浮球背景透明度 + 悬浮球白环透明度 + 全屏兼容模式 + 启用后全屏控制页不再跨 Activity,会导致画中画不可用 + + + scrcpy-server + 自定义 binary + 自定义 binary version + Remote Path + + + ADB + 调整后台电池策略 + 解决 Scrcpy 切换到后台时无法联网导致 ADB 断连\n应用的电池使用情况 -> 允许后台使用 -> 无限制\n国产ROM魔改的电源设置一般都可在对应的魔改应用设置中找到 + 应用信息 + 自定义 ADB 密钥名 + ADB 私钥 + ADB 公钥 + 未导入 + 已导入 + 已生成 + 私钥已导入,公钥指纹:%1$s + 公钥已导入,指纹:%1$s + ADB 密钥已重新生成,公钥指纹:%1$s + 已移除导入的 ADB 密钥,当前公钥指纹:%1$s + ADB 密钥导入失败:%1$s + 移除 + 重新生成 + 配对时自动启用发现服务 + 打开配对弹窗后自动搜索可用配对端口 + 自动重连已配对设备 + 自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘) + 连接后自动获取应用列表 + ADB 连接成功后立刻执行 --list-apps,用于补全最近任务列表应用名 + --list-apps 操作可能非常耗时(特别是在息屏状态下),启用后可能导致连接设备后阻塞过久! + 无法打开设置 + + + 终端 + 终端字号 + 也可以在终端上双指缩放调整 + 自定义终端字体 + 内置等宽字体 + 已恢复默认终端字体 + 当前没有可清除的自定义字体 + + + 杂项 + 退出应用时清除日志 + 双击返回退出应用时清除日志 + 隐藏设备页日志框 + 隐藏设备页最下方的日志框 + + + 终端 + 自由复制 + 清屏 + 已复制所有终端输出 + 已复制到剪贴板 + 复制全部 + 当前没有输出 + 终端输出 + 终端字号 %1$dsp + 恢复默认 + + + 虚拟按钮排序 + 按钮显示文本 + 超过3个建议关闭,只对预览卡下方的虚拟按钮生效 + 显示在外部 + 显示在更多菜单内 + + + 更多 + 主页 + 返回 + 多任务 + 菜单 + 通知栏 + 音量+ + 音量- + 静音 + 锁屏 + 截图 + 填充锁屏密码 + 所有应用 + 最近任务 + 拉起输入法 + 粘贴本机剪贴板 + + + 跟随 + 固定 + 上方 + 下方 + 左侧 + 右侧 + 显示在屏幕 + + + 跟随系统 + 浅色 + 深色 + + + 录制 + 录制格式 + 文件名,不为空时启用 + 预览: + 纯文本 + 四位年份,例如 2026 + 两位年份,例如 26 + 月份,两位,例如 09 + 月份,一位或两位,例如 9 + 日期,两位,例如 19 + 日期,一位或两位,例如 9 + 24 小时制,两位,例如 23 + 24 小时制,一位或两位,例如 9 + 12 小时制,两位,例如 11 + 12 小时制,一位或两位,例如 9 + 分钟,两位,例如 09 + 分钟,一位或两位,例如 9 + 秒,两位,例如 09 + 秒,一位或两位,例如 9 + 秒级时间戳,例如 1776952809 + 设备名 + 设备 IP + 设备端口 + 视频串流编码(非文件实际编码) + 音频串流编码(非文件实际编码) + 视频宽度 + 视频高度 + + + 快速设备排序 + + + 停止投屏 + 验证以创建新密码 + 验证身份以继续 + 验证以禁用密码保护 + 验证身份以继续 + 验证以填充锁屏密码 + 密码填充失败 + 无可用密码 + 当前页面无法拉起验证 + 认证失败 + 设备安全状态已变更,请重新设置密码 + 密码已过期,请重新设置密码 + 锁屏密码自动填充 + 创建新密码 + 当前设备未设置锁屏保护 + 继续使用将允许在无认证保护的情况下保存和填充锁屏密码 + 同意 + 关闭验证后密码将失去保护 + 关闭后每次填充密码时将不再强制认证\n同时会熔断当前经认证创建的密码 + 继续关闭 + 删除密码 + 将删除 %1$s + 密码 %1$d + 密码不能为空 + 填充密码时需要验证 + 关闭后将允许直接填充锁屏密码\n同时会熔断当前经认证创建的密码 + 当前设备无认证能力 + 或在右上角菜单中 + 已失效 + 创建时已验证 + 创建时未经验证 + 创建时已验证(熔断) + 重命名密码 + 锁屏密码 + 免责声明\n0. 无法保证没有 bug\n1. 本功能的防护边界仅包括加密存储、按需认证和使用后内存清理,不构成绝对安全保证\n2. 在 root / posed / hook / 调试器 / 恶意输入法 等环境下,密码仍可能泄露\n3. 本功能不会绕过系统锁屏认证,仅用于你已合法授权控制的设备\n4. 关闭\"填充密码时需要验证\"会显著降低安全性,请谨慎选择 + + + 上一层 + 排序 + 文件名 + 大小 + 时间 + 扩展名 + 正序 + 倒序 + 创建文件夹 + 上传文件到该目录 + 正在加载详情 + 暂无详情 + 下拉刷新 + 释放刷新 + 正在刷新… + 刷新完成 + 加载失败:%1$s + 空目录 + 文件详情 + 显示原文 + 显示解析 + 下载 + 跳转路径 + 创建文件夹 + 新建文件夹 + 创建 + 链接目标不可用,长按查看信息 + 链接目标不是文件夹,长按查看信息 + 读取链接目标失败:%1$s + 长按可查看文件详情 + 文件夹名称不能为空 + 已创建文件夹 + 创建失败:%1$s + 已上传到 %1$s + 上传失败:%1$s + 读取详情失败:%1$s + 目录扫描失败:%1$s + 开始下载 + 目录信息仍在加载,请稍后重试 + 已下载到 Download/Scrcpy + 无法直接写入 Download/Scrcpy,请选择保存目录 + 下载完成 + 下载失败:%1$s + 终端输入失败:%1$s + 终端会话创建失败:%1$s + 终端输出失败:%1$s + 无法读取文件名 + 无法读取选择的文件 + 文件夹名称不能为空 + 无法写入目标文件 + 目录不存在 + 目标不是文件夹 + 无法创建目录 + 无法创建文件 + 远端 shell 已关闭 + 命令执行失败 + 路径 + 类型 + 大小 + 块数 + IO 块大小 + 硬链接 + 权限 + 设备 + 设备类型 + 访问时间 + 修改时间 + 变更时间 + 链接目标 + 目录数 + 文件数 + 目标信息 + 未知 + + + 配置 + 新配置 + 所有参数 + 配置管理 + 管理配置 + 新建配置 + 重命名配置 + 配置名重复时会自动追加序号 + 配置名 + 复制配置 + 当前配置 + 删除配置 + 确认删除 \"%1$s\"? + 优先文本 + 原始按键事件 + 默认 + 不修改 + 无效参数:%1$s + scrcpy 启动后熄灭设备屏幕 + 注意:大部分设备在关闭屏幕后刷新率会降低/减半 + 禁用控制 + 禁用视频 + 仅转发不播放视频 + 禁用音频 + 仅转发不播放 + scrcpy 启动后受控机的息屏时间 + scrcpy 启动时不唤醒屏幕 + scrcpy 结束后息屏 + scrcpy 启动后保持设备唤醒状态(插入电源) + 显示物理触控 + 启动后进入全屏 + scrcpy 启动后保持本机屏幕常亮 + 注意防烧屏,画中画也生效 + scrcpy 结束时断开 adb + 音频编码 + 音频码率 + 视频编码 + 视频码率 + 视频来源 + 监视器 ID + 最大分辨率 + 最大帧率 + 摄像头 ID + 摄像头朝向 + 摄像头分辨率 + 摄像头帧率 + 高帧率模式 + 音频来源 + 音频双路输出 + 音频转发失败时终止 + 视频编码器 + 音频编码器 + 输入法显示策略 + 关闭虚拟显示器时保留内容 + 禁用虚拟显示器系统装饰 + 禁用 MediaCodec 出错时自动降级 + 默认情况下,在 MediaCodec 出错时,scrcpy 会自动尝试使用更低的分辨率重新开始\n此选项将禁用此行为 + 粘贴兼容回退(仅支持ASCII/英文字符) + 键盘注入策略 + 禁用按键重复转发 + 禁用剪贴板双向同步 + 禁用鼠标悬停转发 + 禁用结束后清理 + 默认情况下,scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作 + scrcpy 启动后打开应用 + 本机 + 日志等级 + + + ADB 已连接 + scrcpy 已启动 + scrcpy 已启动并开始录制 + scrcpy 已停止,ADB 已断开 + scrcpy 已停止 + ADB 连接失败 + ADB 已断开 + 配对成功 + 配对失败 + ADB 自动重连成功 + ADB 自动重连失败 + 启动 scrcpy + 停止 scrcpy + 启动应用 + 连接 ADB + 断开 ADB + 执行配对 + 发送 %1$s + %1$s 超时 + %1$s 参数错误:%2$s + %1$s 失败:%2$s + 已在当前显示启动应用:%1$s + 通过 scrcpy 控制通道启动应用失败,回退 ADB + 已通过 ADB 启动应用:%1$s + ADB 已连接:model=%1$s + 获取应用列表失败:%1$s + 已请求 scrcpy 启动应用:%1$s + 通过 scrcpy 控制通道启动应用失败:%1$s + scrcpy 已启动:device=%1$s + scrcpy 已停止,ADB 已断开 + 获取最近任务失败:%1$s + 已使用 legacy paste 注入本机剪贴板文本 + 已同步本机剪贴板到设备并触发粘贴 + 本机剪贴板粘贴失败 + 输入法文本提交失败:%1$s + ADB 已断开:%1$s + mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s + ADB 快速探测连接成功:%1$s:%2$s + 当前连接设备已切换为配置:%1$s + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c77063..e00ebf7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,554 @@ + Scrcpy for Android - \ No newline at end of file + + + Devices + Terminal + Files + Settings + Press back again to exit + Cannot read font file + Terminal font imported successfully + Terminal font import failed: %1$s + Update check failed: %1$s + + + About + Project repo + Releases + Error + + + Back + More + Clear + Select file + Select font + Fullscreen + Close + Save + Edit + Drag to sort + Refresh %1$s + None + Default + Global + Loading… + None available + Off + Auto + Custom + Fetching… + Fetch successful + Refresh failed: %1$s + Cancel + Delete + Done + Connect + Disconnect + Start + Stop + Fullscreen + Pair + OK + Add device + Direct connect + Auto discover + Discovering… + Swap + IP address + Port + Device name + Name + WLAN pairing code + Resolution + Codec + Status + FPS + Touches + + + Devices + Config panel on right + Config panel on left + Quick device sort + Virtual button order + Clear logs + Long press to edit + Cannot modify a connected device + Device added: %1$s:%2$s + Wireless pairing + Scrcpy + Logs + Pair device using pairing code + Use a six-digit pairing code to pair a new device + Connect / Add device + Mirroring + Resolution + Codec + ADB connected + ADB disconnected + Current device + Status + Idle + Audio forwarding + Forward device audio to this device (Android 11+) + Audio codec + Note: FLAC codec introduces significant latency + Audio bitrate + Video codec + Note: The vast majority of devices do not support AV1 hardware encoding + Video bitrate + More parameters + All parameters + All scrcpy parameters + Start scrcpy immediately after connecting + Enter fullscreen directly + scrcpy configuration + %1$s switched to configuration %2$s + + + Recent tasks + Loading recent tasks… + No recent tasks available + All apps + Loading app list… + No app list available + + + Failed to get app list + Failed to get recent tasks + Non-ASCII text paste failed + Text input failed + Local clipboard is empty or not text + Legacy paste failed + Clipboard sync paste failed, try enabling --legacy-paste + Start app failed: %1$s + Resolution: %1$sx%2$s + FPS: %1$s + Touches: %1$d + + + Settings + + + Current version %1$s + , checking for updates + , update check failed + , found new version %1$s + , already the latest version + , newer than latest release %1$s + + + Theme + Appearance mode + Select the app\'s appearance mode + Monet color + Uses Monet dynamic color when enabled + Monet Key Color + Set Monet accent color + Monet Palette Style + Set Monet palette style + Monet Color Spec + Set Monet color specification + Blur + Enable blur effect on top bar and bottom bar + Floating bottom bar + Use Apple-style floating bottom bar + Liquid glass + Enable liquid glass effect on the floating bottom bar + Smooth corners + Enable global smooth corner effect + + + Screen mirroring + Low latency audio (experimental) + When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app + Enable debug info + Display resolution, frame rate, and touch info as overlay in fullscreen + Hide simple settings on device page + When enabled, the device page only keeps More parameters, All apps, Recent tasks, and Start/Stop buttons + Preview card height + Preview card height on device page + Quick device sort + Manually sort quick devices on the device page + Virtual button order + Manually sort virtual buttons in preview/fullscreen and select which are shown + Lockscreen password auto-fill + Manage lockscreen passwords used for auto-fill + Realtime clipboard sync to device + 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 + + + Fullscreen + Don\'t follow system rotation lock in fullscreen + When enabled, uses sensor orientation, ignoring system auto-rotation lock + Send BACK to device in fullscreen + 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 + Show virtual buttons in fullscreen + Show Back, Home and other virtual buttons on the fullscreen control page + Virtual button direction + Virtual button height + Show floating button in fullscreen + Show a draggable floating button on the fullscreen control page; tap to open the full virtual button menu + Floating button size + Floating button background opacity + Floating button ring opacity + Fullscreen compatibility mode + When enabled, the fullscreen control page no longer spans Activities; Picture-in-Picture will be unavailable + + + scrcpy-server + Custom binary + Custom binary version + Remote Path + + + ADB + Adjust background 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 + App info + Custom ADB key name + ADB private key + ADB public key + Not imported + Imported + Generated + Private key imported, public key fingerprint: %1$s + Public key imported, fingerprint: %1$s + ADB key regenerated, public key fingerprint: %1$s + Imported ADB key removed, active public key fingerprint: %1$s + ADB key import failed: %1$s + Remove + Regenerate + Auto enable discovery service when pairing + Automatically search for available pairing ports after opening the pairing dialog + Auto reconnect paired devices + Automatically discover devices with wireless debugging enabled, update quick device random ports, and attempt connection (results may vary) + Auto fetch app list after connection + Execute --list-apps immediately after successful ADB connection to populate app names in recent tasks list + --list-apps may be very time-consuming (especially when screen is off); enabling may cause excessive blocking after connecting to the device! + Cannot open settings + + + Terminal + Terminal font size + Can also be adjusted with pinch-to-zoom on the terminal + Custom terminal font + Built-in monospace font + Restored default terminal font + No custom font to clear currently + + + Miscellaneous + Clear logs when exiting app + Clear logs when double-pressing back to exit the app + Hide device page log box + Hide the log box at the bottom of the device page + + + Terminal + Free copy + Clear screen + Copied all terminal output + Copied to clipboard + Copy all + No output currently + Terminal output + Terminal font size %1$dsp + Restore default + + + Virtual button order + Button display text + Exceed 3, recommend hiding, only effective for virtual buttons below the preview card + Display on external + Display in more menu + + + More + Home + Back + App switch + Menu + Notifications + Volume up + Volume down + Mute + Lock screen + Screenshot + Fill lockscreen password + All apps + Recent tasks + Toggle IME + Paste local clipboard + + + Follow + Fixed + Top + Bottom + Left + Right + show at screen + + + Follow system + Light + Dark + + + Recording + Recording format + Filename, enabled when not empty + Preview: + Plain text + Four-digit year, e.g. 2026 + Two-digit year, e.g. 26 + Month, two digits, e.g. 09 + Month, one or two digits, e.g. 9 + Day, two digits, e.g. 19 + Day, one or two digits, e.g. 9 + 24-hour, two digits, e.g. 23 + 24-hour, one or two digits, e.g. 9 + 12-hour, two digits, e.g. 11 + 12-hour, one or two digits, e.g. 9 + Minutes, two digits, e.g. 09 + Minutes, one or two digits, e.g. 9 + Seconds, two digits, e.g. 09 + Seconds, one or two digits, e.g. 9 + Unix timestamp in seconds, e.g. 1776952809 + Device name + Device IP + Device port + Video stream codec (not actual file encoding) + Audio stream codec (not actual file encoding) + Video width + Video height + + + Quick device sort + + + Stop screen mirroring + Authenticate to create a new password + Verify your identity to continue + Authenticate to disable password protection + Verify your identity to continue + Authenticate to fill lockscreen password + Password fill failed + No available passwords + Cannot authenticate on this page + Authentication failed + Device security state has changed, please reset password + Password has expired, please reset + Lockscreen Password Auto-fill + Create new password + Current device does not have lock screen protection set + Continuing will allow saving and filling lockscreen passwords without authentication protection + Agree + Passwords will lose protection after disabling authentication + After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned. + Continue to disable + Delete password + Will delete %1$s + Password %1$d + Password cannot be empty + Require authentication when filling passwords + After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned. + Current device has no authentication capability + Or in the top-right menu + Invalidated + Authenticated when created + Unauthenticated when created + Authenticated when created (burned) + Rename password + Lockscreen password + Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully. + + + Parent directory + Sort + Name + Size + Time + Extension + Ascending + Descending + Create folder + Upload file to this directory + Loading details… + No details + Pull to refresh + Release to refresh + Refreshing… + Refresh done + Load failed: %1$s + Empty directory + File Details + Show raw + Show parsed + Download + Go to path + Create folder + New folder + Create + Link target unavailable, long press for details + Link target is not a directory, long press for details + Failed to read link target: %1$s + Long press to view file details + Folder name cannot be empty + Folder created + Create failed: %1$s + Uploaded to %1$s + Upload failed: %1$s + Failed to read details: %1$s + Directory scan failed: %1$s + Starting download + Directory info still loading, please try again later + Downloaded to Download/Scrcpy + Cannot write directly to Download/Scrcpy, please select a save directory + Download complete + Download failed: %1$s + Terminal input failed: %1$s + Terminal session creation failed: %1$s + Terminal output failed: %1$s + Cannot read file name + Cannot read selected file + Folder name cannot be empty + Cannot write target file + Directory not found + Target is not a directory + Cannot create directory + Cannot create file + Remote shell closed + Command failed + Path + Type + Size + Blocks + IO block size + Hard links + Permissions + Device + Device type + Access time + Modify time + Change time + Link target + Directories + Files + Target info + Unknown + + + Profile + New Profile + All Parameters + Profile Management + Manage Profiles + New Profile + Rename Profile + Duplicate profile names will have a number appended automatically + Profile Name + Copy From Profile + Current Profile + Delete Profile + Confirm deletion of \"%1$s\"? + Prefer Text + Raw Key Events + Default + Don\'t Modify + Invalid options: %1$s + Turn device screen off after scrcpy starts + Note: On most devices, the refresh rate decreases/is halved when the screen is turned off + Disable device control (mirror the device in read-only) + Disable video forwarding + Disable video playback on the computer + Disable audio forwarding + Disable audio playback on the computer + Controlled device\'s screen-off timeout after scrcpy starts + Do not power on the device on start + Turn the device screen off when closing scrcpy + Keep the device on while scrcpy is running, when the device is plugged in + Enable \"show touches\" on start, restore the initial value on exit + Start in fullscreen + Disable screensaver while scrcpy is running + Be aware of burn-in; this also affects picture-in-picture + Kill adb when scrcpy terminates + Audio codec + Audio bitrate + Video codec + Video bitrate + Video source + Display ID + Max resolution + Max framerate + Camera ID + Camera facing + Camera resolution + Camera framerate + High framerate mode + Audio source + Duplicate audio (capture and keep playing on the device) + Terminate when audio forwarding fails + Video encoder + Audio encoder + IME display policy + Retain content when closing virtual display + Disable virtual display system decorations + Disable auto-downscale on MediaCodec error + By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior. + Legacy paste (ASCII/English characters only) + Keyboard injection mode + Do not forward repeated key events when a key is held down + Disable automatic clipboard synchronization + Do not forward mouse hover events + Disable cleanup on exit + By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation. + Open app after scrcpy starts + Native + Log level + + + ADB connected + scrcpy started + scrcpy started and recording + scrcpy stopped, ADB disconnected + scrcpy stopped + ADB connection failed + ADB disconnected + Pairing succeeded + Pairing failed + ADB auto-reconnect succeeded + ADB auto-reconnect failed + Start scrcpy + Stop scrcpy + Launch app + Connect ADB + Disconnect ADB + Execute pairing + Send %1$s + %1$s timed out + %1$s parameter error: %2$s + %1$s failed: %2$s + App started on current display: %1$s + Failed to start app via scrcpy control channel, falling back to ADB + App started via ADB: %1$s + ADB connected: model=%1$s + Failed to get app list: %1$s + Scrcpy requested to start app: %1$s + Failed to start app via scrcpy control channel: %1$s + scrcpy started: device=%1$s + scrcpy stopped, ADB disconnected + Failed to get recent tasks: %1$s + Used legacy paste to inject local clipboard text + Synced local clipboard to device and triggered paste + Local clipboard paste failed + IME text submission failed: %1$s + ADB disconnected: %1$s + mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s + ADB quick probe connection succeeded: %1$s:%2$s + Current connected device switched to profile: %1$s +