From 3a6df23a6c57e13b0b52890d40d0c6ecf0bd9329 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Mon, 13 Apr 2026 23:59:03 +0800 Subject: [PATCH] feat: biometric auth and lockscreen password autofill --- README.md | 6 +- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 6 + .../LockscreenPasswordActivity.kt | 618 ++++++++++++++++++ .../miuzarte/scrcpyforandroid/MainActivity.kt | 18 +- .../scrcpyforandroid/StreamActivity.kt | 4 +- .../scrcpyforandroid/models/DeviceModels.kt | 1 + .../scrcpyforandroid/pages/DeviceTabScreen.kt | 21 +- .../pages/FullscreenControlScreen.kt | 51 +- .../pages/ScrcpyAllOptionsScreen.kt | 5 +- .../scrcpyforandroid/pages/SettingsScreen.kt | 8 + .../scrcpyforandroid/pages/StreamScreen.kt | 13 +- .../pages/VirtualButtonOrderScreen.kt | 5 + .../password/BiometricGate.kt | 103 +++ .../password/InjectionController.kt | 17 + .../password/PasswordEntry.kt | 17 + .../password/PasswordPickerPopup.kt | 114 ++++ .../password/PasswordRepository.kt | 200 ++++++ .../password/PasswordSanitizer.kt | 16 + .../password/PasswordUseCase.kt | 31 + .../scrcpyforandroid/services/AppRuntime.kt | 3 + .../PictureInPictureActionReceiver.kt | 3 +- .../scrcpyforandroid/storage/AppSettings.kt | 12 +- .../scrcpyforandroid/storage/Storage.kt | 18 +- .../scrcpyforandroid/widgets/DeviceWidgets.kt | 2 + .../widgets/VirtualButtons.kt | 239 +++++-- .../锁屏密码自动填充实现设计方案初案.md | 174 +++++ gradle/libs.versions.toml | 4 + 28 files changed, 1623 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/BiometricGate.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/InjectionController.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordEntry.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordRepository.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordSanitizer.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordUseCase.kt create mode 100644 doc/scheme/锁屏密码自动填充实现设计方案初案.md diff --git a/README.md b/README.md index 490b3a6..533a068 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@

