From 9aacf6566620ae946def96b14f6f36274b451f44 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Tue, 7 Jul 2026 10:51:52 +0800 Subject: [PATCH] feat: confirmation before key reset; key export close #32 --- .gitignore | 2 +- .../nativecore/DirectAdbClient.kt | 12 + .../scrcpyforandroid/pages/SettingsScreen.kt | 240 ++++++++++++++++-- app/src/main/res/values-zh/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + 5 files changed, 253 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 26ed072..65fd28b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,7 @@ google-services.json *.hprof .claude/ -.kiro/ +AGENTS.md .kotlin/ 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 31e2516..f44a80d 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 @@ -51,6 +51,18 @@ internal object DirectAdbTransport { val privateKey: PrivateKey get() = keys().first val publicKeyX509: ByteArray get() = keys().second + fun getPrivateKeyPem(): String? = cachedKeys?.let { (priv, _) -> + "-----BEGIN PRIVATE KEY-----\n" + + Base64.encodeToString(priv.encoded, Base64.DEFAULT) + + "-----END PRIVATE KEY-----" + } + + fun getPublicKeyPem(): String? = cachedKeys?.let { (_, pub) -> + "-----BEGIN PUBLIC KEY-----\n" + + Base64.encodeToString(pub, Base64.DEFAULT) + + "-----END PUBLIC KEY-----" + } + @Volatile var keyName: String = AppSettings.ADB_KEY_NAME.defaultValue 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 af474c3..16b2f64 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 @@ -38,6 +38,7 @@ 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.LocalInputService import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings.FullscreenVirtualButtonDock import io.github.miuzarte.scrcpyforandroid.storage.Settings @@ -47,6 +48,10 @@ import io.github.miuzarte.scrcpyforandroid.ui.* import kotlinx.coroutines.* import top.yukonga.miuix.kmp.basic.* import top.yukonga.miuix.kmp.blur.layerBackdrop +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Download +import top.yukonga.miuix.kmp.icon.extended.Share +import top.yukonga.miuix.kmp.overlay.OverlayDialog import top.yukonga.miuix.kmp.preference.ArrowPreference import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference import top.yukonga.miuix.kmp.preference.SwitchPreference @@ -237,6 +242,59 @@ fun SettingsPage( } } + var exportingKey by rememberSaveable { mutableStateOf(null) } + var showResetKeyConfirm by rememberSaveable { mutableStateOf(false) } + + val exportPrivateKeyPicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("*/*"), + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + DirectAdbTransport.privateKey + val pem = DirectAdbTransport.getPrivateKeyPem() + ?: error("Private key not available") + context.contentResolver.openOutputStream(uri)?.use { os -> + os.write(pem.toByteArray()) + } ?: error("Cannot open output stream") + } + }.onSuccess { + AppRuntime.snackbar(R.string.pref_adb_key_exported_file) + }.onFailure { e -> + AppRuntime.snackbar( + R.string.pref_adb_key_export_failed, + e.message ?: e.javaClass.simpleName, + ) + } + } + } + + val exportPublicKeyPicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("*/*"), + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + DirectAdbTransport.publicKeyX509 + val pem = DirectAdbTransport.getPublicKeyPem() + ?: error("Public key not available") + context.contentResolver.openOutputStream(uri)?.use { os -> + os.write(pem.toByteArray()) + } ?: error("Cannot open output stream") + } + }.onSuccess { + AppRuntime.snackbar(R.string.pref_adb_key_exported_file) + }.onFailure { e -> + AppRuntime.snackbar( + R.string.pref_adb_key_export_failed, + e.message ?: e.javaClass.simpleName, + ) + } + } + } + val updateSummary = stringResource(R.string.pref_update_current, BuildConfig.VERSION_NAME) + when (val state = updateState) { AppUpdateChecker.State.Idle -> "" @@ -935,26 +993,7 @@ fun SettingsPage( IconButton( onClick = { haptic.contextClick() - 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, - ) - } - } + showResetKeyConfirm = true }, ) { Icon( @@ -967,6 +1006,17 @@ fun SettingsPage( ), ) } + IconButton( + onClick = { + haptic.contextClick() + exportingKey = "private" + }, + ) { + Icon( + imageVector = MiuixIcons.Share, + contentDescription = stringResource(R.string.cd_export_adb_key), + ) + } IconButton( onClick = { haptic.contextClick() @@ -974,7 +1024,7 @@ fun SettingsPage( }, ) { Icon( - imageVector = Icons.Rounded.FileOpen, + imageVector = MiuixIcons.Download, contentDescription = stringResource(R.string.cd_select_file), ) } @@ -1011,6 +1061,17 @@ fun SettingsPage( modifier = Modifier.fillMaxWidth(), trailingIcon = { Row(modifier = Modifier.padding(end = UiSpacing.Medium)) { + IconButton( + onClick = { + haptic.contextClick() + exportingKey = "public" + }, + ) { + Icon( + imageVector = MiuixIcons.Share, + contentDescription = stringResource(R.string.cd_export_adb_key), + ) + } IconButton( onClick = { haptic.contextClick() @@ -1018,7 +1079,7 @@ fun SettingsPage( }, ) { Icon( - imageVector = Icons.Rounded.FileOpen, + imageVector = MiuixIcons.Download, contentDescription = stringResource(R.string.cd_select_file), ) } @@ -1192,4 +1253,139 @@ fun SettingsPage( } } } + + ExportAdbKeyDialog( + show = exportingKey != null, + isPrivateKey = exportingKey == "private", + onDismissRequest = { exportingKey = null }, + onDismissFinished = { exportingKey = null }, + onExportToClipboard = { + val target = exportingKey + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + if (target == "private") { + DirectAdbTransport.privateKey + DirectAdbTransport.getPrivateKeyPem() + } else { + DirectAdbTransport.publicKeyX509 + DirectAdbTransport.getPublicKeyPem() + } ?: error("Key not available") + } + }.onSuccess { pem -> + LocalInputService.setClipboardText(context, pem) + AppRuntime.snackbar(R.string.pref_adb_key_exported_clipboard) + }.onFailure { e -> + AppRuntime.snackbar( + R.string.pref_adb_key_export_failed, + e.message ?: e.javaClass.simpleName, + ) + } + } + }, + onExportToFile = { + if (exportingKey == "private") { + exportPrivateKeyPicker.launch("adbkey") + } else { + exportPublicKeyPicker.launch("adbkey.pub") + } + }, + ) + + OverlayDialog( + show = showResetKeyConfirm, + title = stringResource(R.string.pref_adb_key_reset_confirm_title), + summary = stringResource(R.string.pref_adb_key_reset_confirm_summary), + defaultWindowInsetsPadding = false, + onDismissRequest = { showResetKeyConfirm = false }, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + TextButton( + text = stringResource(R.string.button_cancel), + onClick = { + haptic.contextClick() + showResetKeyConfirm = false + }, + modifier = Modifier.weight(1f), + ) + TextButton( + text = stringResource(R.string.button_confirm), + onClick = { + haptic.confirm() + showResetKeyConfirm = false + 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, + ) + } + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } +} + +@Composable +private fun ExportAdbKeyDialog( + show: Boolean, + isPrivateKey: Boolean, + onDismissRequest: () -> Unit, + onDismissFinished: () -> Unit, + onExportToClipboard: () -> Unit, + onExportToFile: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + OverlayDialog( + show = show, + title = stringResource(R.string.pref_adb_key_export), + summary = stringResource( + if (isPrivateKey) R.string.pref_adb_key_export_private_summary + else R.string.pref_adb_key_export_public_summary, + ), + defaultWindowInsetsPadding = false, + onDismissRequest = onDismissRequest, + onDismissFinished = onDismissFinished, + ) { + Spacer(Modifier.height(UiSpacing.ContentVertical * 2)) + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextButton( + text = stringResource(R.string.pref_adb_key_export_to_clipboard), + onClick = { + haptic.contextClick() + onExportToClipboard() + onDismissRequest() + }, + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + text = stringResource(R.string.pref_adb_key_export_to_file), + onClick = { + haptic.contextClick() + onExportToFile() + onDismissRequest() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } } diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3900b7e..1855238 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -228,6 +228,17 @@ ADB 密钥导入失败:%1$s 移除 重新生成 + 导出 + 导出私钥到... + 导出公钥到... + 剪贴板 + 文件 + 已导出到剪贴板 + 已导出到文件 + 导出失败:%1$s + 导出 + 重置 ADB 密钥 + 重置后,所有已授权的设备都需要重新授权。\n是否继续? 配对时自动启用发现服务 打开配对弹窗后自动搜索可用配对端口 自动重连已配对设备 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82d1dc8..43314e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -228,6 +228,17 @@ ADB key import failed: %1$s Remove Regenerate + Export + Export private key to... + Export public key to... + Clipboard + File + Exported to clipboard + Exported to file + Export failed: %1$s + Export + Reset ADB key + After resetting, all previously authorized devices will need to be re-authorized.\nContinue? Auto enable discovery service when pairing Automatically search for available pairing ports after opening the pairing dialog Auto reconnect paired devices