feat: biometric auth and lockscreen password autofill

This commit is contained in:
Miuzarte
2026-04-13 23:59:03 +08:00
parent 2ddbbcf8b9
commit 3a6df23a6c
28 changed files with 1623 additions and 88 deletions

View File

@@ -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))

View File

@@ -38,6 +38,12 @@
android:resizeableActivity="true"
android:supportsPictureInPicture="true" />
<activity
android:name=".LockscreenPasswordActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
android:enableOnBackInvokedCallback="true"
android:exported="false" />
<!-- <profileable android:shell="true" /> -->
</application>

View File

@@ -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<String?>(null) }
var dialogMode by rememberSaveable { mutableStateOf<PasswordDialogMode?>(null) }
var editingId by rememberSaveable { mutableStateOf<String?>(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<PasswordEntry>,
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))
}
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -121,6 +121,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
DeviceShortcuts(devices)
}
// TODO: 增加 id 字段,解决编辑端口的问题
data class DeviceShortcut(
val name: String = "",
val host: String,

View File

@@ -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) }
},
)
},
)
}
}

View File

@@ -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) }
},
)
}
},
)
}

View File

@@ -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此选项将禁用此行为"
)
},
)

View File

@@ -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 = "在全屏控制页底部显示返回键、主页键等虚拟按钮",

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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() }
}
}
}

View File

@@ -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')
}
}
}

View File

@@ -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,
)

View File

@@ -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 ?: "密码填充失败")
}
}
},
)
}
}
}

View File

@@ -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<List<PasswordEntry>>(emptyList())
val entriesState: StateFlow<List<PasswordEntry>> = _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<PasswordEntry> {
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<String>) {
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<String> {
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)
}
}

View File

@@ -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'
}
}
}

View File

@@ -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<CharArray> {
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())
}
}

View File

@@ -17,6 +17,9 @@ object AppRuntime {
AdbMdnsDiscoverer.init(appContext)
}
val context: Context
get() = appContext
var scrcpy: Scrcpy? = null
var currentConnectionTarget: ConnectionTarget? = null
}

View File

@@ -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()
}

View File

@@ -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),
)

View File

@@ -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) }
}

View File

@@ -373,6 +373,7 @@ internal fun VirtualButtonCard(
moreActions: List<VirtualButtonAction>,
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),

View File

@@ -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<VirtualButtonItem> {
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<VirtualButtonItem>): String {
@@ -182,12 +202,18 @@ class VirtualButtonBar(
private val outsideActions: List<VirtualButtonAction>,
private val moreActions: List<VirtualButtonAction>,
) {
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<VirtualButtonAction>,
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<VirtualButtonAction>,
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 <Destination> 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)
}
}
}