Screenshot 1 - Screenshot 2 - Screenshot 3 - Screenshot 4 + Screenshot 2 + Screenshot 3 + Screenshot 4 Screenshot 5 Screenshot 6 Screenshot 7 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7a90c66..fd5e651 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -121,6 +121,8 @@ dependencies { implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("androidx.datastore:datastore-preferences:1.2.1") implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.biometric) + implementation(libs.androidx.security.crypto) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c5d3dc5..d6d586e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,12 @@ android:resizeableActivity="true" android:supportsPictureInPicture="true" /> + + diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt new file mode 100644 index 0000000..337ff29 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt @@ -0,0 +1,618 @@ +package io.github.miuzarte.scrcpyforandroid + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Password +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.fragment.app.FragmentActivity +import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics +import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.password.BiometricGate +import io.github.miuzarte.scrcpyforandroid.password.PasswordCreatedState +import io.github.miuzarte.scrcpyforandroid.password.PasswordEntry +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.services.LocalSnackbarController +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.ButtonDefaults +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.ListPopupColumn +import top.yukonga.miuix.kmp.basic.ListPopupDefaults +import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior +import top.yukonga.miuix.kmp.basic.PopupPositionProvider +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.SnackbarHost +import top.yukonga.miuix.kmp.basic.SnackbarHostState +import top.yukonga.miuix.kmp.basic.SpinnerDefaults +import top.yukonga.miuix.kmp.basic.SpinnerEntry +import top.yukonga.miuix.kmp.basic.SpinnerItemImpl +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Close +import top.yukonga.miuix.kmp.icon.extended.Ok +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet +import top.yukonga.miuix.kmp.overlay.OverlayDialog +import top.yukonga.miuix.kmp.overlay.OverlayListPopup +import top.yukonga.miuix.kmp.preference.ArrowPreference +import top.yukonga.miuix.kmp.preference.SwitchPreference +import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.ThemeController + +class LockscreenPasswordActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + PasswordRepository.refresh() + setContent { + val asBundle by appSettings.bundleState.collectAsState() + val hostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val snackbarController = remember(scope, hostState) { + SnackbarController(scope = scope, hostState = hostState) + } + val themeMode = + when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) { + 1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight + 2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark + else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem + } + val themeController = + remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + val haptics = rememberAppHaptics() + + MiuixTheme(controller = themeController) { + CompositionLocalProvider( + LocalSnackbarController provides snackbarController, + LocalAppHaptics provides haptics, + ) { + LockscreenPasswordScreen( + activity = this, + hostState = hostState, + ) + } + } + } + } + + companion object { + fun createIntent(context: Context): Intent { + return Intent(context, LockscreenPasswordActivity::class.java) + } + } +} + +private enum class PasswordDialogMode { + Create, + Rename, +} + +@Composable +private fun LockscreenPasswordScreen( + activity: LockscreenPasswordActivity, + hostState: SnackbarHostState, +) { + val snackbar = LocalSnackbarController.current + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val scrollBehavior = MiuixScrollBehavior() + + 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) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { appSettings.saveBundle(asBundleLatest) } + } + } + + val entries by PasswordRepository.entriesState.collectAsState() + var showMenu by rememberSaveable { mutableStateOf(false) } + var pendingCreate by rememberSaveable { mutableStateOf(false) } + var showRiskDialog by rememberSaveable { mutableStateOf(!BiometricGate.isDeviceSecure()) } + var showDisableDialog by rememberSaveable { mutableStateOf(false) } + var pendingDeleteId by rememberSaveable { mutableStateOf(null) } + var dialogMode by rememberSaveable { mutableStateOf(null) } + var editingId by rememberSaveable { mutableStateOf(null) } + var editorInitialName by rememberSaveable { mutableStateOf("") } + + DisposableEffect(Unit) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if ( + event == androidx.lifecycle.Lifecycle.Event.ON_PAUSE || + event == androidx.lifecycle.Lifecycle.Event.ON_STOP + ) { + editorInitialName = "" + } + } + activity.lifecycle.addObserver(observer) + onDispose { activity.lifecycle.removeObserver(observer) } + } + + LaunchedEffect(pendingCreate, showMenu) { + if (!pendingCreate || showMenu) return@LaunchedEffect + if (asBundle.passwordRequireAuth) { + val ok = BiometricGate.authenticate( + activity = activity, + title = "验证以创建新密码", + ) + if (!ok) { + snackbar.show("认证失败") + pendingCreate = false + return@LaunchedEffect + } + } + dialogMode = PasswordDialogMode.Create + editingId = null + editorInitialName = "" + pendingCreate = false + } + + Scaffold( + topBar = { + TopAppBar( + title = "锁屏密码自动填充", + navigationIcon = { + IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") + } + }, + actions = { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Rounded.MoreVert, contentDescription = "更多") + } + PasswordMenuPopup( + show = showMenu, + onDismissRequest = { showMenu = false }, + onCreate = { + showMenu = false + pendingCreate = true + }, + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { SnackbarHost(hostState) }, + ) { pagePadding -> + LockscreenPasswordPage( + contentPadding = pagePadding, + scrollBehavior = scrollBehavior, + entries = entries, + requireAuth = asBundle.passwordRequireAuth, + canAuthenticate = BiometricGate.canAuthenticate(), + onToggleRequireAuth = { checked -> + if (checked) { + asBundle = asBundle.copy(passwordRequireAuth = true) + } else { + if (entries.any { it.cipherText != null }) { + showDisableDialog = true + } else { + asBundle = asBundle.copy(passwordRequireAuth = false) + } + } + }, + onCreate = { + pendingCreate = true + }, + onRename = { entry -> + dialogMode = PasswordDialogMode.Rename + editingId = entry.id + editorInitialName = entry.name + }, + onDelete = { entry -> + pendingDeleteId = entry.id + }, + onMove = { fromIndex, toIndex -> + val reordered = entries.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + } + PasswordRepository.updateOrder(reordered.map { it.id }) + }, + ) + + if (showRiskDialog) { + OverlayDialog( + show = true, + title = "当前设备未设置锁屏保护", + summary = "继续使用将允许在无认证保护的情况下保存和填充锁屏密码", + onDismissRequest = activity::finish, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) { + TextButton( + text = "取消", + onClick = activity::finish, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "同意", + onClick = { showRiskDialog = false }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } + + if (showDisableDialog) { + OverlayDialog( + show = true, + title = "关闭验证后密码将失去保护", + summary = "关闭后每次填充密码时将不再强制认证" + + "\n同时会熔断当前经认证创建的密码", + onDismissRequest = { showDisableDialog = false }, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) { + TextButton( + text = "取消", + onClick = { showDisableDialog = false }, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "继续关闭", + onClick = { + scope.launch { + if (entries.any { it.cipherText != null }) { + val ok = BiometricGate.authenticate( + activity = activity, + title = "验证以禁用“填充密码时需要验证”", + ) + if (!ok) { + snackbar.show("认证失败") + showDisableDialog = false + return@launch + } + } + asBundle = asBundle.copy(passwordRequireAuth = false) + entries.forEach { entry -> + PasswordRepository.update( + entry.copy( + cipherText = entry.cipherText?.copyOf(), + createdWithAuth = entry.createdWithAuth + .takeIf { it != PasswordCreatedState.AuthenticatedCreated } + ?: PasswordCreatedState.AuthenticatedCreatedModified + ) + ) + } + showDisableDialog = false + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } + + pendingDeleteId?.let { deleteId -> + val target = entries.firstOrNull { it.id == deleteId } + if (target != null) { + OverlayDialog( + show = true, + title = "删除密码", + summary = "将删除 ${target.name}", + onDismissRequest = { pendingDeleteId = null }, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) { + TextButton( + text = "取消", + onClick = { pendingDeleteId = null }, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "删除", + onClick = { + PasswordRepository.delete(target.id) + pendingDeleteId = null + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } + } + + PasswordEditorSheet( + show = dialogMode != null, + mode = dialogMode ?: PasswordDialogMode.Create, + initialName = editorInitialName, + onDismissRequest = { + dialogMode = null + editingId = null + editorInitialName = "" + }, + onConfirm = { nameInput, passwordInput -> + val sanitizedName = PasswordSanitizer.filterName(nameInput) + val resolvedName = sanitizedName.ifBlank { "密码 ${entries.size + 1}" } + when (dialogMode) { + PasswordDialogMode.Create -> { + val sanitizedPassword = PasswordSanitizer.filterPassword(passwordInput) + val passwordChars = sanitizedPassword.toCharArray() + if (passwordChars.isEmpty()) { + scope.launch { snackbar.show("密码不能为空") } + return@PasswordEditorSheet + } + PasswordRepository.create( + name = resolvedName, + cipherText = passwordChars, + createdWithAuth = if (asBundle.passwordRequireAuth) { + PasswordCreatedState.AuthenticatedCreated + } else { + PasswordCreatedState.UnauthenticatedCreated + }, + ) + } + + PasswordDialogMode.Rename -> { + val targetId = editingId ?: return@PasswordEditorSheet + PasswordRepository.rename(targetId, resolvedName) + } + + null -> return@PasswordEditorSheet + } + + dialogMode = null + editingId = null + editorInitialName = "" + }, + ) + + } +} + +@Composable +private fun LockscreenPasswordPage( + contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, + entries: List, + requireAuth: Boolean, + canAuthenticate: Boolean, + onToggleRequireAuth: (Boolean) -> Unit, + onCreate: () -> Unit, + onRename: (PasswordEntry) -> Unit, + onDelete: (PasswordEntry) -> Unit, + onMove: (Int, Int) -> Unit, +) { + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + item { + Card { + SwitchPreference( + title = "填充密码时需要验证", + summary = + if (canAuthenticate) "关闭后将允许直接填充锁屏密码" + + "\n同时会熔断当前经认证创建的密码" + else "当前设备无认证认证能力", + checked = requireAuth, + enabled = canAuthenticate || requireAuth, + onCheckedChange = onToggleRequireAuth, + ) + } + } + + item { Spacer(Modifier.height(UiSpacing.Medium)) } + + if (entries.isEmpty()) item { + Card { + ArrowPreference( + title = "创建新密码", + summary = "或在右上角菜单中", + onClick = onCreate, + ) + } + } + else item { + ReorderableList( + itemsProvider = { + entries.map { entry -> + ReorderableList.Item( + id = entry.id, + icon = + if (entry.cipherText == null) Icons.Rounded.Block + else Icons.Rounded.Password, + title = entry.name, + subtitle = + if (entry.cipherText == null) "已失效" + else when (entry.createdWithAuth) { + PasswordCreatedState.AuthenticatedCreated -> "创建时已验证" + PasswordCreatedState.UnauthenticatedCreated -> "创建时未经验证" + PasswordCreatedState.AuthenticatedCreatedModified -> "创建时已验证(熔断)" + }, + onClick = { if (entry.cipherText != null) onRename(entry) }, + endActions = listOf( + ReorderableList.EndAction.Icon( + icon = Icons.Rounded.Edit, + contentDescription = "编辑名称", + onClick = { if (entry.cipherText != null) onRename(entry) }, + ), + ReorderableList.EndAction.Icon( + icon = Icons.Rounded.DeleteOutline, + contentDescription = "删除密码", + onClick = { onDelete(entry) }, + ), + ), + ) + } + }, + onSettle = onMove, + ).invoke() + } + + item { + Text( + text = "免责声明" + + "\n0. 无法保证没有 bug" + + "\n1. 本功能的防护边界仅包括加密存储、按需认证和使用后内存清理,不构成绝对安全保证" + + "\n2. 在 root / posed / hook / 调试器 / 恶意输入法 等环境下,密码仍可能泄露" + + "\n3. 本功能不会绕过系统锁屏认证,仅用于你已合法授权控制的设备" + + "\n4. 关闭“填充密码时需要验证”会显著降低安全性,请谨慎选择", + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier + .fillMaxWidth() + .padding(top = UiSpacing.Large) + .padding(horizontal = UiSpacing.Large), + ) + } + + item { Spacer(Modifier.height(UiSpacing.PageBottom)) } + } +} + +@Composable +private fun PasswordMenuPopup( + show: Boolean, + onDismissRequest: () -> Unit, + onCreate: (Int) -> Unit, +) { + OverlayListPopup( + show = show, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = PopupPositionProvider.Align.TopEnd, + onDismissRequest = onDismissRequest, + enableWindowDim = false, + ) { + ListPopupColumn { + SpinnerItemImpl( + entry = SpinnerEntry(title = "创建新密码"), + entryCount = 1, + isSelected = false, + index = 0, + spinnerColors = SpinnerDefaults.spinnerColors(), + dialogMode = false, + onSelectedIndexChange = onCreate, + ) + } + } +} + +@Composable +private fun PasswordEditorSheet( + show: Boolean, + mode: PasswordDialogMode, + initialName: String, + onDismissRequest: () -> Unit, + onConfirm: (String, String) -> Unit, +) { + 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 "重命名密码", + onDismissRequest = onDismissRequest, + startAction = { + IconButton(onClick = onDismissRequest) { + Icon(MiuixIcons.Close, contentDescription = "关闭") + } + }, + endAction = { + IconButton(onClick = { onConfirm(nameBuffer, passwordBuffer) }) { + Icon(MiuixIcons.Ok, contentDescription = "保存") + } + }, + ) { + Column( + modifier = Modifier.padding(vertical = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextField( + value = nameBuffer, + onValueChange = { nameBuffer = it }, + label = "名称", + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.Large), + ) + AnimatedVisibility(mode == PasswordDialogMode.Create) { + TextField( + value = passwordBuffer, + onValueChange = { passwordBuffer = it }, + label = "锁屏密码", + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.Large), + ) + } + Spacer(Modifier.height(UiSpacing.SheetBottom)) + } + } +} 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 8c0de6f..21f896e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -2,30 +2,42 @@ package io.github.miuzarte.scrcpyforandroid import android.os.Build import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi +import androidx.fragment.app.FragmentActivity import io.github.miuzarte.scrcpyforandroid.pages.MainScreen +import io.github.miuzarte.scrcpyforandroid.password.BiometricGate +import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository +import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.runBlocking -class MainActivity : ComponentActivity() { +// 生物认证需要 FragmentActivity +class MainActivity : FragmentActivity() { @RequiresApi(Build.VERSION_CODES.R) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Storage.init(applicationContext) AppRuntime.init(applicationContext) AppWakeLocks.init(applicationContext) val migration = PreferenceMigration(applicationContext) runBlocking { + // 旧版设置迁移 if (migration.needsMigration()) migration.migrate(clearSharedPrefs = true) + + PasswordRepository.refresh() + // 认证不可用时, 清除经认证创建的密码 + if (!BiometricGate.canAuthenticate()) { + PasswordRepository.getAll() + .filter { it.createdWithAuth.hasAuthenticatedOrigin && it.cipherText != null } + .forEach { PasswordRepository.markInvalid(it.id) } + } } enableEdgeToEdge() 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 6e285ef..fb39695 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt @@ -7,18 +7,18 @@ import android.content.Context import android.content.Intent import android.graphics.drawable.Icon import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.app.PictureInPictureParamsCompat.Builder import androidx.core.content.ContextCompat import androidx.core.pip.BasicPictureInPicture +import androidx.fragment.app.FragmentActivity import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.lang.ref.WeakReference -class StreamActivity : ComponentActivity() { +class StreamActivity : FragmentActivity() { private val basicPip = BasicPictureInPicture(this) private val pipActionReceiver = PictureInPictureActionReceiver() 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 f70d8ce..fda22c7 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 @@ -121,6 +121,7 @@ class DeviceShortcuts(val devices: List) : List DeviceShortcuts(devices) } +// TODO: 增加 id 字段,解决编辑端口的问题 data class DeviceShortcut( val name: String = "", val host: String, 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 2cdf3a0..a63e9d7 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 @@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.annotation.SuppressLint import android.util.Log +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -49,6 +51,7 @@ import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics 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.password.PasswordPickerPopupContent import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy @@ -74,6 +77,7 @@ import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard import kotlinx.coroutines.CoroutineScope @@ -180,6 +184,8 @@ fun DeviceTabPage( scrollBehavior: ScrollBehavior, scrcpy: Scrcpy, ) { + val activity = LocalActivity.current + val fragmentActivity = remember(activity) {activity as? FragmentActivity} val context = LocalContext.current val scope = rememberCoroutineScope() @@ -314,7 +320,6 @@ fun DeviceTabPage( VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) ) } - var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) } @@ -1124,12 +1129,26 @@ fun DeviceTabPage( moreActions = virtualButtonLayout.second, showText = asBundle.previewVirtualButtonShowText, onAction = { action -> + if (action == VirtualButtonAction.PASSWORD_INPUT) { + snackbar.show("当前页面无法拉起验证") + return@VirtualButtonCard + } val keycode = action.keycode ?: return@VirtualButtonCard runBusy("发送 ${action.title}") { scrcpy.injectKeycode(0, keycode) scrcpy.injectKeycode(1, keycode) } }, + passwordPopupContent = + if (fragmentActivity == null) null + else { onDismissRequest -> + PasswordPickerPopupContent( + onDismissRequest = onDismissRequest, + onMessage = { message -> + scope.launch { snackbar.show(message) } + }, + ) + }, ) } } 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 f5dc883..5a33036 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 @@ -5,6 +5,7 @@ import android.app.Activity import android.graphics.Rect import android.util.Log import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -44,10 +45,13 @@ import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat 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.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler +import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface @@ -61,6 +65,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SnackbarHost import top.yukonga.miuix.kmp.basic.Text @Composable @@ -73,11 +78,12 @@ fun FullscreenControlScreen( ) { BackHandler(enabled = true, onBack = onBack) - val context = LocalContext.current + val activity = LocalActivity.current + val fragmentActivity = remember(activity) { activity as? FragmentActivity } val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } - val activity = remember(context) { context as? Activity } + val snackbar = LocalSnackbarController.current val currentSession by scrcpy.currentSessionState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState() @@ -174,7 +180,10 @@ fun FullscreenControlScreen( } } - Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding -> + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(snackbar.hostState) }, + ) { contentPadding -> Box( modifier = Modifier .fillMaxSize() @@ -210,7 +219,23 @@ fun FullscreenControlScreen( bar.Fullscreen( modifier = Modifier.align(Alignment.BottomCenter), onAction = { action -> - action.keycode?.let { sendKeycode(it) } + if (action == VirtualButtonAction.PASSWORD_INPUT) { + snackbar.show("当前页面无法拉起验证") + } else { + action.keycode?.let { sendKeycode(it) } + } + }, + passwordPopupContent = if (fragmentActivity == null) { + null + } else { + { onDismissRequest -> + PasswordPickerPopupContent( + onDismissRequest = onDismissRequest, + onMessage = { message -> + taskScope.launch(Dispatchers.Main) { snackbar.show(message) } + }, + ) + } }, ) } @@ -220,7 +245,23 @@ fun FullscreenControlScreen( actions = floatingActions, modifier = Modifier.fillMaxSize(), onAction = { action -> - action.keycode?.let { sendKeycode(it) } + if (action == VirtualButtonAction.PASSWORD_INPUT) { + snackbar.show("当前页面无法拉起验证") + } else { + action.keycode?.let { sendKeycode(it) } + } + }, + passwordPopupContent = if (fragmentActivity == null) { + null + } else { + { onDismissRequest -> + PasswordPickerPopupContent( + onDismissRequest = onDismissRequest, + onMessage = { message -> + taskScope.launch(Dispatchers.Main) { snackbar.show(message) } + }, + ) + } }, ) } 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 f699112..e0552ec 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 @@ -89,7 +89,6 @@ import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.extended.Store -import top.yukonga.miuix.kmp.icon.extended.Tune import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet import top.yukonga.miuix.kmp.overlay.OverlayDialog import top.yukonga.miuix.kmp.overlay.OverlayListPopup @@ -1502,8 +1501,8 @@ internal fun ScrcpyAllOptionsPage( ) if (it) snackbar.show( "默认情况下,在 MediaCodec 出错时," + - "scrcpy 会自动尝试使用更低的分辨率重新开始。" + - "\n此选项将禁用此行为。" + "scrcpy 会自动尝试使用更低的分辨率重新开始" + + "\n此选项将禁用此行为" ) }, ) 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 ac4af92..11d72ff 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import io.github.miuzarte.scrcpyforandroid.BuildConfig +import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn @@ -251,6 +252,13 @@ fun SettingsPage( summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外", onClick = { navigator.push(RootScreen.VirtualButtonOrder) }, ) + ArrowPreference( + title = "锁屏密码自动填充", + summary = "管理用于自动填充的锁屏密码", + onClick = { + context.startActivity(LockscreenPasswordActivity.createIntent(context)) + }, + ) SwitchPreference( title = "全屏显示虚拟按钮", summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", 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 2b7298b..d08cbf3 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 @@ -11,15 +11,19 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import io.github.miuzarte.scrcpyforandroid.StreamActivity import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics 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.Storage import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState +import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.theme.ColorSchemeMode import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.ThemeController @@ -89,9 +93,15 @@ fun StreamScreen(activity: StreamActivity) { val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } val haptics = rememberAppHaptics() + val snackbarHostState = remember { SnackbarHostState() } + val snackbarScope = rememberCoroutineScope() + val snackbarController = remember(snackbarScope, snackbarHostState) { + SnackbarController(scope = snackbarScope, hostState = snackbarHostState) + } MiuixTheme(controller = themeController) { CompositionLocalProvider( + LocalSnackbarController provides snackbarController, LocalAppHaptics provides haptics, ) { FullscreenControlScreen( @@ -100,11 +110,10 @@ fun StreamScreen(activity: StreamActivity) { isInPip = isInPip, onVideoSizeChanged = { width, height -> // 只在全屏时跟随视频方向 - if (!isInPip) { + if (!isInPip) activity.requestedOrientation = if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } }, onVideoBoundsInWindowChanged = { // 记录下一次进入 PiP 时可用的 sourceRectHint 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 28ccc13..ecf05ba 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 @@ -1,6 +1,8 @@ package io.github.miuzarte.scrcpyforandroid.pages import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.runtime.Composable @@ -16,6 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings @@ -117,6 +120,8 @@ internal fun VirtualButtonOrderPage( } } + item { Spacer(Modifier.height(UiSpacing.Medium)) } + item { ReorderableList( itemsProvider = { 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 new file mode 100644 index 0000000..1391027 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/BiometricGate.kt @@ -0,0 +1,103 @@ +package io.github.miuzarte.scrcpyforandroid.password + +import android.app.KeyguardManager +import android.content.Context +import android.os.Build +import android.os.Looper +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime +import io.github.miuzarte.scrcpyforandroid.storage.Storage +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.resume + +object BiometricGate { + + private const val ALLOWED_AUTHENTICATORS = + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + + private val sessionIds = AtomicLong(0L) + + fun canAuthenticate(): Boolean { + return BiometricManager.from(AppRuntime.context) + .canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS + } + + fun isDeviceSecure(): Boolean { + val manager = AppRuntime.context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + return manager.isDeviceSecure + } + + suspend fun authenticate( + activity: FragmentActivity, + title: String = "验证身份", + subtitle: String = "确认后继续", + ): Boolean = suspendCancellableCoroutine { continuation -> + val sessionId = sessionIds.incrementAndGet() + var resumed = false + lateinit var prompt: BiometricPrompt + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP) { + prompt.cancelAuthentication() + if (!resumed && continuation.isActive) { + resumed = true + continuation.resume(false) + } + } + } + activity.lifecycle.addObserver(observer) + + fun finish(result: Boolean) { + if (resumed || !continuation.isActive) return + if (sessionId != sessionIds.get()) return + resumed = true + activity.runOnMain { activity.lifecycle.removeObserver(observer) } + continuation.resume(result) + } + + prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + finish(true) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + finish(false) + } + + override fun onAuthenticationFailed() = Unit + } + ) + + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(ALLOWED_AUTHENTICATORS) + .setTitle(title) + .setSubtitle(subtitle) + .build() + ) + + continuation.invokeOnCancellation { + activity.runOnMain { + activity.lifecycle.removeObserver(observer) + prompt.cancelAuthentication() + } + } + } + + private inline fun FragmentActivity.runOnMain(crossinline block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + runOnUiThread { block() } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/InjectionController.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/InjectionController.kt new file mode 100644 index 0000000..c96393a --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/InjectionController.kt @@ -0,0 +1,17 @@ +package io.github.miuzarte.scrcpyforandroid.password + +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object InjectionController { + suspend fun inject(password: CharArray) { + try { + withContext(Dispatchers.IO) { + AppRuntime.scrcpy?.injectText(String(password)) + } + } finally { + password.fill('\u0000') + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordEntry.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordEntry.kt new file mode 100644 index 0000000..cbf781b --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordEntry.kt @@ -0,0 +1,17 @@ +package io.github.miuzarte.scrcpyforandroid.password + +enum class PasswordCreatedState { + AuthenticatedCreated, + UnauthenticatedCreated, + AuthenticatedCreatedModified, +} + +val PasswordCreatedState.hasAuthenticatedOrigin: Boolean + get() = this != PasswordCreatedState.UnauthenticatedCreated + +data class PasswordEntry( + val id: String, + val name: String, + val cipherText: CharArray?, + val createdWithAuth: PasswordCreatedState, +) 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 new file mode 100644 index 0000000..8ea05e6 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt @@ -0,0 +1,114 @@ +package io.github.miuzarte.scrcpyforandroid.password + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.Password +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.fragment.app.FragmentActivity +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.ListPopupColumn +import top.yukonga.miuix.kmp.basic.SpinnerDefaults +import top.yukonga.miuix.kmp.basic.SpinnerEntry +import top.yukonga.miuix.kmp.basic.SpinnerItemImpl +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +fun PasswordPickerPopupContent( + onDismissRequest: () -> Unit, + onMessage: (String) -> Unit, +) { + val activity = LocalActivity.current as? FragmentActivity + val scope = rememberCoroutineScope() + val passwordUseCase = remember { PasswordUseCase() } + val entries by PasswordRepository.entriesState.collectAsState() + val appSettingsBundle by appSettings.bundleState.collectAsState() + + val spinnerEntries = remember(entries) { + entries.map { entry -> + SpinnerEntry( + icon = { + Icon( + imageVector = + if (entry.cipherText == null) Icons.Rounded.Block + else Icons.Rounded.Password, + contentDescription = entry.name, + modifier = Modifier.padding(end = UiSpacing.ContentVertical), + ) + }, + title = entry.name, + summary = + if (entry.cipherText == null) "已失效" + else when (entry.createdWithAuth) { + PasswordCreatedState.AuthenticatedCreated -> "创建时已验证" + PasswordCreatedState.UnauthenticatedCreated -> "创建时未经验证" + PasswordCreatedState.AuthenticatedCreatedModified -> "创建时已验证(熔断)" + }, + ) + } + } + + ListPopupColumn { + if (spinnerEntries.isEmpty()) { + Text( + text = "无可用密码", + modifier = Modifier + .fillMaxWidth() + .padding(UiSpacing.PopupHorizontal), + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontWeight = FontWeight.Medium, + ) + return@ListPopupColumn + } + + if (activity == null) { + Text( + text = "当前页面无法拉起验证", + modifier = Modifier + .fillMaxWidth() + .padding(UiSpacing.PopupHorizontal), + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontWeight = FontWeight.Medium, + ) + return@ListPopupColumn + } + + spinnerEntries.forEachIndexed { index, spinnerEntry -> + SpinnerItemImpl( + entry = spinnerEntry, + entryCount = spinnerEntries.size, + isSelected = false, + index = index, + spinnerColors = SpinnerDefaults.spinnerColors(), + dialogMode = false, + onSelectedIndexChange = { selectedIndex -> + val target = entries[selectedIndex] + scope.launch { + passwordUseCase.preparePassword( + activity = activity, + entry = target, + globalRequiresAuth = appSettingsBundle.passwordRequireAuth, + ).onSuccess { password -> + InjectionController.inject(password) + onDismissRequest() + }.onFailure { + onMessage(it.message ?: "密码填充失败") + } + } + }, + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordRepository.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordRepository.kt new file mode 100644 index 0000000..3ab8389 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordRepository.kt @@ -0,0 +1,200 @@ +package io.github.miuzarte.scrcpyforandroid.password + +import android.content.SharedPreferences +import android.security.keystore.KeyPermanentlyInvalidatedException +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime +import io.github.miuzarte.scrcpyforandroid.storage.Storage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.security.GeneralSecurityException +import java.security.KeyStoreException +import java.util.UUID + +object PasswordRepository { + private const val PREFS_NAME = "LockscreenPasswords" + private const val KEY_ORDER = "__order" + private const val ENTRY_PREFIX = "entry." + private const val NAME_SUFFIX = ".name" + private const val PASSWORD_SUFFIX = ".password" + private const val CREATED_WITH_AUTH_SUFFIX = ".created_with_auth" + + class PasswordStorageCorruptedException(cause: Throwable) : Exception(cause) + + private val _entriesState = MutableStateFlow>(emptyList()) + val entriesState: StateFlow> = _entriesState.asStateFlow() + + private val prefs: SharedPreferences by lazy { + val context = AppRuntime.context + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + fun refresh() { + _entriesState.value = runCatching { getAll() }.getOrElse { emptyList() } + } + + fun getAll(): List { + return runCatching { + val all = prefs.all + val nameIds = all.keys + .filter { it.startsWith(ENTRY_PREFIX) && it.endsWith(NAME_SUFFIX) } + .map { it.removePrefix(ENTRY_PREFIX).removeSuffix(NAME_SUFFIX) } + .distinct() + val storedOrder = (all[KEY_ORDER] as? String) + .orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotBlank) + val orderedIds = (storedOrder + nameIds).distinct() + orderedIds.mapNotNull { id -> + val name = all[nameKey(id)] as? String ?: return@mapNotNull null + val rawPassword = all[passwordKey(id)] as? String + PasswordEntry( + id = id, + name = name, + cipherText = rawPassword?.toCharArray(), + createdWithAuth = parseCreatedState(all[createdWithAuthKey(id)]), + ) + } + }.getOrElse { throwable -> + throw storageExceptionOf(throwable) + } + } + + fun create( + name: String, + cipherText: CharArray, + createdWithAuth: PasswordCreatedState, + ): PasswordEntry { + val entry = PasswordEntry( + id = UUID.randomUUID().toString(), + name = name, + cipherText = cipherText.copyOf(), + createdWithAuth = createdWithAuth, + ) + add(entry) + return entry + } + + fun add(entry: PasswordEntry) { + editEntry(entry) + } + + fun update(entry: PasswordEntry) { + editEntry(entry) + } + + fun rename(id: String, name: String) { + prefs.edit { + putString(nameKey(id), name) + } + refresh() + } + + fun updateOrder(ids: List) { + prefs.edit { + putString(KEY_ORDER, ids.joinToString(",")) + } + refresh() + } + + fun delete(id: String) { + prefs.edit { + remove(nameKey(id)) + .remove(passwordKey(id)) + .remove(createdWithAuthKey(id)) + .putString(KEY_ORDER, getOrderedIds().filterNot { it == id }.joinToString(",")) + } + refresh() + } + + fun markInvalid(id: String) { + prefs.edit { + remove(passwordKey(id)) + } + refresh() + } + + fun clearAllSecretsKeepingEntries() { + val ids = getOrderedIds() + prefs.edit { + ids.forEach { remove(passwordKey(it)) } + } + refresh() + } + + private fun editEntry(entry: PasswordEntry) { + val password = entry.cipherText + try { + prefs.edit { + putString(nameKey(entry.id), entry.name) + putString(createdWithAuthKey(entry.id), entry.createdWithAuth.name) + if (password == null) { + remove(passwordKey(entry.id)) + } else { + putString(passwordKey(entry.id), String(password)) + } + val newOrder = (getOrderedIds() + entry.id).distinct() + putString(KEY_ORDER, newOrder.joinToString(",")) + } + refresh() + } finally { + password?.fill('\u0000') + } + } + + private fun getOrderedIds(): List { + return (prefs.getString(KEY_ORDER, "").orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotBlank) + getAll().map { it.id }).distinct() + } + + private fun nameKey(id: String) = "$ENTRY_PREFIX$id$NAME_SUFFIX" + private fun passwordKey(id: String) = "$ENTRY_PREFIX$id$PASSWORD_SUFFIX" + private fun createdWithAuthKey(id: String) = "$ENTRY_PREFIX$id$CREATED_WITH_AUTH_SUFFIX" + + private fun parseCreatedState(raw: Any?): PasswordCreatedState { + return when (raw) { + is String -> PasswordCreatedState.entries + .firstOrNull { it.name == raw } + ?: when (raw.lowercase()) { + "true" -> PasswordCreatedState.AuthenticatedCreated + "false" -> PasswordCreatedState.UnauthenticatedCreated + else -> PasswordCreatedState.UnauthenticatedCreated + } + + is Boolean -> if (raw) { + PasswordCreatedState.AuthenticatedCreated + } else { + PasswordCreatedState.UnauthenticatedCreated + } + + else -> PasswordCreatedState.UnauthenticatedCreated + } + } + + private fun storageExceptionOf(throwable: Throwable): PasswordStorageCorruptedException { + val cause = when (throwable) { + is PasswordStorageCorruptedException -> throwable.cause ?: throwable + is KeyStoreException, + is GeneralSecurityException, + is KeyPermanentlyInvalidatedException -> throwable + + else -> throwable + } + return PasswordStorageCorruptedException(cause) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordSanitizer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordSanitizer.kt new file mode 100644 index 0000000..41e24ab --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordSanitizer.kt @@ -0,0 +1,16 @@ +package io.github.miuzarte.scrcpyforandroid.password + +object PasswordSanitizer { + fun filterName(input: String): String { + return input + .filterNot(Char::isISOControl) + .replace('\u0000', ' ') + .trim() + } + + fun filterPassword(input: String): String { + return input.filter { ch -> + !ch.isISOControl() && ch != '\u0000' + } + } +} 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 new file mode 100644 index 0000000..9468f2f --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordUseCase.kt @@ -0,0 +1,31 @@ +package io.github.miuzarte.scrcpyforandroid.password + +import androidx.fragment.app.FragmentActivity + +class PasswordUseCase { + suspend fun preparePassword( + activity: FragmentActivity, + entry: PasswordEntry, + globalRequiresAuth: Boolean, + ): Result { + val canAuthNow = BiometricGate.canAuthenticate() + + if (globalRequiresAuth) { + val ok = BiometricGate.authenticate( + activity = activity, + title = "验证以填充锁屏密码", + subtitle = entry.name, + ) + if (!ok) { + return Result.failure(IllegalStateException("认证失败")) + } + } else if (entry.createdWithAuth.hasAuthenticatedOrigin && !canAuthNow) { + return Result.failure(IllegalStateException("设备安全状态已变更,请重新设置密码")) + } + + val password = entry.cipherText ?: return Result.failure( + IllegalStateException("密码已失效,请重新设置") + ) + return Result.success(password.copyOf()) + } +} 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 bf3d3a9..5cf5e96 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 @@ -17,6 +17,9 @@ object AppRuntime { AdbMdnsDiscoverer.init(appContext) } + val context: Context + get() = appContext + var scrcpy: Scrcpy? = null var currentConnectionTarget: ConnectionTarget? = null } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt index f754af5..dc95157 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.IntentFilter import android.os.Build import androidx.annotation.RequiresApi +import io.github.miuzarte.scrcpyforandroid.password.BiometricGate import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.runBlocking @@ -19,8 +20,8 @@ class PictureInPictureActionReceiver : BroadcastReceiver() { Thread { try { val appContext = context.applicationContext - Storage.init(appContext) AppRuntime.init(appContext) + AppWakeLocks.init(appContext) runBlocking { AppRuntime.scrcpy?.stop() } 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 5985242..6bc465a 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 @@ -52,7 +52,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { ) val VIRTUAL_BUTTONS_LAYOUT = Pair( stringPreferencesKey("virtual_buttons_layout"), - "more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0" + "more:1,password_input:0,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0" ) val CUSTOM_SERVER_URI = Pair( stringPreferencesKey("custom_server_uri"), @@ -78,8 +78,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { booleanPreferencesKey("adb_auto_reconnect_paired_device"), true ) - // 没必要加开关, 保持启用 val ADB_MDNS_LAN_DISCOVERY = Pair( + // 没必要加开关, 保持启用 booleanPreferencesKey("adb_mdns_lan_discovery"), true ) @@ -87,6 +87,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { booleanPreferencesKey("adb_auto_load_app_list_on_connect"), false ) + val PASSWORD_REQUIRE_AUTH = Pair( + booleanPreferencesKey("password_require_auth"), + true + ) val LAST_UPDATE_CHECK_AT = Pair( longPreferencesKey("last_update_check_at"), 0L @@ -118,6 +122,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) + val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH) val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT) @Parcelize @@ -140,6 +145,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbAutoReconnectPairedDevice: Boolean, val adbMdnsLanDiscovery: Boolean, val adbAutoLoadAppListOnConnect: Boolean, + val passwordRequireAuth: Boolean, val lastUpdateCheckAt: Long, ) : Parcelable { } @@ -163,6 +169,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice }, bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery }, bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect }, + bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth }, bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt }, ) @@ -189,6 +196,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE), adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY), adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT), + passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH), lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT), ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt index 26ee065..570b368 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt @@ -1,18 +1,12 @@ package io.github.miuzarte.scrcpyforandroid.storage -import android.content.Context +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime // settings singleton object Storage { - private lateinit var appContext: Context - - fun init(context: Context) { - appContext = context.applicationContext - } - - val appSettings: AppSettings by lazy { AppSettings(appContext) } - val quickDevices: QuickDevices by lazy { QuickDevices(appContext) } - val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) } - val scrcpyProfiles: ScrcpyProfiles by lazy { ScrcpyProfiles(appContext) } - val adbClientData: AdbClientData by lazy { AdbClientData(appContext) } + val appSettings: AppSettings by lazy { AppSettings(AppRuntime.context) } + val quickDevices: QuickDevices by lazy { QuickDevices(AppRuntime.context) } + val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(AppRuntime.context) } + val scrcpyProfiles: ScrcpyProfiles by lazy { ScrcpyProfiles(AppRuntime.context) } + val adbClientData: AdbClientData by lazy { AdbClientData(AppRuntime.context) } } 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 6ea87d1..6d74230 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 @@ -373,6 +373,7 @@ internal fun VirtualButtonCard( moreActions: List, showText: Boolean, onAction: (VirtualButtonAction) -> Unit, + passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, ) { val bar = remember(outsideActions, moreActions) { VirtualButtonBar( @@ -386,6 +387,7 @@ internal fun VirtualButtonCard( enabled = !busy, showText = showText, onAction = onAction, + passwordPopupContent = passwordPopupContent, modifier = Modifier .fillMaxWidth() .padding(UiSpacing.ContentVertical), 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 45b7953..a80f3cf 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 @@ -25,6 +25,7 @@ import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Password import androidx.compose.material.icons.rounded.PowerSettingsNew import androidx.compose.material.icons.rounded.Screenshot import androidx.compose.runtime.Composable @@ -138,6 +139,12 @@ enum class VirtualButtonAction( "截图", Icons.Rounded.Screenshot, UiAndroidKeycodes.SYSRQ + ), + PASSWORD_INPUT( + "password_input", + "填充锁屏密码", + Icons.Rounded.Password, + null ); } @@ -152,17 +159,30 @@ object VirtualButtonActions { private val byId = all.associateBy { it.id } fun parseStoredLayout(raw: String): List { - if (raw.isBlank()) - return parseStoredLayout(AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue) - - return raw.split(',').mapNotNull { item -> - val parts = item.trim().split(':') - if (parts.size != 2) return@mapNotNull null - val id = parts[0] - val showOutside = parts[1] == "1" - val action = byId[id] ?: return@mapNotNull null - VirtualButtonItem(action, showOutside) + val parsed = raw.takeIf { it.isNotBlank() } + ?.split(',') + ?.mapNotNull { item -> + val parts = item.trim().split(':') + if (parts.size != 2) return@mapNotNull null + val id = parts[0] + val showOutside = parts[1] == "1" + val action = byId[id] ?: return@mapNotNull null + VirtualButtonItem(action, showOutside) + } + .orEmpty() + .distinctBy { it.action.id } + val base = parsed.ifEmpty { + parseStoredLayout(AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue) } + val missing = all + .filterNot { action -> base.any { it.action == action } } + .map { action -> + VirtualButtonItem( + action = action, + showOutside = action == VirtualButtonAction.MORE, + ) + } + return base + missing } fun encodeStoredLayout(items: List): String { @@ -182,12 +202,18 @@ class VirtualButtonBar( private val outsideActions: List, private val moreActions: List, ) { + private enum class ActionPopupDestination { + Actions, + Passwords, + } + @Composable fun Preview( enabled: Boolean, showText: Boolean, onAction: (VirtualButtonAction) -> Unit, modifier: Modifier = Modifier, + passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, ) { val haptics = LocalAppHaptics.current val activeContainerColor = MiuixTheme.colorScheme.primary @@ -201,14 +227,22 @@ class VirtualButtonBar( horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { outsideActions.forEach { action -> + var showPasswordPopup by remember { mutableStateOf(false) } Box(modifier = Modifier.weight(1f)) { Button( onClick = { haptics.contextClick() - if (action == VirtualButtonAction.MORE) { - showMorePopup = true - } else { - onAction(action) + when (action) { + VirtualButtonAction.MORE -> { + showMorePopup = true + } + + VirtualButtonAction.PASSWORD_INPUT + if passwordPopupContent != null -> { + showPasswordPopup = true + } + + else -> onAction(action) } }, enabled = enabled, @@ -219,7 +253,9 @@ class VirtualButtonBar( ), insideMargin = PaddingValues(0.dp), ) { - val contentColor = if (enabled) activeContentColor else disabledContentColor + val contentColor = + if (enabled) activeContentColor + else disabledContentColor Icon( action.icon, contentDescription = action.title, @@ -240,9 +276,25 @@ class VirtualButtonBar( onAction(it) showMorePopup = false }, + passwordPopupContent = passwordPopupContent, renderInRootScaffold = false, ) } + if ( + action == VirtualButtonAction.PASSWORD_INPUT && + passwordPopupContent != null + ) { + OverlayListPopup( + show = showPasswordPopup, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = PopupPositionProvider.Align.TopEnd, + onDismissRequest = { showPasswordPopup = false }, + renderInRootScaffold = false, + enableWindowDim = false, + ) { + passwordPopupContent { showPasswordPopup = false } + } + } } } } @@ -252,10 +304,12 @@ class VirtualButtonBar( fun Fullscreen( onAction: suspend (VirtualButtonAction) -> Unit, modifier: Modifier = Modifier, + passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, ) { val scope = rememberCoroutineScope() val haptics = LocalAppHaptics.current var showMorePopup by remember { mutableStateOf(false) } + var showPasswordPopup by remember { mutableStateOf(false) } Row( modifier = modifier.fillMaxWidth(), @@ -266,12 +320,17 @@ class VirtualButtonBar( Button( onClick = { haptics.contextClick() - if (action == VirtualButtonAction.MORE) { - showMorePopup = true - } else { - scope.launch { - onAction(action) + when (action) { + VirtualButtonAction.MORE -> { + showMorePopup = true } + + VirtualButtonAction.PASSWORD_INPUT + if passwordPopupContent != null -> { + showPasswordPopup = true + } + + else -> scope.launch { onAction(action) } } }, modifier = Modifier.fillMaxWidth(), @@ -291,15 +350,33 @@ class VirtualButtonBar( actions = moreActions, onDismiss = { showMorePopup = false }, onAction = { - onAction(it) + if (it == VirtualButtonAction.PASSWORD_INPUT + && passwordPopupContent != null + ) showPasswordPopup = true + else onAction(it) + showMorePopup = false }, + passwordPopupContent = passwordPopupContent, renderInRootScaffold = true, ) } } } } + + if (passwordPopupContent != null) { + OverlayListPopup( + show = showPasswordPopup, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = PopupPositionProvider.Align.TopEnd, + onDismissRequest = { showPasswordPopup = false }, + renderInRootScaffold = true, + enableWindowDim = false, + ) { + passwordPopupContent { showPasswordPopup = false } + } + } } @Composable @@ -307,11 +384,13 @@ class VirtualButtonBar( actions: List, onAction: suspend (VirtualButtonAction) -> Unit, modifier: Modifier = Modifier, + passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, ) { val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val haptics = LocalAppHaptics.current var showActions by remember { mutableStateOf(false) } + var showPasswordPopup by remember { mutableStateOf(false) } val asBundleShared by appSettings.bundleState.collectAsState() val asBundleSharedLatest by rememberUpdatedState(asBundleShared) var offsetXFraction by rememberSaveable(asBundleShared.fullscreenFloatingButtonXFraction) { @@ -374,18 +453,16 @@ class VirtualButtonBar( }, ) { change, dragAmount -> change.consume() - val nextX = - (maxX.toPx() * dragStartXFraction + dragAmount.x).coerceIn( - 0f, - maxX.toPx() - ) - val nextY = - (maxY.toPx() * dragStartYFraction + dragAmount.y).coerceIn( - 0f, - maxY.toPx() - ) - val nextXFraction = if (maxX > 0.dp) nextX / maxX.toPx() else 0f - val nextYFraction = if (maxY > 0.dp) nextY / maxY.toPx() else 0f + val nextX = (maxX.toPx() * dragStartXFraction + dragAmount.x) + .coerceIn(0f, maxX.toPx()) + val nextY = (maxY.toPx() * dragStartYFraction + dragAmount.y) + .coerceIn(0f, maxY.toPx()) + val nextXFraction = + if (maxX > 0.dp) nextX / maxX.toPx() + else 0f + val nextYFraction = + if (maxY > 0.dp) nextY / maxY.toPx() + else 0f dragStartXFraction = nextXFraction dragStartYFraction = nextYFraction offsetXFraction = nextXFraction @@ -419,14 +496,30 @@ class VirtualButtonBar( actions = actions, onDismiss = { showActions = false }, onAction = { - scope.launch { - onAction(it) - } + if (it == VirtualButtonAction.PASSWORD_INPUT && + passwordPopupContent != null + ) showPasswordPopup = true + else scope.launch { onAction(it) } + showActions = false }, + passwordPopupContent = passwordPopupContent, renderInRootScaffold = true, popupAlignment = popupAlignment, ) + + if (passwordPopupContent != null) { + OverlayListPopup( + show = showPasswordPopup, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = popupAlignment, + onDismissRequest = { showPasswordPopup = false }, + renderInRootScaffold = true, + enableWindowDim = false, + ) { + passwordPopupContent { showPasswordPopup = false } + } + } } } } @@ -437,6 +530,7 @@ class VirtualButtonBar( actions: List, onDismiss: () -> Unit, onAction: suspend (VirtualButtonAction) -> Unit, + passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, renderInRootScaffold: Boolean, popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd, ) { @@ -458,6 +552,60 @@ class VirtualButtonBar( } } + NavOverlayListPopup( + show = show, + startDestination = ActionPopupDestination.Actions, + popupAlignment = popupAlignment, + onDismiss = onDismiss, + renderInRootScaffold = renderInRootScaffold, + ) { destination, navigateTo, dismiss -> + ListPopupColumn { + if (destination == ActionPopupDestination.Actions) + spinnerItems.forEachIndexed { index, entry -> + SpinnerItemImpl( + entry = entry, + entryCount = spinnerItems.size, + isSelected = false, + index = index, + spinnerColors = SpinnerDefaults.spinnerColors(), + dialogMode = false, + onSelectedIndexChange = { selectedIdx -> + haptics.confirm() + val selectedAction = actions[selectedIdx] + if ( + selectedAction == VirtualButtonAction.PASSWORD_INPUT && + passwordPopupContent != null + ) { + navigateTo(ActionPopupDestination.Passwords) + } else { + scope.launch { onAction(selectedAction) } + dismiss() + } + }, + ) + } + else if (passwordPopupContent != null) + passwordPopupContent { dismiss() } + else + dismiss() + } + } + } + + @Composable + private fun NavOverlayListPopup( + show: Boolean, + startDestination: Destination, + popupAlignment: PopupPositionProvider.Align, + onDismiss: () -> Unit, + renderInRootScaffold: Boolean, + content: @Composable ( + destination: Destination, + navigateTo: (Destination) -> Unit, + dismiss: () -> Unit, + ) -> Unit, + ) { + var destination by remember(show, startDestination) { mutableStateOf(startDestination) } OverlayListPopup( show = show, popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, @@ -466,24 +614,7 @@ class VirtualButtonBar( renderInRootScaffold = renderInRootScaffold, enableWindowDim = false, ) { - ListPopupColumn { - spinnerItems.forEachIndexed { index, entry -> - SpinnerItemImpl( - entry = entry, - entryCount = spinnerItems.size, - isSelected = false, - index = index, - spinnerColors = SpinnerDefaults.spinnerColors(), - dialogMode = false, - onSelectedIndexChange = { selectedIdx -> - haptics.confirm() - scope.launch { - onAction(actions[selectedIdx]) - } - }, - ) - } - } + content(destination, { destination = it }, onDismiss) } } } diff --git a/doc/scheme/锁屏密码自动填充实现设计方案初案.md b/doc/scheme/锁屏密码自动填充实现设计方案初案.md new file mode 100644 index 0000000..26582b6 --- /dev/null +++ b/doc/scheme/锁屏密码自动填充实现设计方案初案.md @@ -0,0 +1,174 @@ +# 锁屏密码自动填充实现设计方案初案 + +## 用户输入 + +- Activity `onCreate` 设置 `FLAG_SECURE` +- TextField 设置 + - `visualTransformation = PasswordVisualTransformation()` + - `keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, autoCorrect = false)` +- `onPause` / `onStop` 时主动清空 TextField +- 密码允许字符字母数字混合输入,明确白名单/黑名单,禁止 `\n` 类似的控制字符注入 + - 不考虑连线式密码,直接无视,不需要写任何提示 + +## 密码存取 + +- 调用前使用 `BiometricManager` 确认硬件状态。若设备未设置任何锁屏保护,每次进入设置密码的 activity 前都提示风险,且按下“同意”后才允许继续 +- 使用 `BiometricPrompt` 实现验证 ,开关允许跳过验证直接使用,当用户在设置页面将开关从 ON 切换到 OFF 时,除非此时不存在任何密码,否则立即拉起一次 `BiometricPrompt` + - `BiometricPrompt` 的验证器类型包括 `BIOMETRIC_STRONG` 和 `DEVICE_CREDENTIAL`,即同时允许本机密码验证 + - 验证过程受 Activity 生命周期约束,`onStop` 时自动取消挂起的验证请求 +- 使用 `EncryptedSharedPreferences` 储存密码 + - 读取时若捕获到 `KeyStoreException` / `GeneralSecurityException`,静默清除后提示密钥损坏并引导重新设置密码,注意静默清除后同步验证是否可用的状态 +- 明文密码使用 `CharArray` 类型,密码仅在 BiometricPrompt 回调成功的瞬间提取 + - 在传递给 Shell 执行层后,必须在 `finally` 块中执行 `fill(0)`,确保即使后续代码异常,内存也能被清理 + - 严禁将明文密码赋值给长生命周期的成员变量 + - 严禁任何日志泄露 +- 每次调用密码自动输入时都判断按需触发验证,不做长期信任 +- 密码附带一个字段,判断密码的创建环境和使用环境是否匹配,不允许密码在认证的情况下被添加,但是在无认证的时候被使用,因为我允许无验证的情况下使用这个功能 + - `val createdWithAuth: Boolean` + +```kotlin +val globalRequiresAuth: Boolean // 全局开关 +val canAuthNow: Boolean // 当前设备是否可认证 + +if (globalRequiresAuth) { + // 只要开关开着,就必须认证 + BiometricPrompt() +} else { + // 开关关闭 → 允许不认证,但要防止安全降级 + if (pw.createdWithAuth && !canAuthNow) { + // 降级禁止 + showError("设备安全状态已变更,请重新设置密码") + return + } + + // 可以直接使用 + usePassword() +} +``` + +- 在应用启动时就获取设备安全状态,并遍历一遍密码,如果存在安全添加的密码,但是设备变得不安全,就静默清除那个密码 + - 只清除密文,但保留这个 item,让用户可以在密码管理界面看到哪个密码变得不可用 (endAction 多一个 `Block` 图标) + - 显示在 OverlayListPopup 里时也要是不可用的状态,不可点击 + +## 交互设计 + +新增入口 `ArrowPreference` `锁屏密码自动输入` 在设置页 `投屏` section 中的最后一个 + +点击后跳转上述中带 `FLAG_SECURE` 的新 activity,接下来所有的交互都在这个安全的 activity 内 + +首屏页参考目前的 `虚拟按钮排序` 页,使用项目内包装好的 `LazyColumn` + +第一个选项为跳过验证的总开关 `使用密码时需要验证`,在设备无认证条件的时候禁用且(enabled = false),正常默认启用,关闭时提示安全性风险且需要同意,有密码时拉起认证,通过认证成功关闭后遍历一遍当前所有密码,将 `requiresAuth` 都设置为 false + +接下来是密码列表,参考 `虚拟按钮排序` 页,允许排序和命名,命名的交互逻辑参考 `管理配置` 的 BottomSheet,在 endAction 中放一个 `Icons.Rounded.Edit` 图标按钮,右上角三点菜单放 `新增密码` 的入口 + +点击新增密码后根据开关判断是否拉起生物验证,通过/跳过后拉起一个 `OverlayBottomSheet`,里面放一个命名 TextField 和一个密码输入 TextField,左上角关闭按钮,右上角确认按钮,参考 `B:\Git\miuix\example\shared\src\commonMain\kotlin\component\BottomSheetSection.kt` 里的 `private fun SuperBottomSheetDemo`,确认前判断密码框输入长度,禁止空密码,确认后主动清空两个 TextField + +使用入口 `填充锁屏密码` 新增在目前虚拟按钮列表 `class VirtualButtonBar` 中,图标用 `Password2`,点击呼出另一个 `OverlayListPopup` (参考 `VirtualButtonBar.ActionPopup`),里面放按用户排序的密码,点击任意一个就判断是否要验证来选择呼出生物验证还是不需要验证直接注入密码到 `Scrcpy.injectText` + +## 代码设计 + +```kotlin +// 单例 +// 内部使用 EncryptedSharedPreferences +// 不处理认证逻辑 +object PasswordRepository { + fun getAll(): List + fun add(entry: PasswordEntry) + fun update(entry: PasswordEntry) + fun delete(id: String) + + fun markInvalid(id: String) // 清除密文但保留 entry +} +``` + +```kotlin +// 明文 +data class PasswordEntry( + val id: String, + val name: String, + val cipherText: CharArray?, // null = 已失效 + val createdWithAuth: Boolean +) +``` + +```kotlin +// 封装: +// BiometricManager +// BiometricPrompt +// 内部要处理: +// lifecycle +// callback 防重复(sessionId) +object BiometricGate { + fun canAuthenticate(): Boolean + suspend fun authenticate(activity: FragmentActivity): Boolean +} +``` + +```kotlin +class PasswordUseCase { + + suspend fun preparePassword( + activity: FragmentActivity, + entry: PasswordEntry, + globalRequiresAuth: Boolean + ): Result { + + val canAuthNow = BiometricGate.canAuthenticate() + + // 1. 需要认证 + if (globalRequiresAuth) { + val ok = BiometricGate.authenticate(activity) + if (!ok) { + return Result.failure(Exception("认证失败")) + } + } else { + // 2. 不认证,但防降级 + if (entry.createdWithAuth && !canAuthNow) { + return Result.failure(Exception("设备安全状态已变更")) + } + } + + // 3. 取密码 + val pw = entry.cipherText ?: return Result.failure(Exception("密码已失效")) + + return Result.success(pw.copyOf()) // 一定要 copy + } +} + +// 用法 +val result = passwordUseCase.preparePassword( + activity, + entry, + globalRequiresAuth +) + +result.onSuccess { pw -> + InjectionController.inject(pw) +}.onFailure { + showError(it.message) +} +``` + +```kotlin +object InjectionController { + fun inject(password: CharArray) { + try { + Scrcpy.injectText(String(password)) + } finally { + password.fill(0) + } + } +} +``` + +```kotlin +// 应用启动 +val canAuthNow = BiometricGate.canAuthenticate() + +PasswordRepository.getAll().forEach { + if (it.createdWithAuth && !canAuthNow) { + PasswordRepository.markInvalid(it.id) + } +} +``` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3f3cee..8683c13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ miuix = "0.9.0" material = "1.13.0" runtime = "1.10.5" androidxCorePip = "1.0.0-alpha02" +androidxBiometric = "1.4.0-alpha04" +androidxSecurityCrypto = "1.1.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +40,8 @@ miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.r miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "androidxCorePip" } +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometric" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "androidxSecurityCrypto" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }